mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
Compare commits
129
Commits
embeddings
...
45ffa39f2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ffa39f2a | ||
|
|
b656fd04c0 | ||
|
|
97bff35323 | ||
|
|
4a53e09ab3 | ||
|
|
2fb484ebaa | ||
|
|
21efdd4eef | ||
|
|
be0d7ad297 | ||
|
|
99552c57ad | ||
|
|
efc9900de1 | ||
|
|
e3ca264c54 | ||
|
|
f5e16c7e71 | ||
|
|
e45ac28169 | ||
|
|
3d578a9eb8 | ||
|
|
db04da6a2c | ||
|
|
321fac9aa8 | ||
|
|
4a5aef5a3d | ||
|
|
f98ab24d2e | ||
|
|
c5b333bbb3 | ||
|
|
e842923d81 | ||
|
|
51bfb53a39 | ||
|
|
45d33b6bad | ||
|
|
3eb3a968dc | ||
|
|
67d9757f98 | ||
|
|
b6b41b6ba9 | ||
|
|
bdc08bc58e | ||
|
|
83cfcc66c0 | ||
|
|
e878dea235 | ||
|
|
252edc3a6d | ||
|
|
75b13280ef | ||
|
|
7007df2966 | ||
|
|
61673a646d | ||
|
|
1eb8eb2f04 | ||
|
|
ae4482a551 | ||
|
|
884bbe65c7 | ||
|
|
3de55ba216 | ||
|
|
6e74fffb65 | ||
|
|
1ae2b2ac6c | ||
|
|
a188be167b | ||
|
|
e550965086 | ||
|
|
03429b17cc | ||
|
|
3ee7331510 | ||
|
|
592fa917cd | ||
|
|
ad1774e6b0 | ||
|
|
e1a665ba63 | ||
|
|
f3c9feaf97 | ||
|
|
a5cf248e29 | ||
|
|
f9f3f3de12 | ||
|
|
abab886d1a | ||
|
|
acc82af7cd | ||
|
|
fc674d6008 | ||
|
|
087a476df8 | ||
|
|
ba45cbdc37 | ||
|
|
1de436534c | ||
|
|
3bcd7ea266 | ||
|
|
7ecc84ffbc | ||
|
|
3d78353a90 | ||
|
|
bd36f29419 | ||
|
|
3036264d44 | ||
|
|
6554ef8f41 | ||
|
|
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 | ||
|
|
3947e8adb0 | ||
|
|
244113671e | ||
|
|
5b9b2f76f4 | ||
|
|
f7ec0742e1 | ||
|
|
d797b980ea | ||
|
|
2bd86d37a1 | ||
|
|
33d6fae446 | ||
|
|
ff228fb547 | ||
|
|
d21ca9d687 | ||
|
|
2df97f66bf | ||
|
|
b1420a3e4f | ||
|
|
fe9cc09df6 | ||
|
|
55a9fd59ea | ||
|
|
0ee3d8a4d0 | ||
|
|
6365cfb363 | ||
|
|
860839fd75 | ||
|
|
e4b946ec8c | ||
|
|
b80c18c2aa | ||
|
|
8c488d27df | ||
|
|
77d6254a2c | ||
|
|
7fbed528f8 | ||
|
|
cec85be7c9 | ||
|
|
c50336ab5f | ||
|
|
03645f0452 | ||
|
|
cb11d793f6 | ||
|
|
ac1db94b0d | ||
|
|
1b1bc7bd3c | ||
|
|
0f6870344f |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"deny": [
|
||||||
|
"Read(./.env)",
|
||||||
|
"Read(./secrets/**)",
|
||||||
|
"Read(./config/credentials.json)",
|
||||||
|
"Read(./build)",
|
||||||
|
"Read(./embeddings**)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
DATABASE_URL=example.db
|
||||||
|
|
||||||
|
# Local-only admin bypass for testing /admin/stats without the real admin
|
||||||
|
# account. NEVER enable in production (it is also force-disabled when
|
||||||
|
# NODE_ENV=production).
|
||||||
|
# DEV_ADMIN_ALL=true
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Discord admin webhook for the daily statistics digest (optional; digest is skipped if unset)
|
||||||
|
DISCORD_ADMIN_WEBHOOK=https://discord.com/api/webhooks/your-admin-webhook-url
|
||||||
|
|
||||||
|
# Statistics thresholds (optional; these override the built-in defaults)
|
||||||
|
# STATS_CORE_PLAYS_PER_WEEK=4
|
||||||
|
# STATS_CHURN_DAYS=30
|
||||||
|
# STATS_D1_TARGET=20
|
||||||
|
# STATS_RETURN7_TARGET=25
|
||||||
|
# STATS_FIRST_TRY_MIN=15
|
||||||
|
# STATS_FIRST_TRY_MAX=35
|
||||||
|
|
||||||
|
PUBLIC_SITE_URL=https://bibdle.com
|
||||||
|
|
||||||
|
# nodemailer
|
||||||
|
SMTP_USERNAME=email@example.com
|
||||||
|
SMTP_TOKEN=TOKEN
|
||||||
|
SMTP_SERVER=smtp.example.com
|
||||||
|
SMTP_PORT=port
|
||||||
|
# note from mail provider: Enable TLS or SSL on the external service if it is supported.
|
||||||
|
|
||||||
|
# sign in with Discord
|
||||||
|
|
||||||
|
# sign in with google
|
||||||
|
|
||||||
|
# sign in with apple
|
||||||
|
AUTH_SECRET=your-random-secret-here
|
||||||
|
APPLE_ID=com.yourcompany.yourapp.client
|
||||||
|
APPLE_TEAM_ID=your-team-id
|
||||||
|
APPLE_KEY_ID=your-key-id
|
||||||
|
APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
|
||||||
|
your-private-key-here
|
||||||
|
-----END PRIVATE KEY-----"
|
||||||
+5
-2
@@ -27,6 +27,9 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
llms-*
|
llms-*
|
||||||
|
|
||||||
|
embeddings*
|
||||||
|
*bible.xml
|
||||||
engwebu_usfx.xml
|
engwebu_usfx.xml
|
||||||
embeddings-cache-L12.json
|
|
||||||
embeddings-cache-L6.json
|
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
|
|
||||||
+3
-3
@@ -24143,7 +24143,7 @@
|
|||||||
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
<verse number="16">Gather the people, Sanctify the congregation, Assemble the elders, Gather the children and nursing babes; Let the bridegroom go out from his chamber, And the bride from her dressing room.</verse>
|
||||||
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
<verse number="17">Let the priests, who minister to the Lord, Weep between the porch and the altar; Let them say, “Spare Your people, O Lord, And do not give Your heritage to reproach, That the nations should rule over them. Why should they say among the peoples, ‘Where is their God?’ ”</verse>
|
||||||
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
<verse number="18">Then the Lord will be zealous for His land, And pity His people.</verse>
|
||||||
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.</verse>
|
<verse number="19">The Lord will answer and say to His people, “Behold, I will send you grain and new wine and oil, And you will be satisfied by them; I will no longer make you a reproach among the nations.”</verse>
|
||||||
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
<verse number="20">“But I will remove far from you the northern army, And will drive him away into a barren and desolate land, With his face toward the eastern sea And his back toward the western sea; His stench will come up, And his foul odor will rise, Because he has done monstrous things.”</verse>
|
||||||
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
<verse number="21">Fear not, O land; Be glad and rejoice, For the Lord has done marvelous things!</verse>
|
||||||
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
<verse number="22">Do not be afraid, you beasts of the field; For the open pastures are springing up, And the tree bears its fruit; The fig tree and the vine yield their strength.</verse>
|
||||||
@@ -25056,7 +25056,7 @@
|
|||||||
<chapter number="3">
|
<chapter number="3">
|
||||||
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
|
<verse number="1">“Behold, I send My messenger, And he will prepare the way before Me. And the Lord, whom you seek, Will suddenly come to His temple, Even the Messenger of the covenant, In whom you delight. Behold, He is coming,” Says the Lord of hosts.</verse>
|
||||||
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiner’s fire And like launderers’ soap.</verse>
|
<verse number="2">“But who can endure the day of His coming? And who can stand when He appears? For He is like a refiner’s fire And like launderers’ soap.</verse>
|
||||||
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, And purge them as gold and silver, That they may offer to the LordAn offering in righteousness.</verse>
|
<verse number="3">He will sit as a refiner and a purifier of silver; He will purify the sons of Levi, and purge them as gold and silver, that they may offer to the Lord an offering in righteousness.</verse>
|
||||||
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
|
<verse number="4">“Then the offering of Judah and Jerusalem Will be pleasant to the Lord, As in the days of old, As in former years.</verse>
|
||||||
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
|
<verse number="5">And I will come near you for judgment; I will be a swift witness Against sorcerers, Against adulterers, Against perjurers, Against those who exploit wage earners and widows and orphans, And against those who turn away an alien— Because they do not fear Me,” Says the Lord of hosts.</verse>
|
||||||
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
|
<verse number="6">“For I am the Lord, I do not change; Therefore you are not consumed, O sons of Jacob.</verse>
|
||||||
@@ -33616,4 +33616,4 @@
|
|||||||
</chapter>
|
</chapter>
|
||||||
</book>
|
</book>
|
||||||
</testament>
|
</testament>
|
||||||
</bible>
|
</bible>
|
||||||
@@ -1,38 +1,209 @@
|
|||||||
# 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
|
The statistics dashboard migration (`drizzle/0004_slim_nomad.sql`) is **purely additive**: it creates `stats_snapshot`, `analytics_events` and `dashboard_settings`, and adds a nullable `user.created_at`. It never alters or drops historical rows. To replicate to production, apply that SQL to `prod.db` (e.g. `sqlite3 prod.db < drizzle/0004_slim_nomad.sql`) **before** deploying the new code. Applying it twice is safe for the new tables only if you skip the existing objects; run it once.
|
||||||
|
|
||||||
To create a production version of your app:
|
### Environment Variables
|
||||||
|
|
||||||
```sh
|
See `.env.example`. Required/used variables:
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
- `DATABASE_URL` — path to the SQLite database file (e.g. `prod.db`)
|
||||||
|
- `PUBLIC_SITE_URL` — canonical site URL
|
||||||
|
- `CRON_SECRET` — bearer token protecting the cron-only `/api/send-daily-verse` and `/api/cron/build-stats` endpoints
|
||||||
|
- `DISCORD_DAILY_WEBHOOK` — webhook for posting the daily verse to Discord
|
||||||
|
- `DISCORD_ADMIN_WEBHOOK` — optional webhook for the daily statistics digest (digest is skipped when unset)
|
||||||
|
- `STATS_*` — optional overrides for dashboard thresholds (`STATS_D1_TARGET`, `STATS_CORE_PLAYS_PER_WEEK`, …); see `src/lib/types/stats.ts` for defaults
|
||||||
|
- `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)
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
## Architecture
|
||||||
|
|
||||||
|
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||||
|
|
||||||
|
- **user** — `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `googleId` (unique), `isPrivate`, `createdAt` (nullable; existing accounts show "unknown")
|
||||||
|
- **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`.
|
||||||
|
- **statsSnapshot** — one immutable JSON aggregate row per as-of day (`date` PK, `generatedAt`, `buildKind`, `configVersion`, `payload`, `playerState`, `config`). Powers `/admin/stats` with point-in-time reads.
|
||||||
|
- **analyticsEvents** — generic event log (`id`, `name`, `anonymousId`, `userId`, `date`, `createdAt`, `metadata`) with a partial unique index deduping `verse_served` per player/day.
|
||||||
|
- **dashboardSettings** — key/value overrides for dashboard thresholds.
|
||||||
|
|
||||||
|
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` | Slim public community-stats widget: all-time finishes/players, weekly/monthly players, yesterday's finishes + average guesses, and a 30-day finish calendar. Reads the latest daily snapshot; no retention/churn/signup data. |
|
||||||
|
| `/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. |
|
||||||
|
| `/admin/stats` | Admin-only private **Growth Console** (gated to `ADMIN_EMAIL`). Once-daily, point-in-time snapshot; date navigator, “Mature this view”, editable thresholds, Pulse, Growth Loop, Retention, Verse Mix, Habit, Signals and Glossary. Set `?date=YYYY-MM-DD`; `?mature=1` recomputes old cohorts using later data. |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /api/daily-verse` | Fetch (and cache) the verse for a given `YYYY-MM-DD` date. Also records a deduped `verse_served` event for the supplied `anonymousId` (started→finished funnel). |
|
||||||
|
| `POST /api/events` | Fire-and-forget analytics sink. Allowlisted names: `verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`; `userId` resolved from the session. Always returns 204. |
|
||||||
|
| `POST /api/cron/build-stats` | Cron-only (bearer `CRON_SECRET`). Builds yesterday's snapshot (`America/New_York`) and posts the digest to `DISCORD_ADMIN_WEBHOOK`. |
|
||||||
|
| `POST /api/admin/rebuild-stats` | Admin-only. Rebuilds one snapshot: `{ date: 'YYYY-MM-DD' }`. |
|
||||||
|
| `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, and `stats-*` (metrics, segments, settings, events, signals, snapshot, digest) |
|
||||||
|
| `src/lib/server/stats/*` | Statistics engine: `metrics.ts`, `segments.ts`, `snapshot.ts`, `signals.ts`, `settings.ts`, `events.ts`, `digest.ts`, `dates.ts`. |
|
||||||
|
| `src/lib/components/stats/*` | Growth Console UI (Pulse, Growth Loop, Retention/Verse Mix/Habit labs, Signals, Settings, Glossary). |
|
||||||
|
| `src/lib/utils/analytics.ts` | Client event helper (`track`, anonymous ID, first-visit capture). |
|
||||||
|
|
||||||
|
## 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.
|
||||||
+8
-7
@@ -1,18 +1,19 @@
|
|||||||
[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
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -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 |
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
BUN=$(which bun)
|
||||||
|
|
||||||
|
echo "Pulling latest changes..."
|
||||||
|
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..."
|
||||||
|
$BUN i
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
$BUN --bun run build
|
||||||
|
|
||||||
|
SERVICE_NAME="$(basename "$(pwd)").service"
|
||||||
|
echo "Restarting service ($SERVICE_NAME)..."
|
||||||
|
sudo systemctl restart "$SERVICE_NAME"
|
||||||
|
|
||||||
|
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,31 @@
|
|||||||
|
CREATE TABLE `analytics_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`anonymous_id` text,
|
||||||
|
`user_id` text,
|
||||||
|
`date` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`metadata` text,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `analytics_events_name_date_idx` ON `analytics_events` (`name`,`date`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `analytics_events_anonymous_id_date_idx` ON `analytics_events` (`anonymous_id`,`date`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `analytics_events_serve_unique` ON `analytics_events` (`name`,`anonymous_id`,`date`) WHERE "analytics_events"."name" = 'verse_served';--> statement-breakpoint
|
||||||
|
CREATE TABLE `dashboard_settings` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `stats_snapshot` (
|
||||||
|
`date` text PRIMARY KEY NOT NULL,
|
||||||
|
`generated_at` integer NOT NULL,
|
||||||
|
`build_kind` text NOT NULL,
|
||||||
|
`config_version` text NOT NULL,
|
||||||
|
`payload` text NOT NULL,
|
||||||
|
`player_state` text,
|
||||||
|
`config` text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `user` ADD `created_at` integer;
|
||||||
@@ -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,384 @@
|
|||||||
|
{
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"verse_submissions": {
|
||||||
|
"name": "verse_submissions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scheduled_date": {
|
||||||
|
"name": "scheduled_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_book_id": {
|
||||||
|
"name": "selected_book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_chapter": {
|
||||||
|
"name": "selected_chapter",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_verse": {
|
||||||
|
"name": "selected_verse",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"submitted_at": {
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verse_submissions_scheduled_date_unique": {
|
||||||
|
"name": "verse_submissions_scheduled_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"scheduled_date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"verse_submissions_user_id_idx": {
|
||||||
|
"name": "verse_submissions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"verse_submissions_user_id_user_id_fk": {
|
||||||
|
"name": "verse_submissions_user_id_user_id_fk",
|
||||||
|
"tableFrom": "verse_submissions",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "6b6e3771-d705-4bbb-9b36-97f4f402fc5d",
|
||||||
|
"prevId": "80883fb9-70cd-4fa5-b228-36358ffc4c40",
|
||||||
|
"tables": {
|
||||||
|
"analytics_events": {
|
||||||
|
"name": "analytics_events",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"anonymous_id": {
|
||||||
|
"name": "anonymous_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"name": "metadata",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"analytics_events_name_date_idx": {
|
||||||
|
"name": "analytics_events_name_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"name",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"analytics_events_anonymous_id_date_idx": {
|
||||||
|
"name": "analytics_events_anonymous_id_date_idx",
|
||||||
|
"columns": [
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
},
|
||||||
|
"analytics_events_serve_unique": {
|
||||||
|
"name": "analytics_events_serve_unique",
|
||||||
|
"columns": [
|
||||||
|
"name",
|
||||||
|
"anonymous_id",
|
||||||
|
"date"
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"where": "\"analytics_events\".\"name\" = 'verse_served'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"analytics_events_user_id_user_id_fk": {
|
||||||
|
"name": "analytics_events_user_id_user_id_fk",
|
||||||
|
"tableFrom": "analytics_events",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"dashboard_settings": {
|
||||||
|
"name": "dashboard_settings",
|
||||||
|
"columns": {
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"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": {}
|
||||||
|
},
|
||||||
|
"stats_snapshot": {
|
||||||
|
"name": "stats_snapshot",
|
||||||
|
"columns": {
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generated_at": {
|
||||||
|
"name": "generated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"build_kind": {
|
||||||
|
"name": "build_kind",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config_version": {
|
||||||
|
"name": "config_version",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"name": "payload",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"player_state": {
|
||||||
|
"name": "player_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"name": "config",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": 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": {}
|
||||||
|
},
|
||||||
|
"verse_submissions": {
|
||||||
|
"name": "verse_submissions",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scheduled_date": {
|
||||||
|
"name": "scheduled_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_book_id": {
|
||||||
|
"name": "selected_book_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_chapter": {
|
||||||
|
"name": "selected_chapter",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"selected_verse": {
|
||||||
|
"name": "selected_verse",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"submitted_at": {
|
||||||
|
"name": "submitted_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verse_submissions_scheduled_date_unique": {
|
||||||
|
"name": "verse_submissions_scheduled_date_unique",
|
||||||
|
"columns": [
|
||||||
|
"scheduled_date"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"verse_submissions_user_id_idx": {
|
||||||
|
"name": "verse_submissions_user_id_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"verse_submissions_user_id_user_id_fk": {
|
||||||
|
"name": "verse_submissions_user_id_user_id_fk",
|
||||||
|
"tableFrom": "verse_submissions",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,34 @@
|
|||||||
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1789505566968,
|
||||||
|
"tag": "0004_slim_nomad",
|
||||||
|
"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"
|
||||||
+18
-17
@@ -1,42 +1,43 @@
|
|||||||
{
|
{
|
||||||
"name": "bibdle",
|
"name": "bibdle",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "3.0.0alpha",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "bun --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "bun --bun 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",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
"@oslojs/encoding": "^1.1.0",
|
"@sveltejs/kit": "^2.50.1",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@sveltejs/kit": "^2.49.1",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22.19.7",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.0",
|
"drizzle-orm": "^0.45.1",
|
||||||
"svelte": "^5.45.6",
|
"svelte": "^5.48.5",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.5",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.18",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.6"
|
"vite": "^7.3.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"drizzle": "^1.4.0",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Community Verse Submissions — Implementation Plan
|
||||||
|
|
||||||
|
Source of truth: `spec.md`. Each step is independently verifiable and builds on the previous.
|
||||||
|
|
||||||
|
## Step 1 — Schema & migration
|
||||||
|
- Add `verse_submissions` table to `src/lib/server/db/schema.ts`:
|
||||||
|
- `id` text pk (`Bun.randomUUIDv7()`)
|
||||||
|
- `user_id` text not null, FK → `user.id` **ON DELETE SET NULL** (nullable column to allow that)
|
||||||
|
- `scheduled_date` text not null **unique**
|
||||||
|
- `selected_book_id` text not null
|
||||||
|
- `selected_chapter` integer not null
|
||||||
|
- `selected_verse` integer not null
|
||||||
|
- `submitted_at` integer not null (server UTC **millis**; raw integer, drives 7-day cooldown)
|
||||||
|
- indexes: `user_id`, unique on `scheduled_date` (the column-level unique handles this)
|
||||||
|
- `bun run db:push` to dev.db.
|
||||||
|
- Verify: `sqlite3 dev.db ".schema verse_submissions"`.
|
||||||
|
|
||||||
|
> Note: spec says `user_id` FK should be SET NULL on account deletion, so the column is **nullable** despite "not null" in the table sketch (the edge-cases section overrides: "FK should be ON DELETE SET NULL"). Resolution: make `user_id` nullable + ON DELETE SET NULL. The admin LEFT JOIN renders "(deleted user)" when null. This is the spec-intended behavior.
|
||||||
|
|
||||||
|
## Step 2 — Bible structure + windowing helpers
|
||||||
|
- Export `getChapterCount`, `getVerseCount` from `xml-bible.ts` (currently private).
|
||||||
|
- Add `composeVerseWindow(bookNumber, chapter, verse)`:
|
||||||
|
- Computes the selected verse's index `v` within the book (contiguous 1-based across chapters), `bookStart`=1, `bookEnd`=total verses in book.
|
||||||
|
- Default window `[v-2, v-1, v]`; if `v-2 < 1` fall forward to `[v, v+1, v+2]`.
|
||||||
|
- Returns `{ bookId, bookName, verses: string[], startChapter, startVerse, endChapter, endVerse, selectedVerse }` by calling `extractVerses` per chapter and concatenating for cross-chapter windows.
|
||||||
|
- Add `formatWindowReference(bookName, startChapter, startVerse, endChapter, endVerse)`: same-chapter → `Book C:S-E`; cross-chapter → `Book C1:S1–C2:E2`.
|
||||||
|
- Unit tests in `tests/verse-window.test.ts`.
|
||||||
|
|
||||||
|
## Step 3 — Public APIs
|
||||||
|
- `GET /api/bible/structure` → `[{ bookId, chapters: number[] }, ...]` for all 66 books (verse counts per chapter). Cached in-memory.
|
||||||
|
- `GET /api/verse-window?bookId=gen&chapter=1&verse=1` → `{ windowVerses, reference, bookId, selectedVerse }` via `composeVerseWindow`.
|
||||||
|
- Verify with curl.
|
||||||
|
|
||||||
|
## Step 4 — Admin module + `/scheduled-verses` page
|
||||||
|
- `src/lib/server/admin.ts` exporting `ADMIN_EMAIL = 'geohpowell@gmail.com'`.
|
||||||
|
- `src/routes/scheduled-verses/+page.server.ts`: require `locals.user?.email === ADMIN_EMAIL` else auth redirect / 403 access-denied page.
|
||||||
|
- `+page.svelte`: table joining `verse_submissions` LEFT JOIN `daily_verses` (on `scheduled_date = date`) LEFT JOIN `user` (on `user_id`), sorted by `scheduled_date` asc. Columns per spec. "(deleted user)" when user null.
|
||||||
|
- Not linked from nav. Verify by direct URL.
|
||||||
|
|
||||||
|
## Step 5 — Submit backend + lazy gap-fill
|
||||||
|
- `POST /api/submit-verse` (auth required): body `{ bookId, chapter, verse, localDate }`.
|
||||||
|
1. Require `locals.user` (401).
|
||||||
|
2. Solved-today gate: `dailyCompletions` row `(anonymousId=user.id, date=localDate)` else 403.
|
||||||
|
3. Cooldown: most recent `verse_submissions.submitted_at` for user; if `< 7d` ago → 429 `{ cooldownEndsAt }`.
|
||||||
|
4. Structural validation: bookId valid, chapter in `[1, getChapterCount]`, verse in `[1, getVerseCount]`.
|
||||||
|
5. Compute window via `composeVerseWindow`.
|
||||||
|
6. Scheduling scan: fetch all `daily_verses` with `date > today` (server UTC) ordered; walk day-by-day from tomorrow; validity = empty + no back-to-back with D-1/D+2 committed neighbors + no same-window-ref row within ±60 days (bounded lookup). Find earliest valid `D`.
|
||||||
|
7. Transaction: insert `daily_verses` `{ id, date: D, bookId, reference, verseText, createdAt: now }` + `verse_submissions` `{ id, user_id, scheduled_date: D, selected_book_id, selected_chapter, selected_verse, submitted_at: now }`. On `SQLITE_CONSTRAINT_UNIQUE` retry scan from `D+1`, up to 5 attempts.
|
||||||
|
8. Return `{ scheduledDate: D, reference, windowText }`. Emit rybbit event.
|
||||||
|
- `GET /api/submit-verse/status?localDate=YYYY-MM-DD` (auth required): `{ canSubmit, cooldownEndsAt, lastSubmission, upcoming }`.
|
||||||
|
- Modify `getVerseForDate` lazy path: re-roll `getRandomVerses()` until book differs from committed D-1 and D+1 neighbors (cap ~20 retries).
|
||||||
|
- Verify with curl end-to-end.
|
||||||
|
|
||||||
|
## Step 6 — Frontend `SubmitVerse.svelte`
|
||||||
|
- New component rendered in `WinScreen.svelte` near the progress button, sharing `.progress-btn` styling.
|
||||||
|
- States: logged-out (Apple/Google dropdown mirroring progress button), cooldown (greyed + `CountdownTimer`-style countdown to `cooldownEndsAt`), can-submit (cascading Book→Chapter→Verse `<select>`s from `/api/bible/structure`, debounced 250ms preview via `/api/verse-window`, Submit button, success confirmation with scheduled date, inline 429/403 errors).
|
||||||
|
- Pass `isLoggedIn` + `anonymousId` through (already on WinScreen props).
|
||||||
|
- Verify in browser.
|
||||||
|
|
||||||
|
## Step 7 — Tests
|
||||||
|
- `tests/verse-window.test.ts`: book-start fall-forward (Gen 1:1 → [1,2,3]), cross-chapter window, same-chapter window, end-of-book safety.
|
||||||
|
- `tests/scheduling.test.ts` (or unit-test the pure scan function against an in-memory fixture): no-back-to-back, 60-day repeat skip, earliest-valid-date selection.
|
||||||
|
- Cooldown arithmetic test.
|
||||||
|
|
||||||
|
## Step 8 — Docs
|
||||||
|
- Update `README.md` schema table (add `verse_submissions`) and routes/API tables (new endpoints + `/scheduled-verses`).
|
||||||
|
- Note `ADMIN_EMAIL` constant location.
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# analyze_top_users.sh
|
||||||
|
# Analyzes the daily_completions table to find the top 10 anonymous IDs by completion count
|
||||||
|
|
||||||
|
# Set database path from argument or default to dev.db
|
||||||
|
DB_PATH="${1:-dev.db}"
|
||||||
|
|
||||||
|
# Check if database file exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database file not found: $DB_PATH"
|
||||||
|
echo "Usage: $0 [database_path]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the analysis query
|
||||||
|
sqlite3 "$DB_PATH" <<EOF
|
||||||
|
.mode column
|
||||||
|
.headers on
|
||||||
|
.width 36 16 16 17
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
anonymous_id,
|
||||||
|
COUNT(*) as completion_count,
|
||||||
|
MIN(date) as first_completion,
|
||||||
|
MAX(date) as latest_completion
|
||||||
|
FROM daily_completions
|
||||||
|
GROUP BY anonymous_id
|
||||||
|
ORDER BY completion_count DESC
|
||||||
|
LIMIT 10;
|
||||||
|
EOF
|
||||||
@@ -2,18 +2,15 @@ import Database from 'bun:sqlite';
|
|||||||
|
|
||||||
// Database path - adjust if your database is located elsewhere
|
// Database path - adjust if your database is located elsewhere
|
||||||
const dbPath = process.env.DATABASE_URL || './local.db';
|
const dbPath = process.env.DATABASE_URL || './local.db';
|
||||||
|
|
||||||
console.log(`Connecting to database: ${dbPath}`);
|
console.log(`Connecting to database: ${dbPath}`);
|
||||||
|
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
// Query all rows from daily_completions
|
// Query all rows from daily_completions
|
||||||
const query = db.query(`
|
const query = db.query(`
|
||||||
SELECT date, guess_count
|
SELECT date, guess_count
|
||||||
FROM daily_completions
|
FROM daily_completions
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const rows = query.all() as { date: string; guess_count: number }[];
|
const rows = query.all() as { date: string; guess_count: number }[];
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -50,4 +47,60 @@ const overallAvg = (totalGuesses / totalCompletions).toFixed(2);
|
|||||||
console.log('--------------|-------------|-------------------');
|
console.log('--------------|-------------|-------------------');
|
||||||
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
|
console.log(`Overall Average: ${overallAvg} guesses across ${totalCompletions} completions`);
|
||||||
|
|
||||||
db.close();
|
// Calculate correlation between avg_guesses and completions
|
||||||
|
function calculateCorrelation(data: { avgGuesses: number; completions: number }[]): number {
|
||||||
|
const n = data.length;
|
||||||
|
if (n < 2) return 0;
|
||||||
|
|
||||||
|
const avgX = data.reduce((sum, d) => sum + d.avgGuesses, 0) / n;
|
||||||
|
const avgY = data.reduce((sum, d) => sum + d.completions, 0) / n;
|
||||||
|
|
||||||
|
let numerator = 0;
|
||||||
|
let sumXSquared = 0;
|
||||||
|
let sumYSquared = 0;
|
||||||
|
|
||||||
|
for (const d of data) {
|
||||||
|
const xDiff = d.avgGuesses - avgX;
|
||||||
|
const yDiff = d.completions - avgY;
|
||||||
|
numerator += xDiff * yDiff;
|
||||||
|
sumXSquared += xDiff * xDiff;
|
||||||
|
sumYSquared += yDiff * yDiff;
|
||||||
|
}
|
||||||
|
|
||||||
|
const denominator = Math.sqrt(sumXSquared * sumYSquared);
|
||||||
|
return denominator === 0 ? 0 : numerator / denominator;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare data for correlation analysis
|
||||||
|
const allData = Array.from(dateStats.entries()).map(([date, stats]) => ({
|
||||||
|
date,
|
||||||
|
avgGuesses: stats.total / stats.count,
|
||||||
|
completions: stats.count
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Split into pre and post marketing periods
|
||||||
|
const marketingStartDate = '2026-01-08';
|
||||||
|
const preMarketing = allData.filter(d => d.date < marketingStartDate);
|
||||||
|
const postMarketing = allData.filter(d => d.date >= marketingStartDate);
|
||||||
|
|
||||||
|
console.log('\n=== Correlation Analysis ===\n');
|
||||||
|
|
||||||
|
const allCorrelation = calculateCorrelation(allData);
|
||||||
|
console.log(`Overall correlation (avg_guesses vs completions): ${allCorrelation.toFixed(3)}`);
|
||||||
|
|
||||||
|
if (preMarketing.length >= 2) {
|
||||||
|
const preCorrelation = calculateCorrelation(preMarketing);
|
||||||
|
console.log(`Pre-marketing correlation (before ${marketingStartDate}): ${preCorrelation.toFixed(3)} (n=${preMarketing.length} days)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (postMarketing.length >= 2) {
|
||||||
|
const postCorrelation = calculateCorrelation(postMarketing);
|
||||||
|
console.log(`Post-marketing correlation (${marketingStartDate} onward): ${postCorrelation.toFixed(3)} (n=${postMarketing.length} days)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nInterpretation:');
|
||||||
|
console.log(' r close to -1: Strong negative correlation (easier verses → more completions)');
|
||||||
|
console.log(' r close to 0: No correlation');
|
||||||
|
console.log(' r close to +1: Strong positive correlation (harder verses → more completions)');
|
||||||
|
|
||||||
|
db.close();
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/zsh
|
||||||
|
|
||||||
|
# Clear today's verse from daily_verses table
|
||||||
|
DB_PATH="dev.db"
|
||||||
|
TODAY=$(date +%Y-%m-%d)
|
||||||
|
|
||||||
|
echo "Deleting verse for date: $TODAY"
|
||||||
|
|
||||||
|
sqlite3 "$DB_PATH" "DELETE FROM daily_verses WHERE date = '$TODAY';"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "✓ Successfully deleted verse for $TODAY"
|
||||||
|
|
||||||
|
# Show remaining verses in table
|
||||||
|
COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_verses;")
|
||||||
|
echo "Remaining verses in database: $COUNT"
|
||||||
|
else
|
||||||
|
echo "✗ Failed to delete verse"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env zsh
|
||||||
|
|
||||||
|
DB_PATH="./local.db"
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
echo "Error: Database not found at $DB_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Query for daily completions on 2026-02-01 with ranking
|
||||||
|
echo "Daily Completions for 2026-02-01"
|
||||||
|
echo "================================="
|
||||||
|
echo ""
|
||||||
|
printf "%-12s %-10s %-6s\n" "Anonymous ID" "Guesses" "Rank"
|
||||||
|
printf "%-12s %-10s %-6s\n" "------------" "-------" "----"
|
||||||
|
|
||||||
|
# Execute query with custom column mode
|
||||||
|
sqlite3 "$DB_PATH" <<SQL
|
||||||
|
.mode column
|
||||||
|
.headers off
|
||||||
|
.width 12 10 6
|
||||||
|
SELECT
|
||||||
|
SUBSTR(anonymous_id, 1, 10) as anon_id,
|
||||||
|
guess_count,
|
||||||
|
RANK() OVER (ORDER BY guess_count ASC) as rank
|
||||||
|
FROM daily_completions
|
||||||
|
WHERE date = '2026-02-01'
|
||||||
|
ORDER BY rank, guess_count;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Total entries:"
|
||||||
|
sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM daily_completions WHERE date = '2026-02-01';"
|
||||||
@@ -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,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
+3
-3
@@ -17,9 +17,9 @@ const handleAuth: Handle = async ({ event, resolve }) => {
|
|||||||
const { session, user } = await auth.validateSessionToken(sessionToken);
|
const { session, user } = await auth.validateSessionToken(sessionToken);
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
auth.setSessionTokenCookie({ cookies: event.cookies }, sessionToken, session.expiresAt);
|
||||||
} else {
|
} else {
|
||||||
auth.deleteSessionTokenCookie(event);
|
auth.deleteSessionTokenCookie({ cookies: event.cookies });
|
||||||
}
|
}
|
||||||
|
|
||||||
event.locals.user = user;
|
event.locals.user = user;
|
||||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
|||||||
|
|
||||||
// Initialize embeddings on server start (runs once on module load)
|
// Initialize embeddings on server start (runs once on module load)
|
||||||
const verses = getAllNKJVVerses();
|
const verses = getAllNKJVVerses();
|
||||||
await initializeEmbeddings(verses);
|
// await initializeEmbeddings(verses);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="600" height="530" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z" fill="#1185fe"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 745 B |
@@ -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}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
variant?: "primary" | "google" | "apple" | "secondary" | "danger";
|
||||||
|
onclick?: () => void;
|
||||||
|
class?: string;
|
||||||
|
type?: "button" | "submit" | "reset";
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
children,
|
||||||
|
variant = "primary",
|
||||||
|
onclick,
|
||||||
|
class: className = "",
|
||||||
|
type = "button",
|
||||||
|
disabled = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary:
|
||||||
|
"bg-blue-500 hover:bg-blue-600 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
google: "bg-white hover:bg-gray-50 text-gray-700 border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
apple: "bg-black hover:bg-gray-900 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
secondary:
|
||||||
|
"bg-white/50 hover:bg-white/70 text-gray-700 border-gray-500 shadow-sm hover:shadow-md backdrop-blur-sm",
|
||||||
|
danger: "bg-red-500 hover:bg-red-600 text-white border-gray-500 shadow-md hover:shadow-lg",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
{type}
|
||||||
|
{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[
|
||||||
|
variant
|
||||||
|
]} {className}"
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</button>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
reference: string;
|
||||||
|
bookId: string;
|
||||||
|
onCompleted?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { reference, bookId, onCompleted }: Props = $props();
|
||||||
|
|
||||||
|
// Parse the chapter from the reference (e.g., "John 3:16" -> 3)
|
||||||
|
function parseChapterFromReference(ref: string): number {
|
||||||
|
const match = ref.match(/\s(\d+):/);
|
||||||
|
return match ? parseInt(match[1], 10) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the number of chapters for a book
|
||||||
|
function getChapterCount(bookId: string): number {
|
||||||
|
const chapterCounts: Record<string, number> = {
|
||||||
|
GEN: 50,
|
||||||
|
EXO: 40,
|
||||||
|
LEV: 27,
|
||||||
|
NUM: 36,
|
||||||
|
DEU: 34,
|
||||||
|
JOS: 24,
|
||||||
|
JDG: 21,
|
||||||
|
RUT: 4,
|
||||||
|
"1SA": 31,
|
||||||
|
"2SA": 24,
|
||||||
|
"1KI": 22,
|
||||||
|
"2KI": 25,
|
||||||
|
"1CH": 29,
|
||||||
|
"2CH": 36,
|
||||||
|
EZR: 10,
|
||||||
|
NEH: 13,
|
||||||
|
EST: 10,
|
||||||
|
JOB: 42,
|
||||||
|
PSA: 150,
|
||||||
|
PRO: 31,
|
||||||
|
ECC: 12,
|
||||||
|
SNG: 8,
|
||||||
|
ISA: 66,
|
||||||
|
JER: 52,
|
||||||
|
LAM: 5,
|
||||||
|
EZK: 48,
|
||||||
|
DAN: 12,
|
||||||
|
HOS: 14,
|
||||||
|
JOL: 3,
|
||||||
|
AMO: 9,
|
||||||
|
OBA: 1,
|
||||||
|
JON: 4,
|
||||||
|
MIC: 7,
|
||||||
|
NAM: 3,
|
||||||
|
HAB: 3,
|
||||||
|
ZEP: 3,
|
||||||
|
HAG: 2,
|
||||||
|
ZEC: 14,
|
||||||
|
MAL: 4,
|
||||||
|
MAT: 28,
|
||||||
|
MRK: 16,
|
||||||
|
LUK: 24,
|
||||||
|
JHN: 21,
|
||||||
|
ACT: 28,
|
||||||
|
ROM: 16,
|
||||||
|
"1CO": 16,
|
||||||
|
"2CO": 13,
|
||||||
|
GAL: 6,
|
||||||
|
EPH: 6,
|
||||||
|
PHP: 4,
|
||||||
|
COL: 4,
|
||||||
|
"1TH": 5,
|
||||||
|
"2TH": 3,
|
||||||
|
"1TI": 6,
|
||||||
|
"2TI": 4,
|
||||||
|
TIT: 3,
|
||||||
|
PHM: 1,
|
||||||
|
HEB: 13,
|
||||||
|
JAS: 5,
|
||||||
|
"1PE": 5,
|
||||||
|
"2PE": 3,
|
||||||
|
"1JN": 5,
|
||||||
|
"2JN": 1,
|
||||||
|
"3JN": 1,
|
||||||
|
JUD: 1,
|
||||||
|
REV: 22,
|
||||||
|
};
|
||||||
|
return chapterCounts[bookId] || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 4 random chapter options including the correct one
|
||||||
|
function generateChapterOptions(
|
||||||
|
correctChapter: number,
|
||||||
|
totalChapters: number,
|
||||||
|
): number[] {
|
||||||
|
const options = new Set<number>();
|
||||||
|
options.add(correctChapter);
|
||||||
|
|
||||||
|
if (totalChapters >= 4) {
|
||||||
|
while (options.size < 4) {
|
||||||
|
const randomChapter =
|
||||||
|
Math.floor(Math.random() * totalChapters) + 1;
|
||||||
|
options.add(randomChapter);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (options.size < 4) {
|
||||||
|
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||||
|
options.add(randomChapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(options).sort(() => Math.random() - 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
let correctChapter = $derived(parseChapterFromReference(reference));
|
||||||
|
let totalChapters = $derived(getChapterCount(bookId));
|
||||||
|
let chapterOptions = $state<number[]>([]);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (chapterOptions.length === 0) {
|
||||||
|
chapterOptions = generateChapterOptions(
|
||||||
|
correctChapter,
|
||||||
|
totalChapters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let selectedChapter = $state<number | null>(null);
|
||||||
|
let hasAnswered = $state(false);
|
||||||
|
|
||||||
|
// Load saved state from localStorage
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
const key = `bibdle-chapter-guess-${reference}`;
|
||||||
|
const saved = localStorage.getItem(key);
|
||||||
|
if (saved) {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
selectedChapter = data.selectedChapter;
|
||||||
|
hasAnswered = data.hasAnswered;
|
||||||
|
chapterOptions = data.chapterOptions ?? [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save state to localStorage whenever options are generated or answer given
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser || chapterOptions.length === 0) return;
|
||||||
|
const key = `bibdle-chapter-guess-${reference}`;
|
||||||
|
localStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ selectedChapter, hasAnswered, chapterOptions }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChapterSelect(chapter: number) {
|
||||||
|
if (hasAnswered) return;
|
||||||
|
selectedChapter = chapter;
|
||||||
|
hasAnswered = true;
|
||||||
|
if (onCompleted) {
|
||||||
|
onCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCorrect = $derived(
|
||||||
|
selectedChapter !== null && selectedChapter === correctChapter,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container
|
||||||
|
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">
|
||||||
|
<p class="font-bold mb-3 text-lg sm:text-xl">
|
||||||
|
Bonus Challenge
|
||||||
|
<span class="text-base sm:text-lg opacity-60 font-normal"
|
||||||
|
>— guess the chapter for an even higher grade</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-2 justify-center mx-auto mb-3">
|
||||||
|
{#each chapterOptions as chapter (chapter)}
|
||||||
|
<button
|
||||||
|
onclick={() => handleChapterSelect(chapter)}
|
||||||
|
disabled={hasAnswered}
|
||||||
|
class={`
|
||||||
|
w-20 h-20 sm:w-24 sm:h-24 text-2xl sm:text-3xl font-bold rounded-xl
|
||||||
|
transition-all duration-300 border-2
|
||||||
|
${
|
||||||
|
hasAnswered
|
||||||
|
? chapter === correctChapter
|
||||||
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
|
: selectedChapter === chapter
|
||||||
|
? isCorrect
|
||||||
|
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||||
|
: "bg-red-400 text-white border-red-500"
|
||||||
|
: "bg-white/30 dark:bg-white/10 text-gray-400 border-gray-300 dark:border-gray-600 opacity-40"
|
||||||
|
: "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"
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{chapter}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasAnswered}
|
||||||
|
<p
|
||||||
|
class="text-xl sm:text-2xl font-bold mb-2"
|
||||||
|
class:text-green-600={isCorrect}
|
||||||
|
class:text-red-600={!isCorrect}
|
||||||
|
>
|
||||||
|
{isCorrect ? "✓ Correct!" : "✗ Incorrect"}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm opacity-80">
|
||||||
|
The verse is from chapter {correctChapter}
|
||||||
|
</p>
|
||||||
|
{#if isCorrect}
|
||||||
|
<p class="text-lg font-bold text-amber-600 mt-2">Grade: S++</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, class: className = "" }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
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()}
|
||||||
|
</div>
|
||||||
@@ -2,69 +2,53 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
|
|
||||||
let timeUntilNext = $state("");
|
let timeUntilNext = $state("");
|
||||||
|
let newVerseReady = $state(false);
|
||||||
|
let showEncouragement = $state(false);
|
||||||
let intervalId: number | null = null;
|
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);
|
|
||||||
|
|
||||||
// Set target to 5:00 PM today
|
|
||||||
target.setHours(17, 0, 0, 0);
|
|
||||||
|
|
||||||
// If it's already past 5:00 PM, set target to tomorrow 5:00 PM
|
|
||||||
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 calculateTimeUntilMidnight(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const target = new Date(now);
|
|
||||||
|
|
||||||
// Set target to midnight today
|
|
||||||
target.setHours(0, 0, 0, 0);
|
target.setHours(0, 0, 0, 0);
|
||||||
|
if (Date.now() >= target.getTime()) {
|
||||||
// If it's already past midnight, set target to tomorrow midnight
|
|
||||||
if (now.getTime() >= target.getTime()) {
|
|
||||||
target.setDate(target.getDate() + 1);
|
target.setDate(target.getDate() + 1);
|
||||||
}
|
}
|
||||||
|
targetTime = target.getTime();
|
||||||
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() {
|
function updateTimer() {
|
||||||
timeUntilNext = calculateTimeUntilMidnight();
|
const diff = targetTime - Date.now();
|
||||||
|
|
||||||
|
if (diff <= 0) {
|
||||||
|
newVerseReady = true;
|
||||||
|
timeUntilNext = "";
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
initTarget();
|
||||||
updateTimer();
|
updateTimer();
|
||||||
intervalId = window.setInterval(updateTimer, 1000);
|
intervalId = window.setInterval(updateTimer, 1000);
|
||||||
|
|
||||||
|
const winCount = Object.keys(localStorage).filter(
|
||||||
|
(k) =>
|
||||||
|
k.startsWith("bibdle-win-tracked-") &&
|
||||||
|
localStorage.getItem(k) === "true",
|
||||||
|
).length;
|
||||||
|
showEncouragement = winCount < 3;
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -74,17 +58,40 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center py-12">
|
<div class="w-full flex flex-col flex-1">
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
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
|
{#if newVerseReady}
|
||||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2"
|
<p
|
||||||
>
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||||
Next Verse In
|
>
|
||||||
</p>
|
Next Verse In
|
||||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
</p>
|
||||||
{timeUntilNext}
|
<p class="text-4xl font-triodion font-black text-gray-800">Now</p>
|
||||||
</p>
|
<p
|
||||||
|
class="text-xs uppercase tracking-[0.2em] text-gray-500 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>
|
</div>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import SocialLinks from "$lib/components/SocialLinks.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center" in:fade={{ delay: 1500, duration: 1000 }}>
|
||||||
|
<div
|
||||||
|
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 dark:text-gray-300 font-bold"
|
||||||
|
>
|
||||||
|
A project by George Powell & Silent Summit Co.
|
||||||
|
</p>
|
||||||
|
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||||
|
For questions, comments, job opportunities, or cash donations,
|
||||||
|
please email <a
|
||||||
|
class="text-blue-400"
|
||||||
|
href="mailto:george+bibdle@silentsummit.co"
|
||||||
|
>george@silentsummit.co</a
|
||||||
|
>
|
||||||
|
</p> -->
|
||||||
|
<!-- <p class="text-lg font-triodion font-black text-gray-800 tabular-nums">
|
||||||
|
|
||||||
|
</p> -->
|
||||||
|
|
||||||
|
<!-- Bluesky Social Media Button -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<SocialLinks />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
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() {
|
||||||
|
if (!browser) return;
|
||||||
|
// Clear all bibdle-related localStorage items
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && key.startsWith("bibdle-")) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||||
|
// Reload the page to reset state
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="pt-24 pb-4">
|
||||||
|
<div class="border-t-2 border-gray-400"></div>
|
||||||
|
</div>
|
||||||
|
<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">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onclick={() => alert("About page coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
About Bibdle / FAQs
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="google"
|
||||||
|
onclick={() => alert("Google sign-in coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" 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.93l2.85-2.22.81-.62z"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<Button
|
||||||
|
variant="apple"
|
||||||
|
onclick={() => alert("Apple sign-in coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4 mr-2" 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.09l.01-.01zM12.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row gap-3 md:gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onclick={() => alert("Patreon coming soon!")}
|
||||||
|
class="w-full md:w-auto py-4 md:py-2"
|
||||||
|
>
|
||||||
|
Become a Patron
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onclick={clearLocalStorage}
|
||||||
|
class="w-full py-4 md:py-2"
|
||||||
|
>
|
||||||
|
Clear LocalStorage
|
||||||
|
</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>
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- <div
|
|
||||||
class="my-12 p-4 bg-linear-to-r from-blue-50 to-indigo-50 rounded-2xl shadow-md text-center text-sm md:text-base text-gray-600"
|
|
||||||
in:fade={{ delay: 1500, duration: 1000 }}
|
|
||||||
>
|
|
||||||
Thank you so much for playing! Feel free to email me directly with feedback:
|
|
||||||
<a
|
|
||||||
href="mailto:george@snail.city"
|
|
||||||
class="font-semibold text-blue-600 hover:text-blue-800 underline"
|
|
||||||
>george@snail.city</a
|
|
||||||
>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<div
|
|
||||||
class="inline-flex w-full flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
|
||||||
>
|
|
||||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
|
||||||
A project by George Powell & Silent Summit Co.
|
|
||||||
</p>
|
|
||||||
<!-- <p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
|
||||||
|
|
||||||
</p> -->
|
|
||||||
</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,105 +1,349 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Guess {
|
import { bibleBooks } from "$lib/types/bible";
|
||||||
book: {
|
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
testament: string;
|
|
||||||
section: string;
|
|
||||||
};
|
|
||||||
testamentMatch: boolean;
|
|
||||||
sectionMatch: boolean;
|
|
||||||
adjacent: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { guesses, correctBookId }: { guesses: Guess[]; correctBookId: string } =
|
let {
|
||||||
$props();
|
guesses,
|
||||||
|
correctBookId,
|
||||||
|
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 {
|
||||||
|
if (isCorrect) return "bg-green-500 border-green-600";
|
||||||
|
if (isAdjacent) return "bg-yellow-500 border-yellow-600";
|
||||||
|
return "bg-red-500 border-red-600";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookBoxStyle(guess: Guess): string {
|
||||||
|
if (guess.book.id === correctBookId) {
|
||||||
|
return "background-color: #22c55e; border-color: #16a34a;";
|
||||||
|
}
|
||||||
|
return "background-color: #ef4444; border-color: #dc2626;";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoxContent(
|
||||||
|
guess: Guess,
|
||||||
|
column: "book" | "firstLetter" | "testament" | "section",
|
||||||
|
): string {
|
||||||
|
switch (column) {
|
||||||
|
case "book":
|
||||||
|
return guess.book.name;
|
||||||
|
case "firstLetter":
|
||||||
|
// Check if this is the special Epistles + "1" case
|
||||||
|
const correctBook = bibleBooks.find(
|
||||||
|
(b) => b.id === correctBookId,
|
||||||
|
);
|
||||||
|
const correctIsEpistlesWithNumber =
|
||||||
|
(correctBook?.section === "Pauline Epistles" ||
|
||||||
|
correctBook?.section === "General Epistles") &&
|
||||||
|
correctBook.name[0] === "1";
|
||||||
|
const guessIsEpistlesWithNumber =
|
||||||
|
(guess.book.section === "Pauline Epistles" ||
|
||||||
|
guess.book.section === "General Epistles") &&
|
||||||
|
guess.book.name[0] === "1";
|
||||||
|
|
||||||
|
if (
|
||||||
|
correctIsEpistlesWithNumber &&
|
||||||
|
guessIsEpistlesWithNumber &&
|
||||||
|
guess.firstLetterMatch
|
||||||
|
) {
|
||||||
|
const words = [
|
||||||
|
"Exactly",
|
||||||
|
"Right",
|
||||||
|
"Yes",
|
||||||
|
"Naturally",
|
||||||
|
"Of course",
|
||||||
|
"Sure",
|
||||||
|
];
|
||||||
|
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||||
|
}
|
||||||
|
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||||
|
case "testament":
|
||||||
|
return (
|
||||||
|
guess.book.testament.charAt(0).toUpperCase() +
|
||||||
|
guess.book.testament.slice(1).toLowerCase()
|
||||||
|
);
|
||||||
|
case "section":
|
||||||
|
return guess.book.section;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if hasGuesses}
|
{#if hasGuesses}
|
||||||
<div class="bg-white rounded-2xl shadow-xl overflow-x-auto fade-in">
|
<div class="space-y-3">
|
||||||
<table class="w-full">
|
<!-- Column Headers -->
|
||||||
<thead class="fade-in">
|
<div
|
||||||
<tr class="bg-linear-to-r from-gray-50 to-gray-300">
|
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||||
<th
|
>
|
||||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
<div
|
||||||
>Book</th
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
>
|
>
|
||||||
<th
|
Testament
|
||||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
</div>
|
||||||
>Testament</th
|
<div
|
||||||
>
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
<th
|
>
|
||||||
class="p-3 sm:p-4 md:p-4 text-left text-md sm:text-base md:text-md text-gray-700 border-b border-gray-200"
|
Section
|
||||||
>Section</th
|
</div>
|
||||||
>
|
<div
|
||||||
</tr>
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
First Letter
|
||||||
{#each guesses as guess, index (guess.book.id)}
|
</div>
|
||||||
<tr
|
<div
|
||||||
class="border-b border-gray-100 transition-colors {guess.book.id ===
|
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||||
correctBookId
|
>
|
||||||
? 'bg-green-200 animate-shine'
|
Book
|
||||||
: 'hover:bg-gray-50'} {index === 0 ? 'fade-in' : ''}"
|
</div>
|
||||||
>
|
</div>
|
||||||
<td
|
|
||||||
class="p-3 sm:p-4 md:p-6 text-sm sm:text-base font-bold md:text-lg"
|
{#if showMinimized && !expanded}
|
||||||
>
|
<!-- Minimized view: first guess, divider, last two guesses -->
|
||||||
{guess.book.id === correctBookId ? "✅" : "❌"}
|
|
||||||
{guess.book.name}
|
<!-- First guess (no animation since post-win) -->
|
||||||
</td>
|
{@const firstGuess = guesses[0]}
|
||||||
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
|
<div class="flex gap-2 justify-center">
|
||||||
{guess.testamentMatch ? "✅" : "❌"}
|
<!-- Testament Column -->
|
||||||
{guess.book.testament.charAt(0).toUpperCase() +
|
<div
|
||||||
guess.book.testament.slice(1).toLowerCase()}
|
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(
|
||||||
</td>
|
firstGuess.testamentMatch,
|
||||||
<td class="p-3 sm:p-4 md:p-6 text-sm sm:text-base md:text-lg">
|
)}"
|
||||||
{guess.sectionMatch ? "✅" : "❌"}
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
{guess.adjacent ? "‼️ " : ""}{guess.book.section}
|
>
|
||||||
</td>
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
</tr>
|
>{getBoxContent(firstGuess, "testament")}</span
|
||||||
{/each}
|
>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
<!-- Section Column -->
|
||||||
|
<div
|
||||||
|
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
firstGuess.sectionMatch,
|
||||||
|
firstGuess.adjacent,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(firstGuess, "section")}
|
||||||
|
{#if firstGuess.adjacent}
|
||||||
|
‼️
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- First Letter Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||||
|
firstGuess.firstLetterMatch,
|
||||||
|
)}"
|
||||||
|
style="animation: none; opacity: 1; transform: none;"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||||
|
>{getBoxContent(firstGuess, "firstLetter")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Book Column -->
|
||||||
|
<div
|
||||||
|
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||||
|
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(firstGuess)}"
|
||||||
|
>
|
||||||
|
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||||
|
>{getBoxContent(firstGuess, "book")}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expand/collapse divider -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||||
|
onclick={() => (expanded = true)}
|
||||||
|
>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||||
|
expand guesses ▼
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Last two guesses (no animation since post-win) -->
|
||||||
|
{#each guesses.slice(-2) as guess (guess.book.id)}
|
||||||
|
<div class="flex gap-2 justify-center">
|
||||||
|
<!-- 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>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@keyframes shine {
|
@keyframes flipIn {
|
||||||
0% {
|
0% {
|
||||||
background-position: -200% 0;
|
opacity: 0;
|
||||||
}
|
transform: rotateX(-90deg);
|
||||||
100% {
|
}
|
||||||
background-position: 200% 0;
|
50% {
|
||||||
}
|
transform: rotateX(0deg);
|
||||||
}
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: rotateX(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.animate-shine {
|
.animate-flip-in {
|
||||||
background: linear-gradient(110deg, #dcffe7 45%, #f1fff5 50%, #dcffe7 55%);
|
opacity: 0;
|
||||||
background-size: 200% 100%;
|
transform: rotateX(-90deg);
|
||||||
animation: shine 5s infinite;
|
animation: flipIn 0.6s ease-out forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-shine.fade-in {
|
@keyframes fadeIn {
|
||||||
animation:
|
from {
|
||||||
fadeIn 0.5s ease-out,
|
opacity: 0;
|
||||||
shine 5s infinite;
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
.fade-in {
|
||||||
from {
|
animation: fadeIn 0.5s ease-out;
|
||||||
opacity: 0;
|
}
|
||||||
transform: translateY(-10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-in {
|
|
||||||
animation: fadeIn 0.5s ease-out;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
interface ImposterData {
|
||||||
|
verses: string[];
|
||||||
|
refs: string[];
|
||||||
|
imposterIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: ImposterData | null = $state(null);
|
||||||
|
let clicked: boolean[] = $state([]);
|
||||||
|
let gameOver = $state(false);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
|
||||||
|
async function loadGame() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/imposter");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
||||||
|
}
|
||||||
|
data = (await res.json()) as ImposterData;
|
||||||
|
clicked = new Array(data.verses.length).fill(false);
|
||||||
|
gameOver = false;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : "Unknown error";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(index: number) {
|
||||||
|
if (gameOver || !data || clicked[index]) return;
|
||||||
|
clicked[index] = true;
|
||||||
|
if (index !== data.imposterIndex) {
|
||||||
|
clicked[data.imposterIndex] = true;
|
||||||
|
}
|
||||||
|
gameOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function newGame() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
data = null;
|
||||||
|
loadGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(loadGame);
|
||||||
|
|
||||||
|
function formatVerse(verse: string): string {
|
||||||
|
let formatted = verse;
|
||||||
|
|
||||||
|
// Handle unbalanced opening/closing punctuation
|
||||||
|
const pairs: [string, string][] = [
|
||||||
|
["(", ")"],
|
||||||
|
["[", "]"],
|
||||||
|
["{", "}"],
|
||||||
|
['"', '"'],
|
||||||
|
["'", "'"],
|
||||||
|
["\u201C", "\u201D"], // \u201C
|
||||||
|
["\u2018", "\u2019"], // \u2018
|
||||||
|
];
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
|
formatted += "..." + close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
|
formatted = open + "..." + formatted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^[a-z]/.test(formatted)) {
|
||||||
|
formatted = "..." + formatted;
|
||||||
|
}
|
||||||
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(
|
||||||
|
/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/,
|
||||||
|
"...$1",
|
||||||
|
);
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="imposter-game">
|
||||||
|
{#if loading}
|
||||||
|
<p class="loading">Loading verses...</p>
|
||||||
|
{:else if error}
|
||||||
|
<div class="error">
|
||||||
|
<p>Error: {error}</p>
|
||||||
|
<button onclick={newGame}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if data}
|
||||||
|
<!-- <div class="instructions">
|
||||||
|
<p>Click the verse that doesn't belong (from a different book).</p>
|
||||||
|
</div> -->
|
||||||
|
<div class="verses">
|
||||||
|
{#each data.verses as verse, i}
|
||||||
|
<div class="verse-item">
|
||||||
|
<button
|
||||||
|
class="verse-button"
|
||||||
|
class:clicked={clicked[i]}
|
||||||
|
class:correct={clicked[i] && i === data.imposterIndex}
|
||||||
|
class:wrong={clicked[i] && i !== data.imposterIndex}
|
||||||
|
onclick={() => handleClick(i)}
|
||||||
|
disabled={gameOver}
|
||||||
|
>
|
||||||
|
{formatVerse(verse)}
|
||||||
|
</button>
|
||||||
|
{#if gameOver}
|
||||||
|
<div class="ref">{data.refs[i]}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if gameOver}
|
||||||
|
<div class="result">
|
||||||
|
<button onclick={newGame}>New Game</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.imposter-game {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*.instructions {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}*/
|
||||||
|
|
||||||
|
.verses {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
border: 3px solid #ddd;
|
||||||
|
background: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-height: 100px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button:hover:not(.clicked):not(:disabled) {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f8f9ff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button.clicked {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correct {
|
||||||
|
background: #d4edda !important;
|
||||||
|
border-color: #28a745 !important;
|
||||||
|
color: #155724;
|
||||||
|
box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrong {
|
||||||
|
background: #f8d7da !important;
|
||||||
|
border-color: #dc3545 !important;
|
||||||
|
color: #721c24;
|
||||||
|
box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
color: #555;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button.correct ~ .ref {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verse-button.wrong ~ .ref {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result button,
|
||||||
|
.error button {
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result button:hover,
|
||||||
|
.error button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from "$lib/components/Container.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
emoji: string;
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
colorClass: string;
|
||||||
|
suffix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { emoji, value, label, colorClass, suffix }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container class="p-4 md:p-6 w-full">
|
||||||
|
<div class="text-center w-full">
|
||||||
|
<div class="text-2xl md:text-3xl mb-1">{emoji}</div>
|
||||||
|
<div class="text-2xl md:text-3xl font-bold {colorClass} mb-1">
|
||||||
|
{value}{#if suffix}<span class="text-base font-normal text-gray-400"
|
||||||
|
> {suffix}</span
|
||||||
|
>{/if}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs md:text-sm text-gray-300 font-medium">{label}</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
@@ -1,55 +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";
|
||||||
|
|
||||||
|
const displayMode = $derived<DisplayMode>(
|
||||||
|
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredBooks = $derived(
|
||||||
bibleBooks.filter((book) =>
|
bibleBooks.filter((book) =>
|
||||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type SimpleGroup = { books: BibleBook[] };
|
||||||
|
|
||||||
|
type 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) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
if (e.key === "Enter" && firstBookId) {
|
||||||
submitGuess(filteredBooks[0].id);
|
submitGuess(firstBookId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const showBanner = $derived(guessCount >= 3);
|
||||||
|
const showBanner = false;
|
||||||
|
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mb-12">
|
{#if showBanner}
|
||||||
<input
|
<p
|
||||||
bind:value={searchQuery}
|
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
role="status"
|
||||||
class="w-full p-4 sm:p-6 border-2 border-gray-200 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all shadow-lg"
|
aria-live="polite"
|
||||||
onkeydown={handleKeydown}
|
>
|
||||||
/>
|
{#if bannerIsIndigo}
|
||||||
|
Testament & section groups now visible
|
||||||
|
{:else}
|
||||||
|
Old & New Testament groups now visible
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div class="relative">
|
||||||
|
<svg
|
||||||
|
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"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
bind:value={searchQuery}
|
||||||
|
placeholder="Type to guess a book (e.g. 'Genesis', 'John')..."
|
||||||
|
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"
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
onclick={() => (searchQuery = "")}
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 sm:w-6 sm:h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if searchQuery && filteredBooks.length > 0}
|
{#if searchQuery && filteredBooks.length > 0}
|
||||||
<ul
|
<ul
|
||||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-200 rounded-2xl shadow-lg"
|
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"
|
||||||
|
role="listbox"
|
||||||
>
|
>
|
||||||
{#each filteredBooks as book (book.id)}
|
{#if displayMode === "simple"}
|
||||||
<li>
|
{#each simpleGroup.books as book (book.id)}
|
||||||
<button
|
<li role="option" aria-selected={guessedIds.has(book.id)}>
|
||||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(
|
<button
|
||||||
book.id,
|
class="w-full px-5 py-4 text-left border-b border-gray-100 last:border-b-0 flex items-center transition-all
|
||||||
)
|
{guessedIds.has(book.id)
|
||||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||||
onclick={() => submitGuess(book.id)}
|
onclick={() => submitGuess(book.id)}
|
||||||
>
|
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||||
<span
|
|
||||||
class="font-semibold {guessedIds.has(book.id)
|
|
||||||
? 'line-through text-gray-500'
|
|
||||||
: ''}">{book.name}</span
|
|
||||||
>
|
>
|
||||||
<span class="ml-auto text-sm opacity-75"
|
<span
|
||||||
>({book.testament.toUpperCase()})</span
|
class="font-semibold dark:text-gray-100 {guessedIds.has(
|
||||||
|
book.id,
|
||||||
|
)
|
||||||
|
? 'line-through text-gray-400 dark:text-gray-500'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{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"
|
||||||
>
|
>
|
||||||
</button>
|
<span
|
||||||
</li>
|
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||||
{/each}
|
>
|
||||||
|
{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>
|
</ul>
|
||||||
{:else if searchQuery}
|
{:else if searchQuery}
|
||||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||||
|
No books found
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -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,25 +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 { fade } from "svelte/transition";
|
||||||
|
import Container from "./Container.svelte";
|
||||||
|
|
||||||
let { data, isWon }: { data: PageData; isWon: boolean } = $props();
|
interface VerseDisplayData {
|
||||||
let dailyVerse = $derived(data.dailyVerse);
|
dailyVerse: {
|
||||||
let displayReference = $derived(
|
date: string;
|
||||||
dailyVerse.reference.replace(/^Psalms /, "Psalm ")
|
reference: string;
|
||||||
);
|
verseText: string;
|
||||||
let displayVerseText = $derived(
|
};
|
||||||
dailyVerse.verseText.replace(/^([a-z])/, (c) => c.toUpperCase())
|
}
|
||||||
);
|
|
||||||
|
let {
|
||||||
|
data,
|
||||||
|
isWon,
|
||||||
|
blurChapter = false,
|
||||||
|
}: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
|
||||||
|
let dailyVerse = $derived(data.dailyVerse);
|
||||||
|
let displayReference = $derived(
|
||||||
|
blurChapter
|
||||||
|
? dailyVerse.reference
|
||||||
|
.replace(/^Psalms /, "Psalm ")
|
||||||
|
.replace(/\s(\d+):/, " ?:")
|
||||||
|
: dailyVerse.reference.replace(/^Psalms /, "Psalm "),
|
||||||
|
);
|
||||||
|
let displayVerseText = $derived(
|
||||||
|
dailyVerse.verseText
|
||||||
|
.replace(/^([a-z])/, (c: string) => c.toUpperCase())
|
||||||
|
.replace(/[,:;-—]$/, "..."),
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
<div class="bg-gray-50 rounded-2xl shadow-xl p-8 sm:p-12 mb-4 sm:mb-12 w-full">
|
<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 class="text-center text-lg! big-text text-green-600! font-bold mt-8">
|
</blockquote>
|
||||||
{displayReference}
|
<div
|
||||||
</p>
|
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||||
{/if}
|
style="max-height: {showReference ? '200px' : '0px'};"
|
||||||
</div>
|
>
|
||||||
|
{#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>
|
||||||
+949
-173
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Item {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
sub?: string;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
items: Item[];
|
||||||
|
format?: (v: number) => string;
|
||||||
|
signed?: boolean;
|
||||||
|
}
|
||||||
|
let { items, format = (v) => String(v), signed = false }: Props = $props();
|
||||||
|
|
||||||
|
const maxAbs = $derived(Math.max(1, ...items.map((i) => Math.abs(i.value))));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-1.5">
|
||||||
|
{#each items as item (item.label)}
|
||||||
|
<div class="flex items-center gap-2 text-xs">
|
||||||
|
<span class="w-20 shrink-0 text-gray-400">{item.label}</span>
|
||||||
|
<div class="relative h-4 flex-1 overflow-hidden rounded bg-white/5">
|
||||||
|
<div
|
||||||
|
class="absolute top-0 h-full rounded {item.value < 0 ? 'right-1/2 bg-rose-500/60' : 'left-0'}"
|
||||||
|
style={`width:${(Math.abs(item.value) / maxAbs) * 100}%;${item.value < 0 ? '' : `background:${item.color ?? 'rgba(56,189,248,0.5)'}`}`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="w-16 shrink-0 text-right font-medium text-gray-200">
|
||||||
|
{signed && item.value >= 0 ? '+' : ''}{format(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
data: { date: string; count: number }[];
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
let { data, color = '52,211,153' }: Props = $props();
|
||||||
|
|
||||||
|
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
|
||||||
|
const max = $derived(Math.max(1, ...data.map((d) => d.count)));
|
||||||
|
|
||||||
|
// Build Sunday-aligned weeks ending on the last date.
|
||||||
|
const weeks = $derived.by(() => {
|
||||||
|
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
|
||||||
|
const first = new Date(data[0].date + 'T00:00:00Z');
|
||||||
|
const start = new Date(first);
|
||||||
|
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
||||||
|
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
|
||||||
|
const end = new Date(last);
|
||||||
|
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
|
||||||
|
const range = new Set(data.map((d) => d.date));
|
||||||
|
const out: { date: string; inRange: boolean }[][] = [];
|
||||||
|
let cursor = new Date(start);
|
||||||
|
while (cursor <= end) {
|
||||||
|
const week: { date: string; inRange: boolean }[] = [];
|
||||||
|
for (let d = 0; d < 7; d++) {
|
||||||
|
const iso = cursor.toISOString().slice(0, 10);
|
||||||
|
week.push({ date: iso, inRange: range.has(iso) });
|
||||||
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
|
}
|
||||||
|
out.push(week);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<div class="flex flex-col justify-between py-0.5 text-[9px] text-gray-500">
|
||||||
|
<span>Sun</span><span>Wed</span><span>Sat</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-[3px]">
|
||||||
|
{#each weeks as week, wi (wi)}
|
||||||
|
<div class="flex flex-col gap-[3px]">
|
||||||
|
{#each week as cell (cell.date)}
|
||||||
|
{@const count = values.get(cell.date) ?? 0}
|
||||||
|
<div
|
||||||
|
class="h-3 w-3 rounded-[2px]"
|
||||||
|
style={cell.inRange
|
||||||
|
? `background:rgba(${color},${(0.12 + 0.88 * (count / max)).toFixed(3)})`
|
||||||
|
: 'background:transparent'}
|
||||||
|
title={cell.inRange ? `${cell.date}: ${count} finishes` : cell.date}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { CohortRow } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
cohorts: CohortRow[];
|
||||||
|
weeks?: number;
|
||||||
|
}
|
||||||
|
let { cohorts, weeks = 8 }: Props = $props();
|
||||||
|
|
||||||
|
function shade(rate: number | null): string {
|
||||||
|
if (rate === null) return 'background:rgba(255,255,255,0.03)';
|
||||||
|
const t = Math.min(1, rate / 100);
|
||||||
|
return `background:rgba(16,185,129,${(0.08 + 0.82 * t).toFixed(3)})`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="pr-2 text-right font-normal">Cohort</th>
|
||||||
|
<th class="pr-2 font-normal">Size</th>
|
||||||
|
{#each Array(weeks) as _w, k (k)}
|
||||||
|
<th class="w-8 font-normal">W{k}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each cohorts as row (row.weekStart)}
|
||||||
|
<tr>
|
||||||
|
<td class="pr-2 text-right whitespace-nowrap text-gray-300">{row.weekStart.slice(5)}</td>
|
||||||
|
<td class="pr-2 text-right text-gray-400">{row.size}</td>
|
||||||
|
{#each Array(weeks) as _w, k (k)}
|
||||||
|
{@const rate = row.rates[k] ?? null}
|
||||||
|
<td
|
||||||
|
class="h-6 w-8 rounded-sm text-center align-middle text-gray-900"
|
||||||
|
style={shade(rate)}
|
||||||
|
title={`Week ${k}: ${rate === null ? 'not resolved' : rate + '%'}`}
|
||||||
|
>
|
||||||
|
{rate === null ? '' : Math.round(rate)}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
rowLabels: string[];
|
||||||
|
colLabels: string[];
|
||||||
|
values: number[][];
|
||||||
|
max?: number;
|
||||||
|
color?: string;
|
||||||
|
format?: (v: number) => string;
|
||||||
|
cellSize?: number;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
rowLabels,
|
||||||
|
colLabels,
|
||||||
|
values,
|
||||||
|
max,
|
||||||
|
color = '16,185,129',
|
||||||
|
format = (v) => String(v),
|
||||||
|
cellSize = 22
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const effectiveMax = $derived(
|
||||||
|
max ?? Math.max(1, ...values.flat().filter((v) => Number.isFinite(v)))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
{#each colLabels as label, c (c)}
|
||||||
|
<th class="px-0.5 font-normal">{label}</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rowLabels as row, r (r)}
|
||||||
|
<tr>
|
||||||
|
<th class="pr-1 text-right font-normal whitespace-nowrap">{row}</th>
|
||||||
|
{#each colLabels as _label, c (c)}
|
||||||
|
{@const v = values[r]?.[c] ?? 0}
|
||||||
|
<td
|
||||||
|
class="rounded-sm text-center align-middle text-[9px] text-gray-900"
|
||||||
|
style={`width:${cellSize}px;height:${cellSize}px;background:rgba(${color},${Math.min(1, v / effectiveMax).toFixed(3)})`}
|
||||||
|
title={`${row} · ${colLabels[c]}: ${format(v)}`}
|
||||||
|
>
|
||||||
|
{v > 0 ? format(v) : ''}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Series {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
points: { date: string; value: number | null }[];
|
||||||
|
step?: boolean;
|
||||||
|
}
|
||||||
|
interface Props {
|
||||||
|
series: Series[];
|
||||||
|
height?: number;
|
||||||
|
ySuffix?: string;
|
||||||
|
yMin?: number;
|
||||||
|
yMax?: number;
|
||||||
|
showLegend?: boolean;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
series,
|
||||||
|
height = 180,
|
||||||
|
ySuffix = '',
|
||||||
|
yMin,
|
||||||
|
yMax,
|
||||||
|
showLegend = true
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const W = 600;
|
||||||
|
const pad = { l: 36, r: 10, t: 10, b: 22 };
|
||||||
|
const innerW = W - pad.l - pad.r;
|
||||||
|
const innerH = $derived(height - pad.t - pad.b);
|
||||||
|
|
||||||
|
const allValues = $derived(
|
||||||
|
series.flatMap((s) => s.points.map((p) => p.value)).filter((v): v is number => v !== null)
|
||||||
|
);
|
||||||
|
const domain = $derived.by(() => {
|
||||||
|
const lo = yMin ?? (allValues.length ? Math.min(...allValues) : 0);
|
||||||
|
const hi = yMax ?? (allValues.length ? Math.max(...allValues) : 1);
|
||||||
|
const span = hi - lo || 1;
|
||||||
|
return { lo: yMin ?? lo - span * 0.1, hi: yMax ?? hi + span * 0.1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
const pointCount = $derived(
|
||||||
|
Math.max(1, ...series.map((s) => s.points.length))
|
||||||
|
);
|
||||||
|
|
||||||
|
function xAt(i: number): number {
|
||||||
|
if (pointCount <= 1) return pad.l + innerW / 2;
|
||||||
|
return pad.l + (i / (pointCount - 1)) * innerW;
|
||||||
|
}
|
||||||
|
function yAt(v: number): number {
|
||||||
|
const t = (v - domain.lo) / (domain.hi - domain.lo || 1);
|
||||||
|
return pad.t + innerH - t * innerH;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Break a series into path segments so nulls create gaps. */
|
||||||
|
const paths = $derived(
|
||||||
|
series.map((s) => {
|
||||||
|
const segments: string[] = [];
|
||||||
|
let current: string[] = [];
|
||||||
|
s.points.forEach((p, i) => {
|
||||||
|
if (p.value === null) {
|
||||||
|
if (current.length) segments.push(current.join(' '));
|
||||||
|
current = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current.push(`${xAt(i).toFixed(1)},${yAt(p.value).toFixed(1)}`);
|
||||||
|
});
|
||||||
|
if (current.length) segments.push(current.join(' '));
|
||||||
|
return { name: s.name, color: s.color, segments, step: s.step };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstLabel = $derived(series[0]?.points[0]?.date?.slice(5) ?? '');
|
||||||
|
const lastLabel = $derived(
|
||||||
|
series[0]?.points[series[0].points.length - 1]?.date?.slice(5) ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Hover / tooltip state -------------------------------------------------
|
||||||
|
let wrapperEl: HTMLDivElement | undefined = $state();
|
||||||
|
let hoverIndex = $state<number | null>(null);
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent) {
|
||||||
|
if (!wrapperEl || pointCount <= 0) return;
|
||||||
|
const rect = wrapperEl.getBoundingClientRect();
|
||||||
|
if (rect.width === 0) return;
|
||||||
|
const scale = rect.width / W;
|
||||||
|
const svgX = (event.clientX - rect.left) / scale;
|
||||||
|
if (pointCount <= 1) {
|
||||||
|
hoverIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = (svgX - pad.l) / innerW;
|
||||||
|
const i = Math.round(t * (pointCount - 1));
|
||||||
|
hoverIndex = Math.min(pointCount - 1, Math.max(0, i));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHover() {
|
||||||
|
hoverIndex = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows shown in the tooltip for the currently hovered index. */
|
||||||
|
const hoverRows = $derived.by(() => {
|
||||||
|
const i = hoverIndex;
|
||||||
|
if (i === null) return [];
|
||||||
|
return series.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
color: s.color,
|
||||||
|
value: s.points[i]?.value ?? null
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoveredDate = $derived.by(() => {
|
||||||
|
if (hoverIndex === null) return null;
|
||||||
|
for (const s of series) {
|
||||||
|
const point = s.points[hoverIndex];
|
||||||
|
if (point) return point.date;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasHoverValues = $derived(hoverRows.some((r) => r.value !== null));
|
||||||
|
|
||||||
|
const tooltipLeft = $derived(
|
||||||
|
hoverIndex === null ? 50 : Math.min(85, Math.max(15, (xAt(hoverIndex) / W) * 100))
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatValue(value: number | null): string {
|
||||||
|
if (value === null) return '—';
|
||||||
|
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1));
|
||||||
|
return `${rounded.toLocaleString()}${ySuffix}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative w-full touch-pan-y"
|
||||||
|
bind:this={wrapperEl}
|
||||||
|
role="presentation"
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerleave={clearHover}
|
||||||
|
onpointercancel={clearHover}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${height}`}
|
||||||
|
class="w-full text-gray-400"
|
||||||
|
style={`height:${height}px`}
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<!-- gridlines -->
|
||||||
|
{#each [0, 0.5, 1] as t}
|
||||||
|
<line
|
||||||
|
x1={pad.l}
|
||||||
|
x2={W - pad.r}
|
||||||
|
y1={pad.t + innerH * t}
|
||||||
|
y2={pad.t + innerH * t}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-opacity="0.15"
|
||||||
|
stroke-dasharray="2 3"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<text x={4} y={pad.t + 4} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||||
|
{domain.hi.toFixed(0)}{ySuffix}
|
||||||
|
</text>
|
||||||
|
<text x={4} y={pad.t + innerH} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||||
|
{domain.lo.toFixed(0)}{ySuffix}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{#each paths as s (s.name)}
|
||||||
|
{#each s.segments as d}
|
||||||
|
<polyline
|
||||||
|
points={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={s.color}
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
opacity={hoverIndex === null ? 1 : 0.45}
|
||||||
|
style="transition:opacity 120ms ease"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hoverIndex !== null}
|
||||||
|
<!-- crosshair -->
|
||||||
|
<line
|
||||||
|
x1={xAt(hoverIndex)}
|
||||||
|
x2={xAt(hoverIndex)}
|
||||||
|
y1={pad.t}
|
||||||
|
y2={pad.t + innerH}
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
stroke-dasharray="3 3"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<!-- highlighted points -->
|
||||||
|
{#each hoverRows as row (row.name)}
|
||||||
|
{#if row.value !== null}
|
||||||
|
<circle
|
||||||
|
cx={xAt(hoverIndex)}
|
||||||
|
cy={yAt(row.value)}
|
||||||
|
r="3.5"
|
||||||
|
fill={row.color}
|
||||||
|
stroke="#0b0b12"
|
||||||
|
stroke-width="1.5"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<text x={pad.l} y={height - 6} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||||
|
{firstLabel}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={W - pad.r}
|
||||||
|
y={height - 6}
|
||||||
|
text-anchor="end"
|
||||||
|
fill="currentColor"
|
||||||
|
fill-opacity="0.8"
|
||||||
|
font-size="9"
|
||||||
|
>
|
||||||
|
{lastLabel}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{#if hoverIndex !== null && hoveredDate !== null && hasHoverValues}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-lg border border-white/15 bg-gray-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-gray-100 shadow-lg backdrop-blur-sm"
|
||||||
|
style={`left:${tooltipLeft}%`}
|
||||||
|
>
|
||||||
|
<p class="mb-1 font-semibold text-gray-300">{hoveredDate}</p>
|
||||||
|
<ul class="flex flex-col gap-0.5">
|
||||||
|
{#each hoverRows as row (row.name)}
|
||||||
|
<li class="flex items-center justify-between gap-3">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="h-2 w-2 shrink-0 rounded-full" style={`background:${row.color}`}></span>
|
||||||
|
{row.name}
|
||||||
|
</span>
|
||||||
|
<span class="font-semibold tabular-nums text-gray-100">{formatValue(row.value)}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showLegend && series.length > 1}
|
||||||
|
<div class="mt-1 flex flex-wrap gap-3 text-[11px] text-gray-300">
|
||||||
|
{#each series as s (s.name)}
|
||||||
|
<span class="inline-flex items-center gap-1">
|
||||||
|
<span class="h-2 w-2 rounded-full" style={`background:${s.color}`}></span>
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
values: number[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
class?: string;
|
||||||
|
stroke?: string;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
values,
|
||||||
|
width = 120,
|
||||||
|
height = 32,
|
||||||
|
class: className = '',
|
||||||
|
stroke = 'currentColor'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const points = $derived.by(() => {
|
||||||
|
if (values.length === 0) return '';
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const span = max - min || 1;
|
||||||
|
return values
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = values.length === 1 ? width / 2 : (i / (values.length - 1)) * width;
|
||||||
|
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||||
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
class={className}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{#if points}
|
||||||
|
<polyline
|
||||||
|
{points}
|
||||||
|
fill="none"
|
||||||
|
{stroke}
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-linecap="round"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { SignalSeverity } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
severity: SignalSeverity;
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
let { severity, children, label }: Props = $props();
|
||||||
|
|
||||||
|
const styles: Record<SignalSeverity, string> = {
|
||||||
|
good: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||||
|
info: 'bg-sky-500/15 text-sky-300 border-sky-500/30',
|
||||||
|
warn: 'bg-amber-500/15 text-amber-200 border-amber-500/30',
|
||||||
|
bad: 'bg-rose-500/15 text-rose-300 border-rose-500/30'
|
||||||
|
};
|
||||||
|
const dots: Record<SignalSeverity, string> = {
|
||||||
|
good: 'bg-emerald-400',
|
||||||
|
info: 'bg-sky-400',
|
||||||
|
warn: 'bg-amber-400',
|
||||||
|
bad: 'bg-rose-400'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold {styles[
|
||||||
|
severity
|
||||||
|
]}"
|
||||||
|
>
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full {dots[severity]}"></span>
|
||||||
|
{#if children}{@render children()}{:else}{label}{/if}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { bibleBooks, type BibleSection, type Testament } from '$lib/types/bible';
|
||||||
|
import type { BookStat } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
coverage: BookStat[];
|
||||||
|
}
|
||||||
|
let { coverage }: Props = $props();
|
||||||
|
|
||||||
|
interface Tile extends BookStat {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionGroup {
|
||||||
|
section: BibleSection;
|
||||||
|
testament: Testament;
|
||||||
|
books: Tile[];
|
||||||
|
days: number;
|
||||||
|
plays: number;
|
||||||
|
covered: number;
|
||||||
|
firstTryWeightedSum: number;
|
||||||
|
firstTryPlays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = $derived(new Map(coverage.map((c) => [c.bookId, c])));
|
||||||
|
const maxDays = $derived(Math.max(1, ...coverage.map((c) => c.days)));
|
||||||
|
const totalDays = $derived(coverage.reduce((a, b) => a + b.days, 0));
|
||||||
|
const totalPlays = $derived(coverage.reduce((a, b) => a + b.plays, 0));
|
||||||
|
|
||||||
|
/** Red → orange → yellow → green, driven by days featured relative to the busiest book. */
|
||||||
|
function coverageColor(days: number): string {
|
||||||
|
if (days <= 0) return 'hsl(220, 9%, 55%)';
|
||||||
|
const t = Math.min(1, days / maxDays);
|
||||||
|
return `hsl(${Math.round(t * 142)}, 78%, 55%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendStops = [0, 0.25, 0.5, 0.75, 1];
|
||||||
|
const legendGradient = $derived(
|
||||||
|
`linear-gradient(90deg, ${legendStops
|
||||||
|
.map((t) => `hsl(${Math.round(t * 142)}, 78%, 55%) ${Math.round(t * 100)}%`)
|
||||||
|
.join(', ')})`
|
||||||
|
);
|
||||||
|
const legendTicks = $derived(legendStops.map((t) => Math.round(t * maxDays)));
|
||||||
|
|
||||||
|
/** Consecutive runs of books sharing a section (preserves canonical order). */
|
||||||
|
const groups = $derived.by(() => {
|
||||||
|
const out: SectionGroup[] = [];
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const stat = byId.get(book.id);
|
||||||
|
if (!stat) continue;
|
||||||
|
let group = out[out.length - 1];
|
||||||
|
if (!group || group.section !== book.section || group.testament !== book.testament) {
|
||||||
|
group = {
|
||||||
|
section: book.section,
|
||||||
|
testament: book.testament,
|
||||||
|
books: [],
|
||||||
|
days: 0,
|
||||||
|
plays: 0,
|
||||||
|
covered: 0,
|
||||||
|
firstTryWeightedSum: 0,
|
||||||
|
firstTryPlays: 0
|
||||||
|
};
|
||||||
|
out.push(group);
|
||||||
|
}
|
||||||
|
group.books.push({ ...stat, name: book.name });
|
||||||
|
group.days += stat.days;
|
||||||
|
group.plays += stat.plays;
|
||||||
|
if (stat.days > 0) group.covered++;
|
||||||
|
if (stat.plays > 0) {
|
||||||
|
group.firstTryWeightedSum += stat.firstTryPct * stat.plays;
|
||||||
|
group.firstTryPlays += stat.plays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-5">
|
||||||
|
<!-- Numeric colour legend -->
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
<span>Featured less</span>
|
||||||
|
<span>Days featured →</span>
|
||||||
|
<span>Featured more</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 w-full rounded-full ring-1 ring-inset ring-white/10" style={`background:${legendGradient}`}></div>
|
||||||
|
<div class="flex justify-between text-[10px] tabular-nums text-gray-300">
|
||||||
|
{#each legendTicks as tick}
|
||||||
|
<span class="font-semibold">{tick}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="flex items-center gap-1.5 text-[10px] text-gray-500">
|
||||||
|
<span class="inline-block h-2.5 w-2.5 rounded-sm ring-1 ring-inset ring-white/10" style="background:hsl(220, 9%, 55%)"></span>
|
||||||
|
never featured (0 days)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sections -->
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
{#each groups as group (group.testament + group.section)}
|
||||||
|
{@const share = totalDays ? Math.round((group.days / totalDays) * 100) : 0}
|
||||||
|
{@const avgFirstTry = group.firstTryPlays
|
||||||
|
? Math.round(group.firstTryWeightedSum / group.firstTryPlays)
|
||||||
|
: null}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-white/10 pb-1">
|
||||||
|
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-100">
|
||||||
|
{group.section}
|
||||||
|
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-300">
|
||||||
|
{group.testament === 'old' ? 'OT' : 'NT'}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<p class="text-[11px] text-gray-400">
|
||||||
|
<span class="font-semibold text-gray-200">{group.covered}/{group.books.length}</span> books covered ·
|
||||||
|
<span class="font-semibold text-gray-200">{group.days}</span> days ({share}%) ·
|
||||||
|
<span class="font-semibold text-gray-200">{group.plays}</span> plays
|
||||||
|
{#if avgFirstTry !== null}
|
||||||
|
· avg first-try <span class="font-semibold text-gray-200">{avgFirstTry}%</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="h-1 w-full overflow-hidden rounded-full bg-white/5">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full bg-white/40"
|
||||||
|
style={`width:${share}%`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-11">
|
||||||
|
{#each group.books as book (book.bookId)}
|
||||||
|
<div
|
||||||
|
class="flex min-h-11 flex-col items-center justify-center rounded-lg bg-white/5 px-1 py-1 text-center ring-1 ring-inset ring-white/10 transition-colors duration-100 hover:bg-white/10"
|
||||||
|
title={`${book.name}: featured ${book.days} day${book.days === 1 ? '' : 's'}, ${book.plays} play${book.plays === 1 ? '' : 's'}`}
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{book.bookId}</span>
|
||||||
|
<span class="text-base font-bold leading-tight" style={`color:${coverageColor(book.days)}`}>{book.days}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-[11px] text-gray-500">
|
||||||
|
66 books over the last 180 days · {totalDays} featured days · {totalPlays} plays. Colour is scaled
|
||||||
|
to the busiest book ({maxDays} day{maxDays === 1 ? '' : 's'}). Hover a tile for full details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DateNavigator from './DateNavigator.svelte';
|
||||||
|
import SettingsMenu from './SettingsMenu.svelte';
|
||||||
|
import { formatDateLabel } from '$lib/utils/stats-format';
|
||||||
|
import type { StatsSettings } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
today: string;
|
||||||
|
generatedAt: Date;
|
||||||
|
buildKind: string;
|
||||||
|
matured: boolean;
|
||||||
|
stale: boolean;
|
||||||
|
settings: StatsSettings;
|
||||||
|
}
|
||||||
|
let { date, today, generatedAt, buildKind, matured, stale, settings }: Props = $props();
|
||||||
|
|
||||||
|
const matureHref = $derived(
|
||||||
|
`/admin/stats?date=${date}${matured ? '' : '&mature=1'}`
|
||||||
|
);
|
||||||
|
const generatedLabel = $derived(
|
||||||
|
generatedAt instanceof Date ? generatedAt.toISOString().slice(0, 16).replace('T', ' ') : ''
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-20 -mx-4 mb-4 border-b border-white/10 bg-gray-950/85 px-4 py-3 backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="mx-auto flex max-w-6xl flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<a href="/" class="text-sm text-gray-400 hover:text-gray-200">← Game</a>
|
||||||
|
<h1 class="text-xl font-bold text-gray-100">Growth Console</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<DateNavigator {date} {today} mature={matured} />
|
||||||
|
<a
|
||||||
|
href={matureHref}
|
||||||
|
class="rounded-lg border px-3 py-1.5 text-xs font-semibold {matured
|
||||||
|
? 'border-amber-400/50 bg-amber-400/15 text-amber-200'
|
||||||
|
: 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10'}"
|
||||||
|
>
|
||||||
|
{matured ? 'Matured view ✓' : 'Mature this view'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Showing <span class="font-semibold text-gray-200">{formatDateLabel(date)}</span>
|
||||||
|
· snapshot built {generatedLabel} UTC
|
||||||
|
{#if buildKind}<span class="uppercase tracking-wide"> · {buildKind}</span>{/if}
|
||||||
|
{#if matured}
|
||||||
|
<span class="font-semibold text-amber-300"> · Matured — uses data after this date.</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsMenu {settings} {stale} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
date: string;
|
||||||
|
today: string;
|
||||||
|
mature: boolean;
|
||||||
|
}
|
||||||
|
let { date, today, mature }: Props = $props();
|
||||||
|
|
||||||
|
function shift(dateStr: string, n: number): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00Z');
|
||||||
|
d.setUTCDate(d.getUTCDate() + n);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hrefFor = (d: string) => `/admin/stats?date=${d}${mature ? '&mature=1' : ''}`;
|
||||||
|
const prev = $derived(shift(date, -1));
|
||||||
|
const next = $derived(shift(date, 1));
|
||||||
|
const nextDisabled = $derived(next > today);
|
||||||
|
|
||||||
|
function onDateChange(event: Event) {
|
||||||
|
const value = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
if (value) goto(hrefFor(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={hrefFor(prev)}
|
||||||
|
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
|
||||||
|
aria-label="Previous day"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</a>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={date}
|
||||||
|
max={today}
|
||||||
|
onchange={onDateChange}
|
||||||
|
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{#if nextDisabled}
|
||||||
|
<span
|
||||||
|
class="cursor-not-allowed rounded-lg border border-white/5 bg-white/5 px-3 py-1.5 text-sm text-gray-600"
|
||||||
|
aria-label="Next day (unavailable)"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href={hrefFor(next)}
|
||||||
|
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
|
||||||
|
aria-label="Next day"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<a
|
||||||
|
href={hrefFor(today)}
|
||||||
|
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">7 · Feature Impact</h2>
|
||||||
|
<Container class="p-4 w-full items-start text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Needs data</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Feature-usage events are deferred. The <code>analytics_events</code> table already supports
|
||||||
|
them; only the emit points remain. Once added, this panel will show:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc pl-5 text-xs text-gray-400">
|
||||||
|
<li>Imposter-mode entries and their return rate</li>
|
||||||
|
<li>Hint reveals per finish and whether they help retention</li>
|
||||||
|
<li>Progress-page visits as a signup/retention signal</li>
|
||||||
|
<li>Verse submissions and their effect on next-day return</li>
|
||||||
|
</ul>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
|
||||||
|
interface GlossaryEntry {
|
||||||
|
name: string;
|
||||||
|
definition: string;
|
||||||
|
action: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors statistics-plan.md §5. Single source for on-page definitions. */
|
||||||
|
const GLOSSARY: GlossaryEntry[] = [
|
||||||
|
{ name: 'Players today', definition: 'Finished the puzzle yesterday.', action: 'Investigate content or traffic.' },
|
||||||
|
{ name: 'WAU / MAU', definition: 'Played at least once in the last 7 / 30 days.', action: 'Reach vs habit.' },
|
||||||
|
{ name: 'Core players', definition: 'Finished 4+ times this week.', action: 'If falling, it is a retention problem.' },
|
||||||
|
{ name: 'Returning share', definition: 'Played before today vs. first time.', action: 'Low = acquisition-heavy and leaky.' },
|
||||||
|
{ name: 'D1 return', definition: 'First-timers who came back the next day.', action: 'The number one activation lever.' },
|
||||||
|
{ name: 'Return rate (any)', definition: 'Played again within N days.', action: 'Onboarding quality.' },
|
||||||
|
{ name: 'Overall return rate', definition: 'Ever played more than once.', action: 'Overall stickiness.' },
|
||||||
|
{ name: 'Net growth', definition: 'New + resurrected − churned.', action: 'Is the base expanding?' },
|
||||||
|
{ name: 'Completion rate', definition: 'Started → finished.', action: 'Friction or confusion.' },
|
||||||
|
{ name: 'Signup rate', definition: 'Players who made an account.', action: 'Account value.' },
|
||||||
|
{ name: 'First-try rate', definition: 'Solved with no wrong guesses.', action: 'Too high = giveaway; too low = brutal.' },
|
||||||
|
{ name: 'Share rate', definition: 'Shares ÷ finishes.', action: 'Virality.' },
|
||||||
|
{ name: 'Streak survival', definition: 'Chance a streak continues.', action: 'Streak feature return on investment.' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">8 · Notes & Glossary</h2>
|
||||||
|
<Container class="p-2 md:p-4 w-full overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm text-gray-200">
|
||||||
|
<thead class="border-b border-white/10 text-xs uppercase text-gray-400">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-2 md:px-3">Metric</th>
|
||||||
|
<th class="px-2 py-2 md:px-3">Plain definition</th>
|
||||||
|
<th class="px-2 py-2 md:px-3">What to do if it's off</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each GLOSSARY as entry (entry.name)}
|
||||||
|
<tr class="border-b border-white/5 align-top">
|
||||||
|
<td class="px-2 py-2 font-medium md:px-3">{entry.name}</td>
|
||||||
|
<td class="px-2 py-2 text-gray-300 md:px-3">{entry.definition}</td>
|
||||||
|
<td class="px-2 py-2 text-gray-400 md:px-3">{entry.action}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Container>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Snapshots are point-in-time. A day where a metric did not exist shows
|
||||||
|
“—”.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||||
|
import BarList from '$lib/components/charts/BarList.svelte';
|
||||||
|
import type { SnapshotPayload } from '$lib/types/stats';
|
||||||
|
import { signed, numOrDash } from '$lib/utils/stats-format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: SnapshotPayload;
|
||||||
|
}
|
||||||
|
let { payload }: Props = $props();
|
||||||
|
|
||||||
|
const series = $derived(payload.dailySeries.slice(-60));
|
||||||
|
const hasStarted = $derived(payload.meta.hasStartedData);
|
||||||
|
const signupTotal = $derived(payload.growth.signups.reduce((a, b) => a + b.count, 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">2 · Growth Loop</h2>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">New players per day</p>
|
||||||
|
<p class="text-xs text-gray-400">First-time players each day. Hover later for post annotations.</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[{ name: 'New players', color: '#60a5fa', points: series.map((d) => ({ date: d.date, value: d.newPlayers })) }]}
|
||||||
|
showLegend={false}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Acquisition vs churn (7d)</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
New + resurrected − churned = net. Week-over-week.
|
||||||
|
</p>
|
||||||
|
<BarList
|
||||||
|
signed
|
||||||
|
items={[
|
||||||
|
{ label: 'New', value: payload.activity.newPlayers, color: 'rgba(96,165,250,0.6)' },
|
||||||
|
{ label: 'Resurrected', value: payload.activity.resurrected, color: 'rgba(52,211,153,0.6)' },
|
||||||
|
{ label: 'Churned', value: -payload.activity.churned, color: 'rgba(251,113,133,0.6)' },
|
||||||
|
{ label: 'Net', value: payload.activity.netGrowth, color: 'rgba(167,139,250,0.6)' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-400">Net growth: <span class="font-semibold text-gray-200">{signed(payload.activity.netGrowth)}</span></p>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Lifecycle funnel</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Of everyone active in the tracked window, how many reached each play count.
|
||||||
|
</p>
|
||||||
|
<BarList
|
||||||
|
items={payload.growth.lifecycleFunnel.map((f) => ({
|
||||||
|
label: f.label,
|
||||||
|
value: f.players,
|
||||||
|
color: 'rgba(52,211,153,0.5)'
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Started → finished</p>
|
||||||
|
{#if hasStarted}
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Completion rate {payload.kpis.completionRate ?? '—'}%. Started counts distinct
|
||||||
|
players who loaded the verse.
|
||||||
|
</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[
|
||||||
|
{ name: 'Started', color: '#f59e0b', points: series.map((d) => ({ date: d.date, value: d.started })) },
|
||||||
|
{ name: 'Finished', color: '#34d399', points: series.map((d) => ({ date: d.date, value: d.completions })) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Needs data. Once players load the game this shows start → finish.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Signups</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Account creations ({numOrDash(signupTotal)} in the last 90 days). Existing accounts show
|
||||||
|
“unknown” date.
|
||||||
|
</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[{ name: 'Signups', color: '#a78bfa', points: payload.growth.signups.map((s) => ({ date: s.date, value: s.count })) }]}
|
||||||
|
showLegend={false}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||||
|
import BarList from '$lib/components/charts/BarList.svelte';
|
||||||
|
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
|
||||||
|
import HeatmapGrid from '$lib/components/charts/HeatmapGrid.svelte';
|
||||||
|
import type { SnapshotPayload } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: SnapshotPayload;
|
||||||
|
}
|
||||||
|
let { payload }: Props = $props();
|
||||||
|
|
||||||
|
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const hourLabels = Array.from({ length: 24 }, (_, h) => (h % 3 === 0 ? String(h) : ''));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">5 · Habit Lab</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Streak survival</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
If you've played N days in a row, how likely are you to play day N+1.
|
||||||
|
</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[
|
||||||
|
{
|
||||||
|
name: 'Survival',
|
||||||
|
color: '#34d399',
|
||||||
|
points: payload.habit.streakSurvival.map((s) => ({ date: String(s.length), value: s.probability }))
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
showLegend={false}
|
||||||
|
ySuffix="%"
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Current streak lengths</p>
|
||||||
|
<p class="text-xs text-gray-400">Players whose current run has reached each length.</p>
|
||||||
|
<BarList
|
||||||
|
items={payload.habit.streakLengths.map((b) => ({ label: `${b.label} days`, value: b.value, color: 'rgba(251,191,36,0.5)' }))}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Daily finishes (last 120 days)</p>
|
||||||
|
<CalendarHeatmap data={payload.habit.calendar} />
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">When people play</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Finishes by weekday and hour. Times are server time (UTC) and therefore approximate.
|
||||||
|
</p>
|
||||||
|
<HeatmapGrid
|
||||||
|
rowLabels={dayLabels}
|
||||||
|
colLabels={hourLabels}
|
||||||
|
values={payload.habit.weekdayHour}
|
||||||
|
color="96,165,250"
|
||||||
|
cellSize={16}
|
||||||
|
format={(v) => String(v)}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import Sparkline from '$lib/components/charts/Sparkline.svelte';
|
||||||
|
import type { SignalSeverity } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
caption?: string;
|
||||||
|
delta?: string;
|
||||||
|
deltaDirection?: 'up' | 'down' | 'flat';
|
||||||
|
sparkValues?: number[];
|
||||||
|
severity?: SignalSeverity;
|
||||||
|
}
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
caption,
|
||||||
|
delta,
|
||||||
|
deltaDirection = 'flat',
|
||||||
|
sparkValues = [],
|
||||||
|
severity
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const deltaClass = $derived(
|
||||||
|
deltaDirection === 'up'
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: deltaDirection === 'down'
|
||||||
|
? 'text-rose-400'
|
||||||
|
: 'text-gray-400'
|
||||||
|
);
|
||||||
|
const strokeColor = $derived(
|
||||||
|
severity === 'bad'
|
||||||
|
? '#fb7185'
|
||||||
|
: severity === 'warn'
|
||||||
|
? '#fbbf24'
|
||||||
|
: '#34d399'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-start text-left">
|
||||||
|
<div class="flex items-start justify-between gap-2 w-full">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{#if delta}
|
||||||
|
<span class="text-xs font-semibold {deltaClass}">{delta}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-gray-100">{value}</p>
|
||||||
|
{#if sparkValues.length > 1}
|
||||||
|
<Sparkline
|
||||||
|
values={sparkValues}
|
||||||
|
stroke={strokeColor}
|
||||||
|
class="mt-2 w-full h-8 text-gray-500"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if caption}
|
||||||
|
<p class="mt-1 text-xs leading-snug text-gray-400">{caption}</p>
|
||||||
|
{/if}
|
||||||
|
</Container>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import KpiCard from './KpiCard.svelte';
|
||||||
|
import StatusPill from '$lib/components/charts/StatusPill.svelte';
|
||||||
|
import type { Signal, SnapshotPayload, StatsSettings } from '$lib/types/stats';
|
||||||
|
import {
|
||||||
|
delta,
|
||||||
|
numOrDash,
|
||||||
|
pctOrDash,
|
||||||
|
signed
|
||||||
|
} from '$lib/utils/stats-format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: SnapshotPayload;
|
||||||
|
settings: StatsSettings;
|
||||||
|
verdict: string;
|
||||||
|
topSignal: Signal | null;
|
||||||
|
}
|
||||||
|
let { payload, settings, verdict, topSignal }: Props = $props();
|
||||||
|
|
||||||
|
const series = $derived(payload.dailySeries.slice(-30));
|
||||||
|
const spark = $derived(series.map((d) => d.completions));
|
||||||
|
const newSpark = $derived(series.map((d) => d.newPlayers));
|
||||||
|
|
||||||
|
const d1Severity = $derived(
|
||||||
|
payload.kpis.d1Return === null
|
||||||
|
? 'info'
|
||||||
|
: payload.kpis.d1Return >= settings.d1_target
|
||||||
|
? 'good'
|
||||||
|
: payload.kpis.d1Return < 12
|
||||||
|
? 'bad'
|
||||||
|
: 'warn'
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">1 · Pulse</h2>
|
||||||
|
{#if topSignal}
|
||||||
|
<StatusPill severity={topSignal.severity}>{topSignal.title}</StatusPill>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-white/10 bg-white/5 p-3 text-sm text-gray-200"
|
||||||
|
>
|
||||||
|
{verdict}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<KpiCard
|
||||||
|
label="Players today"
|
||||||
|
value={numOrDash(payload.activity.dau)}
|
||||||
|
delta={delta(payload.activity.dau, payload.activity.previous?.dau).text}
|
||||||
|
deltaDirection={delta(payload.activity.dau, payload.activity.previous?.dau).direction}
|
||||||
|
sparkValues={spark}
|
||||||
|
caption="Finished yesterday's verse."
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Players this week"
|
||||||
|
value={numOrDash(payload.activity.wau)}
|
||||||
|
delta={delta(payload.activity.wau, payload.activity.previous?.wau).text}
|
||||||
|
deltaDirection={delta(payload.activity.wau, payload.activity.previous?.wau).direction}
|
||||||
|
caption="Distinct players in the last 7 days."
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Players this month"
|
||||||
|
value={numOrDash(payload.activity.mau)}
|
||||||
|
delta={delta(payload.activity.mau, payload.activity.previous?.mau).text}
|
||||||
|
deltaDirection={delta(payload.activity.mau, payload.activity.previous?.mau).direction}
|
||||||
|
caption="Distinct players in the last 30 days."
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Core players"
|
||||||
|
value={numOrDash(payload.activity.core)}
|
||||||
|
delta={delta(payload.activity.core, payload.activity.corePrevious).text}
|
||||||
|
deltaDirection={delta(payload.activity.core, payload.activity.corePrevious).direction}
|
||||||
|
caption={`Finished ${settings.core_plays_per_week}+ times this week.`}
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="New players"
|
||||||
|
value={numOrDash(payload.activity.newPlayers)}
|
||||||
|
sparkValues={newSpark}
|
||||||
|
caption="First-time players in the last 7 days."
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Returning share"
|
||||||
|
value={pctOrDash(payload.activity.returningShare)}
|
||||||
|
caption="Of yesterday's players, how many had played before."
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="D1 return"
|
||||||
|
value={pctOrDash(payload.kpis.d1Return)}
|
||||||
|
severity={d1Severity}
|
||||||
|
delta={`target ${settings.d1_target}%`}
|
||||||
|
caption="First-timers who came back the next day."
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Net growth (7d)"
|
||||||
|
value={signed(payload.activity.netGrowth)}
|
||||||
|
caption={`New + returning − churned (${payload.activity.newPlayers} + ${payload.activity.resurrected} − ${payload.activity.churned}).`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import CohortHeatmap from '$lib/components/charts/CohortHeatmap.svelte';
|
||||||
|
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||||
|
import BarList from '$lib/components/charts/BarList.svelte';
|
||||||
|
import type { SnapshotPayload, StatsSettings } from '$lib/types/stats';
|
||||||
|
import { pctOrDash, numOrDash } from '$lib/utils/stats-format';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: SnapshotPayload;
|
||||||
|
settings: StatsSettings;
|
||||||
|
}
|
||||||
|
let { payload, settings }: Props = $props();
|
||||||
|
|
||||||
|
const mix = $derived(payload.retention.segmentMix);
|
||||||
|
const colors = ['#60a5fa', '#34d399', '#a78bfa', '#f59e0b', '#fb7185'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">3 · Retention Lab</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<Container class="p-4 w-full items-start text-left">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
Overall return rate
|
||||||
|
</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-100">
|
||||||
|
{pctOrDash(payload.kpis.overallReturnRate)}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Share of all players who ever played more than once. Target:
|
||||||
|
{settings.return7_target}%+ within 7 days.
|
||||||
|
</p>
|
||||||
|
</Container>
|
||||||
|
<Container class="p-4 w-full items-start text-left">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
Time to second play
|
||||||
|
</p>
|
||||||
|
<BarList items={payload.retention.timeToSecondPlay.map((b) => ({ label: b.label, value: b.value }))} />
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Cohort retention triangle</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Each row is a starting week; each column is weeks later. Darker = more came back.
|
||||||
|
Only resolved weeks are filled.
|
||||||
|
</p>
|
||||||
|
<CohortHeatmap cohorts={payload.retention.cohorts} weeks={8} />
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Retention curves</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
D1 / D7 / D30 return over time. Latest resolvable cohorts only.
|
||||||
|
</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[
|
||||||
|
{ name: 'D1', color: '#34d399', points: payload.retention.curves.d1 },
|
||||||
|
{ name: 'D7', color: '#60a5fa', points: payload.retention.curves.d7 },
|
||||||
|
{ name: 'D30', color: '#a78bfa', points: payload.retention.curves.d30 }
|
||||||
|
]}
|
||||||
|
ySuffix="%"
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Segment mix over time</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Active players split into new, core, returning, at-risk and churned. Total tracked:
|
||||||
|
{numOrDash(payload.activity.mau)} recently active.
|
||||||
|
</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[
|
||||||
|
{ name: 'New', color: colors[0], points: mix.map((m) => ({ date: m.date, value: m.new })) },
|
||||||
|
{ name: 'Core', color: colors[1], points: mix.map((m) => ({ date: m.date, value: m.core })) },
|
||||||
|
{ name: 'Returning', color: colors[2], points: mix.map((m) => ({ date: m.date, value: m.returning })) },
|
||||||
|
{ name: 'At risk', color: colors[3], points: mix.map((m) => ({ date: m.date, value: m.atRisk })) },
|
||||||
|
{ name: 'Churned', color: colors[4], points: mix.map((m) => ({ date: m.date, value: m.churned })) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import { SETTINGS_DEFAULTS, type SettingsKey, type StatsSettings } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: StatsSettings;
|
||||||
|
stale?: boolean;
|
||||||
|
}
|
||||||
|
let { settings, stale = false }: Props = $props();
|
||||||
|
|
||||||
|
const FIELDS: { key: SettingsKey; label: string; hint: string; suffix?: string }[] = [
|
||||||
|
{ key: 'core_plays_per_week', label: 'Core player threshold', hint: 'Finishes in 7 days to count as core.' },
|
||||||
|
{ key: 'at_risk_days', label: 'At-risk after (days)', hint: 'Days inactive before a player is at risk.' },
|
||||||
|
{ key: 'churn_days', label: 'Churned after (days)', hint: 'Days inactive before a player is churned.' },
|
||||||
|
{ key: 'resurrect_gap_days', label: 'Resurrection gap (days)', hint: 'Gap that makes a return "resurrected".' },
|
||||||
|
{ key: 'active_window_days', label: 'Player-state window (days)', hint: 'How much player history is stored.' },
|
||||||
|
{ key: 'd1_target', label: 'D1 return target', hint: 'Target for next-day return.', suffix: '%' },
|
||||||
|
{ key: 'return7_target', label: '7-day return target', hint: 'Target for any return within 7 days.', suffix: '%' },
|
||||||
|
{ key: 'completion_target', label: 'Completion target', hint: 'Started → finished target.', suffix: '%' },
|
||||||
|
{ key: 'first_try_min', label: 'First-try band (min)', hint: 'Below this is too hard.', suffix: '%' },
|
||||||
|
{ key: 'first_try_max', label: 'First-try band (max)', hint: 'Above this is too easy.', suffix: '%' },
|
||||||
|
{ key: 'share_target', label: 'Share target', hint: 'Shares ÷ finishes target.', suffix: '%' },
|
||||||
|
{ key: 'anomaly_mad_threshold', label: 'Anomaly sensitivity', hint: 'Lower = more alerts.' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<details class="group">
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer list-none rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
⚙️ Settings & thresholds
|
||||||
|
{#if stale}
|
||||||
|
<span class="ml-2 text-xs font-normal text-amber-300"
|
||||||
|
>(snapshot definitions are outdated)</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</summary>
|
||||||
|
<Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Changing these re-reads history; snapshots themselves are unchanged.
|
||||||
|
</p>
|
||||||
|
<form method="POST" action="?/updateSettings" class="grid gap-3 sm:grid-cols-2">
|
||||||
|
{#each FIELDS as field (field.key)}
|
||||||
|
<label class="flex flex-col gap-1 text-xs text-gray-300">
|
||||||
|
<span class="font-semibold">{field.label}{field.suffix ? ` (${field.suffix})` : ''}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={field.key}
|
||||||
|
value={settings[field.key]}
|
||||||
|
step="any"
|
||||||
|
min={field.key === 'anomaly_mad_threshold' ? 0.5 : 0}
|
||||||
|
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span class="text-[11px] text-gray-500">{field.hint}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
<div class="flex items-center gap-2 sm:col-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
Save settings
|
||||||
|
</button>
|
||||||
|
<span class="text-[11px] text-gray-500">
|
||||||
|
Defaults come from <code>SETTINGS_DEFAULTS</code> / <code>STATS_*</code> env vars.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Container>
|
||||||
|
</details>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import StatusPill from '$lib/components/charts/StatusPill.svelte';
|
||||||
|
import type { Signal } from '$lib/types/stats';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
signals: Signal[];
|
||||||
|
}
|
||||||
|
let { signals }: Props = $props();
|
||||||
|
|
||||||
|
const labels: Record<Signal['severity'], string> = {
|
||||||
|
good: 'On target',
|
||||||
|
info: 'Needs data',
|
||||||
|
warn: 'Watch',
|
||||||
|
bad: 'Act now'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">6 · Signals & Actions</h2>
|
||||||
|
{#if signals.length === 0}
|
||||||
|
<p class="text-sm text-gray-400">No signals for this date.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each signals as signal (signal.id)}
|
||||||
|
<Container class="p-4 w-full items-start text-left gap-2">
|
||||||
|
<div class="flex items-center justify-between gap-2 w-full">
|
||||||
|
<p class="font-semibold text-gray-100">{signal.title}</p>
|
||||||
|
<StatusPill severity={signal.severity} label={labels[signal.severity]} />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-300">{signal.detail}</p>
|
||||||
|
{#if signal.action}
|
||||||
|
<p class="text-xs text-amber-200/90">
|
||||||
|
<span class="font-semibold">What to do:</span> {signal.action}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Container>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Container from '$lib/components/Container.svelte';
|
||||||
|
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||||
|
import BarList from '$lib/components/charts/BarList.svelte';
|
||||||
|
import BookCoverage from '$lib/components/stats/BookCoverage.svelte';
|
||||||
|
import type { SnapshotPayload } from '$lib/types/stats';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
payload: SnapshotPayload;
|
||||||
|
}
|
||||||
|
let { payload }: Props = $props();
|
||||||
|
|
||||||
|
const nameOf = (id: string) => bibleBooks.find((b) => b.id === id)?.name ?? id;
|
||||||
|
const coverage = $derived(payload.verseMix.coverage);
|
||||||
|
|
||||||
|
const retained = $derived(payload.verseMix.retentionByBook);
|
||||||
|
const avgD1 = $derived(
|
||||||
|
retained.length ? retained.reduce((a, b) => a + (b.d1Return ?? 0), 0) / retained.length : 0
|
||||||
|
);
|
||||||
|
const giveaways = $derived(
|
||||||
|
payload.verseMix.firstTryByBook
|
||||||
|
.filter((b) => b.firstTryPct >= 50 && b.plays >= 3)
|
||||||
|
.sort((a, b) => b.firstTryPct - a.firstTryPct)
|
||||||
|
.slice(0, 8)
|
||||||
|
);
|
||||||
|
const alarms = $derived(
|
||||||
|
retained
|
||||||
|
.filter((b) => b.plays >= 5 && (b.d1Return ?? 100) < avgD1 - 5)
|
||||||
|
.sort((a, b) => (a.d1Return ?? 0) - (b.d1Return ?? 0))
|
||||||
|
.slice(0, 8)
|
||||||
|
);
|
||||||
|
|
||||||
|
function bar(pct: number | null, color = 'rgba(52,211,153,0.6)'): string {
|
||||||
|
const w = pct === null ? 0 : Math.min(100, pct);
|
||||||
|
return `width:${w}%;background:${color}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<h2 class="text-lg font-bold text-gray-100">4 · Verse Mix Lab</h2>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">66-book coverage (180 days)</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
How often each book was the answer, grouped by canonical section. Red = rarely featured,
|
||||||
|
green = a staple. Aim for breadth, not repetition.
|
||||||
|
</p>
|
||||||
|
<BookCoverage {coverage} />
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Testament balance & variety</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Weekly Old vs New Testament count, plus book-distribution entropy (higher = more variety).
|
||||||
|
</p>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[
|
||||||
|
{ name: 'Old', color: '#f59e0b', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.old })) },
|
||||||
|
{ name: 'New', color: '#60a5fa', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.new })) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<MiniLineChart
|
||||||
|
series={[{ name: 'Entropy (bits)', color: '#a78bfa', points: payload.verseMix.entropy }]}
|
||||||
|
showLegend={false}
|
||||||
|
height={120}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Freshness</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
How long between repeats of the same book. More short gaps = repetitive mix.
|
||||||
|
</p>
|
||||||
|
<BarList
|
||||||
|
items={payload.verseMix.repeatIntervals.map((b) => ({ label: b.label, value: b.value, color: 'rgba(244,114,182,0.5)' }))}
|
||||||
|
/>
|
||||||
|
<p class="pt-2 text-xs text-gray-400">Achievability (last 30 days)</p>
|
||||||
|
<BarList
|
||||||
|
items={payload.verseMix.achievability.map((b) => ({ label: `${b.label} guess${b.label === '1' ? '' : 'es'}`, value: b.value, color: 'rgba(96,165,250,0.5)' }))}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Retention by difficulty</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Next-day return after solving in each guess bucket. Do hard days make people leave?
|
||||||
|
</p>
|
||||||
|
<table class="w-full text-left text-xs text-gray-300">
|
||||||
|
<thead class="text-gray-500">
|
||||||
|
<tr><th class="py-1">Guesses</th><th>Plays</th><th>D1 return</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each payload.verseMix.retentionByGuessBucket as row (row.bucket)}
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">{row.bucket}</td>
|
||||||
|
<td>{row.plays}</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2 w-24 overflow-hidden rounded bg-white/5">
|
||||||
|
<div class="h-full rounded" style={bar(row.d1)}></div>
|
||||||
|
</div>
|
||||||
|
<span>{row.d1 === null ? '—' : `${row.d1}%`}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Retention by book</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Books whose day had below-average next-day return (min 5 plays). Candidate retirements.
|
||||||
|
</p>
|
||||||
|
{#if alarms.length === 0}
|
||||||
|
<p class="text-sm text-gray-400">No clear churn outliers.</p>
|
||||||
|
{:else}
|
||||||
|
<table class="w-full text-left text-xs text-gray-300">
|
||||||
|
<thead class="text-gray-500">
|
||||||
|
<tr><th class="py-1">Book</th><th>Plays</th><th>D1</th><th>First-try</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each alarms as b (b.bookId)}
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">{nameOf(b.bookId)}</td>
|
||||||
|
<td>{b.plays}</td>
|
||||||
|
<td>{b.d1Return === null ? '—' : `${b.d1Return}%`}</td>
|
||||||
|
<td>{b.firstTryPct}%</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||||
|
<p class="font-semibold text-gray-100">Giveaway detector</p>
|
||||||
|
<p class="text-xs text-gray-400">
|
||||||
|
Books solved first try more than half the time (min 3 plays). Likely self-revealing text —
|
||||||
|
down-weight in the curated mix.
|
||||||
|
</p>
|
||||||
|
{#if giveaways.length === 0}
|
||||||
|
<p class="text-sm text-gray-400">No giveaway outliers.</p>
|
||||||
|
{:else}
|
||||||
|
<table class="w-full text-left text-xs text-gray-300">
|
||||||
|
<thead class="text-gray-500">
|
||||||
|
<tr><th class="py-1">Book</th><th>Plays</th><th>First-try</th><th>Avg guesses</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each giveaways as b (b.bookId)}
|
||||||
|
<tr>
|
||||||
|
<td class="py-1">{nameOf(b.bookId)}</td>
|
||||||
|
<td>{b.plays}</td>
|
||||||
|
<td>{b.firstTryPct}%</td>
|
||||||
|
<td>{b.avgGuesses}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Before/after the curated mix system will appear here once enough pre/post data exists.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
@@ -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,16 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
// Local-only escape hatch: set `DEV_ADMIN_ALL=true` in `.env` to treat every
|
||||||
|
// visitor as an admin so the private console can be tested on a dev machine.
|
||||||
|
// Explicitly disabled when NODE_ENV=production, so it can never apply to a
|
||||||
|
// deployed build even if the variable leaks.
|
||||||
|
export const DEV_ADMIN_ALL =
|
||||||
|
Bun.env.DEV_ADMIN_ALL === 'true' && Bun.env.NODE_ENV !== 'production';
|
||||||
|
|
||||||
|
export function isAdmin(email: string | null | undefined): boolean {
|
||||||
|
if (DEV_ADMIN_ALL) return true;
|
||||||
|
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,115 @@
|
|||||||
|
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,
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
}
|
||||||
+96
-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,102 @@ 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,
|
||||||
|
createdAt: new Date()
|
||||||
|
};
|
||||||
|
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,8 +1,19 @@
|
|||||||
import { integer, sqliteTable, text, index, unique } from 'drizzle-orm/sqlite-core';
|
import { integer, sqliteTable, text, index, unique, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||||
|
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
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),
|
||||||
|
// Additive: existing rows stay NULL ("unknown signup date"). New rows get a
|
||||||
|
// timestamp automatically via the client-side default function.
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())
|
||||||
|
});
|
||||||
|
|
||||||
export const session = sqliteTable('session', {
|
export const session = sqliteTable('session', {
|
||||||
id: text('id').primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
@@ -30,11 +41,83 @@ 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;
|
||||||
|
|
||||||
|
// ── Statistics dashboard (see statistics-plan.md) ────────────────────────────
|
||||||
|
// One immutable row per "as of" day. Payload/config/playerState are JSON text.
|
||||||
|
export const statsSnapshot = sqliteTable('stats_snapshot', {
|
||||||
|
// as-of date, YYYY-MM-DD
|
||||||
|
date: text('date').primaryKey(),
|
||||||
|
// build time (server timestamp)
|
||||||
|
generatedAt: integer('generated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
// scheduled | lazy | reconstructed
|
||||||
|
buildKind: text('build_kind').notNull(),
|
||||||
|
// stable hash of the settings used at build time
|
||||||
|
configVersion: text('config_version').notNull(),
|
||||||
|
// all aggregate series / lab data
|
||||||
|
payload: text('payload').notNull(),
|
||||||
|
// compact per-player state for render-time segmentation (JSON)
|
||||||
|
playerState: text('player_state'),
|
||||||
|
// copy of the resolved settings used at build time (JSON)
|
||||||
|
config: text('config').notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StatsSnapshot = typeof statsSnapshot.$inferSelect;
|
||||||
|
|
||||||
|
// Generic, extensible event log. Unlocks instrumentation B/D/E/F now; C later.
|
||||||
|
export const analyticsEvents = sqliteTable('analytics_events', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
anonymousId: text('anonymous_id'),
|
||||||
|
// Nullable so ON DELETE SET NULL keeps historical events when a user is deleted.
|
||||||
|
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||||
|
date: text('date'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
metadata: text('metadata')
|
||||||
|
}, (table) => [
|
||||||
|
index('analytics_events_name_date_idx').on(table.name, table.date),
|
||||||
|
index('analytics_events_anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||||
|
// Dedupe verse_served per player per day (reloads), without collapsing
|
||||||
|
// legitimate repeat events like multiple shares in a day.
|
||||||
|
uniqueIndex('analytics_events_serve_unique')
|
||||||
|
.on(table.name, table.anonymousId, table.date)
|
||||||
|
.where(sql`${table.name} = 'verse_served'`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type AnalyticsEvent = typeof analyticsEvents.$inferSelect;
|
||||||
|
|
||||||
|
// Simple key/value store for dashboard thresholds (env vars provide defaults).
|
||||||
|
export const dashboardSettings = sqliteTable('dashboard_settings', {
|
||||||
|
key: text('key').primaryKey(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DashboardSetting = typeof dashboardSettings.$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,165 @@
|
|||||||
|
import type { DailyVerse } from '$lib/server/db/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The feed presents calendar days, so every timestamp is anchored to midnight
|
||||||
|
* in this reference timezone. Using a fixed zone (rather than the server's
|
||||||
|
* local timezone) keeps output stable across UTC dev machines and NY hosts.
|
||||||
|
*/
|
||||||
|
export const FEED_TIMEZONE = 'America/New_York';
|
||||||
|
|
||||||
|
/** Number of most-recent verses exposed in the feed. */
|
||||||
|
export const FEED_ITEM_LIMIT = 30;
|
||||||
|
|
||||||
|
/** Escape the five XML predefined entities. */
|
||||||
|
export function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current calendar date (`YYYY-MM-DD`) in the feed's reference timezone.
|
||||||
|
* Used to cap the feed so future/scheduled verses are never published early.
|
||||||
|
*/
|
||||||
|
export function todayInTimeZone(timeZone: string = FEED_TIMEZONE, now: Date = new Date()): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit'
|
||||||
|
}).formatToParts(now);
|
||||||
|
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
|
||||||
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The UTC offset in effect on a given `YYYY-MM-DD` for a timezone, formatted
|
||||||
|
* as `±HH:MM` (e.g. `-04:00` during EDT, `-05:00` during EST).
|
||||||
|
*/
|
||||||
|
export function timeZoneOffset(dateStr: string, timeZone: string = FEED_TIMEZONE): string {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
// Noon UTC is safely inside the calendar day in any US offset, so DST is
|
||||||
|
// resolved correctly regardless of the time of day.
|
||||||
|
const reference = new Date(Date.UTC(year, month - 1, day, 12));
|
||||||
|
const name = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
timeZoneName: 'longOffset'
|
||||||
|
})
|
||||||
|
.formatToParts(reference)
|
||||||
|
.find((part) => part.type === 'timeZoneName')?.value;
|
||||||
|
|
||||||
|
const offset = (name ?? 'GMT+00:00').replace('GMT', '');
|
||||||
|
return offset === '' ? '+00:00' : offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a `YYYY-MM-DD` day as an RFC 822 / RFC 2822 timestamp at midnight in
|
||||||
|
* the feed's reference timezone. Always emits a valid numeric/GMT timestamp
|
||||||
|
* (never a bare, ambiguous abbreviation like `EST`).
|
||||||
|
*/
|
||||||
|
export function formatRFC822(dateStr: string, timeZone: string = FEED_TIMEZONE): string {
|
||||||
|
const offset = timeZoneOffset(dateStr, timeZone);
|
||||||
|
return new Date(`${dateStr}T00:00:00${offset}`).toUTCString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable date that does not shift with the server's local timezone. */
|
||||||
|
export function formatReadableDate(dateStr: string): string {
|
||||||
|
const [year, month, day] = dateStr.split('-').map(Number);
|
||||||
|
return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString('en-US', {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap text in a CDATA section, splitting any embedded `]]>` safely. */
|
||||||
|
export function cdata(text: string): string {
|
||||||
|
return `<![CDATA[${text.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||||
|
export function formatVerseText(text: string): string {
|
||||||
|
let formatted = text;
|
||||||
|
|
||||||
|
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||||
|
const pairs: [string, string][] = [
|
||||||
|
['(', ')'],
|
||||||
|
['[', ']'],
|
||||||
|
['{', '}'],
|
||||||
|
['"', '"'],
|
||||||
|
["'", "'"],
|
||||||
|
['\u201C', '\u201D'], // " "
|
||||||
|
['\u2018', '\u2019'] // ' '
|
||||||
|
];
|
||||||
|
|
||||||
|
// Check if text starts with opening punctuation without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||||
|
formatted += '...' + close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text ends with closing punctuation without opening
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||||
|
formatted = open + '...' + formatted;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||||
|
for (const [open, close] of pairs) {
|
||||||
|
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||||
|
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||||
|
if (openCount > closeCount) {
|
||||||
|
formatted += close;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||||
|
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||||
|
|
||||||
|
// Replace trailing punctuation with ellipsis
|
||||||
|
// Preserve closing quotes/brackets that may have been added
|
||||||
|
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||||
|
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content fingerprint used as a strong ETag. Any change to the included rows
|
||||||
|
* (new day, edited verse text, changed reference) produces a new hash, so
|
||||||
|
* cached clients revalidate correctly.
|
||||||
|
*/
|
||||||
|
export function feedFingerprint(
|
||||||
|
verses: Pick<DailyVerse, 'date' | 'reference' | 'verseText'>[]
|
||||||
|
): string {
|
||||||
|
let hash = 5381;
|
||||||
|
for (const verse of verses) {
|
||||||
|
const row = `${verse.date}\u0000${verse.reference}\u0000${verse.verseText}`;
|
||||||
|
for (let i = 0; i < row.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + row.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recent `createdAt` across the given rows, used for `Last-Modified`.
|
||||||
|
* Falls back to `fallback` when no row carries a timestamp.
|
||||||
|
*/
|
||||||
|
export function latestModification(
|
||||||
|
verses: Pick<DailyVerse, 'createdAt'>[],
|
||||||
|
fallback: Date
|
||||||
|
): Date {
|
||||||
|
return verses.reduce<Date | null>((latest, verse) => {
|
||||||
|
if (!verse.createdAt) return latest;
|
||||||
|
return !latest || verse.createdAt > latest ? verse.createdAt : latest;
|
||||||
|
}, null) ?? fallback;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Pure UTC date arithmetic on `YYYY-MM-DD` strings. No DB, no Svelte. */
|
||||||
|
|
||||||
|
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||||
|
|
||||||
|
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`. */
|
||||||
|
export function dayDiff(a: string, b: string): number {
|
||||||
|
return Math.round(
|
||||||
|
(new Date(b + 'T00:00:00Z').getTime() - new Date(a + 'T00:00:00Z').getTime()) / DAY_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Sunday on or before `dateStr` (week cohorts are Sunday-aligned). */
|
||||||
|
export function startOfWeek(dateStr: string): string {
|
||||||
|
const day = new Date(dateStr + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
|
||||||
|
return addDays(dateStr, -day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All dates from `start` to `end` inclusive. */
|
||||||
|
export function dateRange(start: string, end: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (let d = start; d <= end; d = addDays(d, 1)) out.push(d);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayUtc(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDate(dateStr: string): boolean {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !Number.isNaN(new Date(dateStr + 'T00:00:00Z').getTime());
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
|
|
||||||
|
/** The Drizzle database type (production db or a test db). */
|
||||||
|
export type StatsDb = typeof db;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { SnapshotPayload } from '$lib/types/stats';
|
||||||
|
import { sortSignals } from './signals';
|
||||||
|
|
||||||
|
/** Compose the ~6-line plain-language daily digest. Pure / testable. */
|
||||||
|
export function composeDigest(payload: SnapshotPayload): string {
|
||||||
|
const { activity, kpis } = payload;
|
||||||
|
const top = sortSignals(payload.signals)[0];
|
||||||
|
const lines = [
|
||||||
|
`**Bibdle stats — ${payload.asOfDate}**`,
|
||||||
|
`Players: ${activity.dau} today · ${activity.wau} this week · ${activity.mau} this month`,
|
||||||
|
`New ${activity.newPlayers} · returning ${activity.returningShare ?? '—'}% · net ${activity.netGrowth >= 0 ? '+' : ''}${activity.netGrowth}`,
|
||||||
|
`D1 return ${kpis.d1Return ?? '—'}% · 7-day ${kpis.return7 ?? '—'}%`,
|
||||||
|
top ? `Top signal: ${top.title} — ${top.detail}` : 'No signals.',
|
||||||
|
`https://bibdle.com/admin/stats?date=${payload.asOfDate}`
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST the digest to the admin Discord webhook. No-op (with a log) when the
|
||||||
|
* webhook is unset; never throws so a digest failure can't fail the build.
|
||||||
|
*/
|
||||||
|
export async function sendDigest(
|
||||||
|
payload: SnapshotPayload,
|
||||||
|
webhookUrl: string | undefined = Bun.env.DISCORD_ADMIN_WEBHOOK
|
||||||
|
): Promise<boolean> {
|
||||||
|
const message = composeDigest(payload);
|
||||||
|
if (!webhookUrl) {
|
||||||
|
console.log('[stats] DISCORD_ADMIN_WEBHOOK not set; digest skipped:\n' + message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(webhookUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: message })
|
||||||
|
});
|
||||||
|
if (!res.ok) console.error('[stats] digest webhook responded', res.status);
|
||||||
|
return res.ok;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[stats] digest failed', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { db as defaultDb } from '$lib/server/db';
|
||||||
|
import { analyticsEvents } from '$lib/server/db/schema';
|
||||||
|
import { EVENT_NAMES, MAX_METADATA_BYTES, type EventName } from '$lib/types/stats';
|
||||||
|
import type { StatsDb } from './db';
|
||||||
|
|
||||||
|
export interface RecordEventInput {
|
||||||
|
name: string;
|
||||||
|
anonymousId?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isEventName(name: string): name is EventName {
|
||||||
|
return (EVENT_NAMES as readonly string[]).includes(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely serialize metadata, capping it at `MAX_METADATA_BYTES`. Oversized
|
||||||
|
* payloads are replaced with a marker rather than truncated into invalid JSON.
|
||||||
|
*/
|
||||||
|
export function encodeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||||
|
if (metadata == null) return null;
|
||||||
|
let json: string | undefined;
|
||||||
|
try {
|
||||||
|
json = JSON.stringify(metadata);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (json === undefined) return null;
|
||||||
|
if (Buffer.byteLength(json) > MAX_METADATA_BYTES) {
|
||||||
|
return JSON.stringify({ truncated: true });
|
||||||
|
}
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert one analytics event.
|
||||||
|
*
|
||||||
|
* Always uses `ON CONFLICT DO NOTHING`: the only unique constraint is the
|
||||||
|
* partial `verse_served` index, so this dedupes reloads for `verse_served`
|
||||||
|
* while other events insert freely.
|
||||||
|
*
|
||||||
|
* Never throws — analytics must not break gameplay. Returns true on insert.
|
||||||
|
*/
|
||||||
|
export async function recordEvent(
|
||||||
|
input: RecordEventInput,
|
||||||
|
database: StatsDb = defaultDb
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { name, anonymousId, userId, date, metadata } = input;
|
||||||
|
try {
|
||||||
|
const inserted = await database
|
||||||
|
.insert(analyticsEvents)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
anonymousId: anonymousId ?? null,
|
||||||
|
userId: userId ?? null,
|
||||||
|
date: date ?? null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
metadata: encodeMetadata(metadata)
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning({ id: analyticsEvents.id });
|
||||||
|
return inserted.length > 0;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[stats] failed to record event', name, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import type {
|
||||||
|
BookStat,
|
||||||
|
CohortRow,
|
||||||
|
CompletionRow,
|
||||||
|
HistogramBin,
|
||||||
|
VerseRow
|
||||||
|
} from '$lib/types/stats';
|
||||||
|
import { bibleBooks } from '$lib/types/bible';
|
||||||
|
import { addDays, dateRange, dayDiff, startOfWeek } from './dates';
|
||||||
|
|
||||||
|
// ── internal helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Unique play dates per player, sorted ascending. */
|
||||||
|
export function groupDatesByPlayer(completions: CompletionRow[]): Map<string, string[]> {
|
||||||
|
const map = new Map<string, Set<string>>();
|
||||||
|
for (const c of completions) {
|
||||||
|
let set = map.get(c.anonymousId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
map.set(c.anonymousId, set);
|
||||||
|
}
|
||||||
|
set.add(c.date);
|
||||||
|
}
|
||||||
|
const out = new Map<string, string[]>();
|
||||||
|
for (const [id, set] of map) out.set(id, [...set].sort());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First-ever play date per player. */
|
||||||
|
export function firstPlayDates(completions: CompletionRow[]): Map<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const c of completions) {
|
||||||
|
const current = out.get(c.anonymousId);
|
||||||
|
if (current === undefined || c.date < current) out.set(c.anonymousId, c.date);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n: number): number {
|
||||||
|
return Math.round(n * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(part: number, whole: number): number | null {
|
||||||
|
if (whole <= 0) return null;
|
||||||
|
return round1((part / whole) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── activity ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Distinct players with a completion in `[end - windowDays + 1, end]`. */
|
||||||
|
export function activePlayers(
|
||||||
|
completions: CompletionRow[],
|
||||||
|
end: string,
|
||||||
|
windowDays: number
|
||||||
|
): number {
|
||||||
|
const start = addDays(end, -(windowDays - 1));
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const c of completions) {
|
||||||
|
if (c.date >= start && c.date <= end) set.add(c.anonymousId);
|
||||||
|
}
|
||||||
|
return set.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewVsReturning {
|
||||||
|
newPlayers: number;
|
||||||
|
returning: number;
|
||||||
|
returningShare: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a single day's players into first-timers and returning players. */
|
||||||
|
export function newVsReturning(completions: CompletionRow[], date: string): NewVsReturning {
|
||||||
|
const firsts = firstPlayDates(completions);
|
||||||
|
const players = new Set<string>();
|
||||||
|
for (const c of completions) if (c.date === date) players.add(c.anonymousId);
|
||||||
|
let newPlayers = 0;
|
||||||
|
let returning = 0;
|
||||||
|
for (const id of players) {
|
||||||
|
if (firsts.get(id) === date) newPlayers++;
|
||||||
|
else returning++;
|
||||||
|
}
|
||||||
|
return { newPlayers, returning, returningShare: pct(returning, players.size) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetGrowth {
|
||||||
|
newPlayers: number;
|
||||||
|
resurrected: number;
|
||||||
|
churned: number;
|
||||||
|
netGrowth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Growth accounting over a trailing window:
|
||||||
|
* `new + resurrected - churned`.
|
||||||
|
*
|
||||||
|
* - new: first-ever play inside the window
|
||||||
|
* - resurrected: played in the window after a gap longer than `resurrectGapDays`
|
||||||
|
* - churned: played in the preceding equal window but not in this one
|
||||||
|
*/
|
||||||
|
export function netGrowth(
|
||||||
|
completions: CompletionRow[],
|
||||||
|
end: string,
|
||||||
|
windowDays: number,
|
||||||
|
resurrectGapDays: number
|
||||||
|
): NetGrowth {
|
||||||
|
const windowStart = addDays(end, -(windowDays - 1));
|
||||||
|
const prevStart = addDays(windowStart, -windowDays);
|
||||||
|
const prevEnd = addDays(windowStart, -1);
|
||||||
|
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
|
||||||
|
let newPlayers = 0;
|
||||||
|
let resurrected = 0;
|
||||||
|
let churned = 0;
|
||||||
|
|
||||||
|
for (const dates of datesByPlayer.values()) {
|
||||||
|
const inWindow = dates.filter((d) => d >= windowStart && d <= end);
|
||||||
|
const inPrev = dates.filter((d) => d >= prevStart && d <= prevEnd);
|
||||||
|
|
||||||
|
if (inWindow.length > 0) {
|
||||||
|
const firstInWindow = inWindow[0];
|
||||||
|
const earlier = dates.filter((d) => d < firstInWindow);
|
||||||
|
if (earlier.length === 0) {
|
||||||
|
if (firstInWindow >= windowStart) newPlayers++;
|
||||||
|
} else {
|
||||||
|
const gap = dayDiff(earlier[earlier.length - 1], firstInWindow);
|
||||||
|
if (gap > resurrectGapDays) resurrected++;
|
||||||
|
}
|
||||||
|
} else if (inPrev.length > 0) {
|
||||||
|
churned++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { newPlayers, resurrected, churned, netGrowth: newPlayers + resurrected - churned };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── retention ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction of players first seen on `cohortDate` who played again within
|
||||||
|
* `n` days. Returns null for cohorts smaller than `minSize`.
|
||||||
|
*/
|
||||||
|
export function returnWithin(
|
||||||
|
completions: CompletionRow[],
|
||||||
|
cohortDate: string,
|
||||||
|
n: number,
|
||||||
|
minSize = 1
|
||||||
|
): number | null {
|
||||||
|
const firsts = firstPlayDates(completions);
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
const end = addDays(cohortDate, n);
|
||||||
|
|
||||||
|
let cohort = 0;
|
||||||
|
let returned = 0;
|
||||||
|
for (const [id, first] of firsts) {
|
||||||
|
if (first !== cohortDate) continue;
|
||||||
|
cohort++;
|
||||||
|
const dates = datesByPlayer.get(id) ?? [];
|
||||||
|
if (dates.some((d) => d > cohortDate && d <= end)) returned++;
|
||||||
|
}
|
||||||
|
if (cohort < minSize) return null;
|
||||||
|
return pct(returned, cohort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next-day return rate for a cohort of first-timers. */
|
||||||
|
export function d1Return(completions: CompletionRow[], cohortDate: string, minSize = 1): number | null {
|
||||||
|
return returnWithin(completions, cohortDate, 1, minSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weekly cohort triangle. Each row is a cohort (players whose first play fell
|
||||||
|
* in that week) and each column k is the share active k weeks later.
|
||||||
|
*/
|
||||||
|
export function cohortTriangle(
|
||||||
|
completions: CompletionRow[],
|
||||||
|
opts: { start: string; weeks: number; asOf?: string; minSize?: number }
|
||||||
|
): CohortRow[] {
|
||||||
|
const { start, weeks, asOf, minSize = 1 } = opts;
|
||||||
|
const firsts = firstPlayDates(completions);
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
const rows: CohortRow[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < weeks; i++) {
|
||||||
|
const weekStart = addDays(start, i * 7);
|
||||||
|
const weekEnd = addDays(weekStart, 6);
|
||||||
|
const members: string[] = [];
|
||||||
|
for (const [id, first] of firsts) {
|
||||||
|
if (first >= weekStart && first <= weekEnd) members.push(id);
|
||||||
|
}
|
||||||
|
const size = members.length;
|
||||||
|
const counts: number[] = [];
|
||||||
|
const rates: (number | null)[] = [];
|
||||||
|
for (let k = 0; k < weeks - i; k++) {
|
||||||
|
const wStart = addDays(weekStart, k * 7);
|
||||||
|
const wEnd = addDays(wStart, 6);
|
||||||
|
if (asOf && wStart > asOf) {
|
||||||
|
counts.push(0);
|
||||||
|
rates.push(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let active = 0;
|
||||||
|
for (const id of members) {
|
||||||
|
const dates = datesByPlayer.get(id) ?? [];
|
||||||
|
if (dates.some((d) => d >= wStart && d <= wEnd)) active++;
|
||||||
|
}
|
||||||
|
counts.push(active);
|
||||||
|
rates.push(size < minSize ? null : pct(active, size));
|
||||||
|
}
|
||||||
|
rows.push({ weekStart, size, counts, rates });
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days until a first-timer's second play, bucketed. */
|
||||||
|
export function timeToSecondPlayHistogram(completions: CompletionRow[]): HistogramBin[] {
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
const gaps: number[] = [];
|
||||||
|
for (const dates of datesByPlayer.values()) {
|
||||||
|
if (dates.length < 2) continue;
|
||||||
|
gaps.push(dayDiff(dates[0], dates[1]));
|
||||||
|
}
|
||||||
|
const buckets: { label: string; test: (g: number) => boolean }[] = [
|
||||||
|
{ label: '1 day', test: (g) => g === 1 },
|
||||||
|
{ label: '2 days', test: (g) => g === 2 },
|
||||||
|
{ label: '3–4 days', test: (g) => g >= 3 && g <= 4 },
|
||||||
|
{ label: '5–7 days', test: (g) => g >= 5 && g <= 7 },
|
||||||
|
{ label: '8–14 days', test: (g) => g >= 8 && g <= 14 },
|
||||||
|
{ label: '15–30 days', test: (g) => g >= 15 && g <= 30 },
|
||||||
|
{ label: '31+ days', test: (g) => g >= 31 }
|
||||||
|
];
|
||||||
|
return buckets.map((b) => ({ label: b.label, value: gaps.filter(b.test).length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── guesses / difficulty ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Distribution of guess counts, with everything above `max` collapsed. */
|
||||||
|
export function guessHistogram(completions: CompletionRow[], max = 8): HistogramBin[] {
|
||||||
|
const bins: HistogramBin[] = [];
|
||||||
|
for (let g = 1; g <= max; g++) bins.push({ label: String(g), value: 0 });
|
||||||
|
bins.push({ label: `${max + 1}+`, value: 0 });
|
||||||
|
for (const c of completions) {
|
||||||
|
if (c.guessCount <= max) bins[c.guessCount - 1].value++;
|
||||||
|
else bins[max].value++;
|
||||||
|
}
|
||||||
|
return bins;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share of completions solved on the very first guess, %. */
|
||||||
|
export function firstTryRate(completions: CompletionRow[]): number | null {
|
||||||
|
if (completions.length === 0) return null;
|
||||||
|
const firstTry = completions.filter((c) => c.guessCount === 1).length;
|
||||||
|
return pct(firstTry, completions.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function averageGuesses(completions: CompletionRow[]): number | null {
|
||||||
|
if (completions.length === 0) return null;
|
||||||
|
const total = completions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||||
|
return round1(total / completions.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── streaks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StreakSurvivalPoint {
|
||||||
|
length: number;
|
||||||
|
probability: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probability that a streak of length N continues to N+1.
|
||||||
|
* Denominator counts every day a player's run reached length N.
|
||||||
|
*/
|
||||||
|
export function streakSurvival(completions: CompletionRow[]): StreakSurvivalPoint[] {
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
const attempts = new Map<number, number>();
|
||||||
|
const continued = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const dates of datesByPlayer.values()) {
|
||||||
|
let streak = 0;
|
||||||
|
for (let i = 0; i < dates.length; i++) {
|
||||||
|
streak = i > 0 && dayDiff(dates[i - 1], dates[i]) === 1 ? streak + 1 : 1;
|
||||||
|
attempts.set(streak, (attempts.get(streak) ?? 0) + 1);
|
||||||
|
const tomorrow = addDays(dates[i], 1);
|
||||||
|
if (dates.includes(tomorrow)) {
|
||||||
|
continued.set(streak, (continued.get(streak) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengths = [...attempts.keys()].sort((a, b) => a - b);
|
||||||
|
const out: StreakSurvivalPoint[] = [];
|
||||||
|
for (const length of lengths) {
|
||||||
|
const denom = attempts.get(length) ?? 0;
|
||||||
|
if (denom === 0) continue;
|
||||||
|
out.push({ length, probability: round1(((continued.get(length) ?? 0) / denom) * 100) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── content ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Shannon entropy (bits) of a count distribution. */
|
||||||
|
export function entropy(counts: number[]): number {
|
||||||
|
const total = counts.reduce((a, b) => a + b, 0);
|
||||||
|
if (total <= 0) return 0;
|
||||||
|
let h = 0;
|
||||||
|
for (const c of counts) {
|
||||||
|
if (c <= 0) continue;
|
||||||
|
const p = c / total;
|
||||||
|
h -= p * Math.log2(p);
|
||||||
|
}
|
||||||
|
return Math.round(h * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next-feature interval per book, bucketed. */
|
||||||
|
export function repeatIntervals(verses: VerseRow[]): HistogramBin[] {
|
||||||
|
const byBook = new Map<string, string[]>();
|
||||||
|
for (const v of verses) {
|
||||||
|
const arr = byBook.get(v.bookId);
|
||||||
|
if (arr) arr.push(v.date);
|
||||||
|
else byBook.set(v.bookId, [v.date]);
|
||||||
|
}
|
||||||
|
const gaps: number[] = [];
|
||||||
|
for (const dates of byBook.values()) {
|
||||||
|
dates.sort();
|
||||||
|
for (let i = 1; i < dates.length; i++) gaps.push(dayDiff(dates[i - 1], dates[i]));
|
||||||
|
}
|
||||||
|
const buckets: { label: string; test: (g: number) => boolean }[] = [
|
||||||
|
{ label: '1–7d', test: (g) => g <= 7 },
|
||||||
|
{ label: '8–14d', test: (g) => g >= 8 && g <= 14 },
|
||||||
|
{ label: '15–30d', test: (g) => g >= 15 && g <= 30 },
|
||||||
|
{ label: '31–60d', test: (g) => g >= 31 && g <= 60 },
|
||||||
|
{ label: '61–90d', test: (g) => g >= 61 && g <= 90 },
|
||||||
|
{ label: '90d+', test: (g) => g > 90 }
|
||||||
|
];
|
||||||
|
return buckets.map((b) => ({ label: b.label, value: gaps.filter(b.test).length }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookCoverageOptions {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
minPlays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-book content + retention stats over `[start, end]`.
|
||||||
|
* `days` is the number of distinct days the book was the answer; `plays` is the
|
||||||
|
* number of completions on those days.
|
||||||
|
*/
|
||||||
|
export function bookCoverage(
|
||||||
|
verses: VerseRow[],
|
||||||
|
completions: CompletionRow[],
|
||||||
|
opts: BookCoverageOptions
|
||||||
|
): BookStat[] {
|
||||||
|
const { start, end, minPlays = 1 } = opts;
|
||||||
|
const verseInRange = verses.filter((v) => v.date >= start && v.date <= end);
|
||||||
|
const bookByDate = new Map<string, string>();
|
||||||
|
const daysByBook = new Map<string, Set<string>>();
|
||||||
|
for (const v of verseInRange) {
|
||||||
|
bookByDate.set(v.date, v.bookId);
|
||||||
|
let set = daysByBook.get(v.bookId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
daysByBook.set(v.bookId, set);
|
||||||
|
}
|
||||||
|
set.add(v.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
const completionsInRange = completions.filter((c) => c.date >= start && c.date <= end);
|
||||||
|
|
||||||
|
// Aggregate per book
|
||||||
|
const stats = new Map<
|
||||||
|
string,
|
||||||
|
{ plays: number; firstTry: number; guessSum: number; d1: number; d1Denom: number }
|
||||||
|
>();
|
||||||
|
for (const c of completionsInRange) {
|
||||||
|
const bookId = bookByDate.get(c.date);
|
||||||
|
if (!bookId) continue;
|
||||||
|
let s = stats.get(bookId);
|
||||||
|
if (!s) {
|
||||||
|
s = { plays: 0, firstTry: 0, guessSum: 0, d1: 0, d1Denom: 0 };
|
||||||
|
stats.set(bookId, s);
|
||||||
|
}
|
||||||
|
s.plays++;
|
||||||
|
if (c.guessCount === 1) s.firstTry++;
|
||||||
|
s.guessSum += c.guessCount;
|
||||||
|
s.d1Denom++;
|
||||||
|
const dates = datesByPlayer.get(c.anonymousId) ?? [];
|
||||||
|
if (dates.includes(addDays(c.date, 1))) s.d1++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out: BookStat[] = [];
|
||||||
|
for (const book of bibleBooks) {
|
||||||
|
const days = daysByBook.get(book.id)?.size ?? 0;
|
||||||
|
const s = stats.get(book.id);
|
||||||
|
if (!s || s.plays < minPlays) {
|
||||||
|
out.push({
|
||||||
|
bookId: book.id,
|
||||||
|
days,
|
||||||
|
plays: s?.plays ?? 0,
|
||||||
|
firstTryPct: s ? (pct(s.firstTry, s.plays) ?? 0) : 0,
|
||||||
|
avgGuesses: s ? round1(s.guessSum / s.plays) : 0,
|
||||||
|
d1Return: null
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
bookId: book.id,
|
||||||
|
days,
|
||||||
|
plays: s.plays,
|
||||||
|
firstTryPct: pct(s.firstTry, s.plays) ?? 0,
|
||||||
|
avgGuesses: round1(s.guessSum / s.plays),
|
||||||
|
d1Return: pct(s.d1, s.d1Denom)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next-day return rate grouped by how many guesses the solve took. */
|
||||||
|
export function retentionByGuessBucket(
|
||||||
|
completions: CompletionRow[]
|
||||||
|
): { bucket: string; d1: number | null; plays: number }[] {
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
const buckets: { label: string; test: (g: number) => boolean }[] = [
|
||||||
|
{ label: '1', test: (g) => g === 1 },
|
||||||
|
{ label: '2', test: (g) => g === 2 },
|
||||||
|
{ label: '3', test: (g) => g === 3 },
|
||||||
|
{ label: '4–5', test: (g) => g >= 4 && g <= 5 },
|
||||||
|
{ label: '6+', test: (g) => g >= 6 }
|
||||||
|
];
|
||||||
|
return buckets.map((b) => {
|
||||||
|
const rows = completions.filter((c) => b.test(c.guessCount));
|
||||||
|
let returned = 0;
|
||||||
|
for (const c of rows) {
|
||||||
|
const dates = datesByPlayer.get(c.anonymousId) ?? [];
|
||||||
|
if (dates.includes(addDays(c.date, 1))) returned++;
|
||||||
|
}
|
||||||
|
return { bucket: b.label, d1: pct(returned, rows.length), plays: rows.length };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completion counts by weekday (0=Sun) and UTC hour. */
|
||||||
|
export function weekdayHourHeatmap(completions: CompletionRow[]): number[][] {
|
||||||
|
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||||
|
for (const c of completions) {
|
||||||
|
const d = new Date(c.completedAt);
|
||||||
|
grid[d.getUTCDay()][d.getUTCHours()]++;
|
||||||
|
}
|
||||||
|
return grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall share of players who ever played more than once. */
|
||||||
|
export function overallReturnRate(completions: CompletionRow[]): number | null {
|
||||||
|
const datesByPlayer = groupDatesByPlayer(completions);
|
||||||
|
if (datesByPlayer.size === 0) return null;
|
||||||
|
let returned = 0;
|
||||||
|
for (const dates of datesByPlayer.values()) if (dates.length >= 2) returned++;
|
||||||
|
return pct(returned, datesByPlayer.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Players who completed on each date, for daily series. */
|
||||||
|
export function dailyCompletionsSeries(
|
||||||
|
completions: CompletionRow[],
|
||||||
|
start: string,
|
||||||
|
end: string
|
||||||
|
): { date: string; completions: number; newPlayers: number }[] {
|
||||||
|
const firsts = firstPlayDates(completions);
|
||||||
|
const byDate = new Map<string, { completions: number; newPlayers: number }>();
|
||||||
|
for (const date of dateRange(start, end)) byDate.set(date, { completions: 0, newPlayers: 0 });
|
||||||
|
for (const c of completions) {
|
||||||
|
const row = byDate.get(c.date);
|
||||||
|
if (!row) continue;
|
||||||
|
row.completions++;
|
||||||
|
if (firsts.get(c.anonymousId) === c.date) row.newPlayers++;
|
||||||
|
}
|
||||||
|
return [...byDate.entries()].map(([date, v]) => ({ date, ...v }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { startOfWeek, addDays, dayDiff };
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type {
|
||||||
|
PlayerState,
|
||||||
|
SegmentCounts,
|
||||||
|
SegmentSets,
|
||||||
|
StatsSettings
|
||||||
|
} from '$lib/types/stats';
|
||||||
|
import { dayDiff } from './dates';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify players into mutually-exclusive lifecycle segments using the
|
||||||
|
* thresholds supplied at render time. The stored player state is frozen as of
|
||||||
|
* the snapshot date, so changing thresholds re-segments all history instantly
|
||||||
|
* without rebuilding anything.
|
||||||
|
*/
|
||||||
|
export function deriveSegments(
|
||||||
|
players: PlayerState[],
|
||||||
|
asOfDate: string,
|
||||||
|
settings: StatsSettings
|
||||||
|
): SegmentSets {
|
||||||
|
const sets: SegmentSets = {
|
||||||
|
new: [],
|
||||||
|
core: [],
|
||||||
|
returning: [],
|
||||||
|
resurrected: [],
|
||||||
|
atRisk: [],
|
||||||
|
churned: [],
|
||||||
|
active: []
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const p of players) {
|
||||||
|
const inactive = dayDiff(p.lastDate, asOfDate);
|
||||||
|
const firstAge = dayDiff(p.firstDate, asOfDate);
|
||||||
|
|
||||||
|
// Inactive segments first.
|
||||||
|
if (inactive >= settings.churn_days) {
|
||||||
|
sets.churned.push(p.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inactive >= settings.at_risk_days) {
|
||||||
|
sets.atRisk.push(p.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sets.active.push(p.id);
|
||||||
|
|
||||||
|
if (firstAge < 7) {
|
||||||
|
sets.new.push(p.id);
|
||||||
|
} else if (p.plays7 >= settings.core_plays_per_week) {
|
||||||
|
sets.core.push(p.id);
|
||||||
|
} else if (
|
||||||
|
p.plays7 > 0 &&
|
||||||
|
firstAge > settings.resurrect_gap_days &&
|
||||||
|
p.plays30 <= p.plays7
|
||||||
|
) {
|
||||||
|
// Active this week, no activity in the 8–30 day band, first seen long
|
||||||
|
// ago → they came back after a long gap.
|
||||||
|
sets.resurrected.push(p.id);
|
||||||
|
} else {
|
||||||
|
sets.returning.push(p.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sets;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countSegments(sets: SegmentSets): SegmentCounts {
|
||||||
|
return {
|
||||||
|
new: sets.new.length,
|
||||||
|
core: sets.core.length,
|
||||||
|
returning: sets.returning.length,
|
||||||
|
resurrected: sets.resurrected.length,
|
||||||
|
atRisk: sets.atRisk.length,
|
||||||
|
churned: sets.churned.length,
|
||||||
|
active: sets.active.length,
|
||||||
|
total:
|
||||||
|
sets.active.length +
|
||||||
|
sets.atRisk.length +
|
||||||
|
sets.churned.length
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { db as defaultDb } from '$lib/server/db';
|
||||||
|
import { dashboardSettings } from '$lib/server/db/schema';
|
||||||
|
import {
|
||||||
|
SETTINGS_DEFAULTS,
|
||||||
|
type SettingsKey,
|
||||||
|
type StatsSettings
|
||||||
|
} from '$lib/types/stats';
|
||||||
|
import type { StatsDb } from './db';
|
||||||
|
|
||||||
|
const SETTINGS_KEYS = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
|
||||||
|
|
||||||
|
/** `core_plays_per_week` → `STATS_CORE_PLAYS_PER_WEEK` */
|
||||||
|
export function settingsEnvVar(key: SettingsKey): string {
|
||||||
|
return `STATS_${key.toUpperCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Defaults from `SETTINGS_DEFAULTS`, overridden by `STATS_*` env vars. */
|
||||||
|
export function envSettings(): StatsSettings {
|
||||||
|
const out = { ...SETTINGS_DEFAULTS } as StatsSettings;
|
||||||
|
for (const key of SETTINGS_KEYS) {
|
||||||
|
const raw = Bun.env[settingsEnvVar(key)];
|
||||||
|
if (raw === undefined || raw === '') continue;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (Number.isFinite(value)) out[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerce(key: SettingsKey, raw: string): number {
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isFinite(value) ? value : SETTINGS_DEFAULTS[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads persisted overrides from `dashboard_settings` and overlays them on the
|
||||||
|
* env/default baseline. Safe to call when the table does not yet exist.
|
||||||
|
*/
|
||||||
|
export async function getSettings(database: StatsDb = defaultDb): Promise<StatsSettings> {
|
||||||
|
const settings = envSettings();
|
||||||
|
try {
|
||||||
|
const rows = await database.select().from(dashboardSettings);
|
||||||
|
for (const row of rows) {
|
||||||
|
if ((SETTINGS_KEYS as string[]).includes(row.key)) {
|
||||||
|
settings[row.key as SettingsKey] = coerce(row.key as SettingsKey, row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Table missing / DB unavailable — fall back to defaults.
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upserts a partial set of setting overrides. */
|
||||||
|
export async function updateSettings(
|
||||||
|
partial: Partial<StatsSettings>,
|
||||||
|
database: StatsDb = defaultDb
|
||||||
|
): Promise<StatsSettings> {
|
||||||
|
const now = new Date();
|
||||||
|
for (const [key, value] of Object.entries(partial)) {
|
||||||
|
if (!(SETTINGS_KEYS as string[]).includes(key)) continue;
|
||||||
|
if (value === undefined || value === null || !Number.isFinite(value)) continue;
|
||||||
|
await database
|
||||||
|
.insert(dashboardSettings)
|
||||||
|
.values({ key, value: String(value), updatedAt: now })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: dashboardSettings.key,
|
||||||
|
set: { value: String(value), updatedAt: now }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getSettings(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable short hash of the resolved settings — used to detect stale snapshots. */
|
||||||
|
export function hashSettings(settings: StatsSettings): string {
|
||||||
|
const canonical = SETTINGS_KEYS.map((k) => `${k}=${settings[k]}`).join(';');
|
||||||
|
const digest = new Bun.CryptoHasher('sha256').update(canonical).digest('hex');
|
||||||
|
return digest.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function configVersion(database: StatsDb = defaultDb): Promise<string> {
|
||||||
|
return hashSettings(await getSettings(database));
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import type { SeriesPoint, Signal, SignalSeverity, SnapshotPayload, StatsSettings } from '$lib/types/stats';
|
||||||
|
|
||||||
|
// ── generic anomaly detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Anomaly {
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
median: number;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Robust, weekday-aware anomaly detection. Compares the latest point with the
|
||||||
|
* median/MAD of the same weekday over the preceding `lookback` points.
|
||||||
|
* Returns null when there is not enough history or the series is flat.
|
||||||
|
*/
|
||||||
|
export function detectAnomaly(series: SeriesPoint[], threshold: number, lookback = 28): Anomaly | null {
|
||||||
|
const points = series.filter((p) => p.value !== null) as { date: string; value: number }[];
|
||||||
|
if (points.length < 5) return null;
|
||||||
|
const latest = points[points.length - 1];
|
||||||
|
const weekday = new Date(latest.date + 'T00:00:00Z').getUTCDay();
|
||||||
|
const history = points
|
||||||
|
.slice(0, -1)
|
||||||
|
.filter((p) => new Date(p.date + 'T00:00:00Z').getUTCDay() === weekday)
|
||||||
|
.slice(-lookback);
|
||||||
|
if (history.length < 4) return null;
|
||||||
|
|
||||||
|
const values = history.map((p) => p.value).sort((a, b) => a - b);
|
||||||
|
const median = medianOf(values);
|
||||||
|
const deviations = values.map((v) => Math.abs(v - median)).sort((a, b) => a - b);
|
||||||
|
const mad = medianOf(deviations);
|
||||||
|
if (mad === 0) return null;
|
||||||
|
const score = (0.6745 * (latest.value - median)) / mad;
|
||||||
|
if (Math.abs(score) < threshold) return null;
|
||||||
|
return { date: latest.date, value: latest.value, median: Math.round(median * 10) / 10, score: Math.round(score * 10) / 10 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function medianOf(sorted: number[]): number {
|
||||||
|
if (sorted.length === 0) return 0;
|
||||||
|
const mid = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── rule helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Direction = 'up' | 'down';
|
||||||
|
|
||||||
|
interface TargetRule {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
action: string;
|
||||||
|
value: number | null;
|
||||||
|
target: number;
|
||||||
|
/** At or above target is good when direction is 'up'. */
|
||||||
|
direction?: Direction;
|
||||||
|
/** Absolute value that means "red" regardless of the target. */
|
||||||
|
red: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateTarget(rule: TargetRule): Signal {
|
||||||
|
const { id, title, action, value, target, red, direction = 'up', unit = '%' } = rule;
|
||||||
|
if (value === null) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
severity: 'info',
|
||||||
|
title,
|
||||||
|
detail: `Not enough data yet to measure this (needs instrumented events).`,
|
||||||
|
metric: id,
|
||||||
|
value: null,
|
||||||
|
target
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const meets = direction === 'up' ? value >= target : value <= target;
|
||||||
|
const isRed = direction === 'up' ? value < red : value > red;
|
||||||
|
let severity: SignalSeverity = 'good';
|
||||||
|
if (!meets) severity = isRed ? 'bad' : 'warn';
|
||||||
|
|
||||||
|
const comparison =
|
||||||
|
direction === 'up'
|
||||||
|
? value >= target
|
||||||
|
? `at or above the ${target}% target`
|
||||||
|
: `below the ${target}% target`
|
||||||
|
: value <= target
|
||||||
|
? `at or below the ${target}% target`
|
||||||
|
: `above the ${target}% target`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
severity,
|
||||||
|
title,
|
||||||
|
detail: `${title} is ${value}${unit}, ${comparison}.`,
|
||||||
|
action: severity === 'good' ? undefined : action,
|
||||||
|
metric: id,
|
||||||
|
value,
|
||||||
|
target
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── main engine ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate the explicit targets plus anomaly detection. Pure: the snapshot
|
||||||
|
* payload carries every number the engine needs.
|
||||||
|
*/
|
||||||
|
export function evaluateSignals(payload: SnapshotPayload, settings: StatsSettings): Signal[] {
|
||||||
|
const { kpis, activity } = payload;
|
||||||
|
const signals: Signal[] = [];
|
||||||
|
|
||||||
|
signals.push(
|
||||||
|
evaluateTarget({
|
||||||
|
id: 'd1_return',
|
||||||
|
title: 'D1 return',
|
||||||
|
action: 'Test a "come back tomorrow" prompt on the win screen.',
|
||||||
|
value: kpis.d1Return,
|
||||||
|
target: settings.d1_target,
|
||||||
|
red: 12
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
signals.push(
|
||||||
|
evaluateTarget({
|
||||||
|
id: 'return7',
|
||||||
|
title: '7-day return',
|
||||||
|
action: 'Improve the first-session experience or send a day-2 reminder.',
|
||||||
|
value: kpis.return7,
|
||||||
|
target: settings.return7_target,
|
||||||
|
red: 15
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
signals.push(
|
||||||
|
evaluateTarget({
|
||||||
|
id: 'completion',
|
||||||
|
title: 'Completion rate',
|
||||||
|
action: 'Look for friction between the first guess and the win screen.',
|
||||||
|
value: kpis.completionRate,
|
||||||
|
target: settings.completion_target,
|
||||||
|
red: 55
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// First-try rate is a band: both too high and too low are bad.
|
||||||
|
if (kpis.firstTryRate === null) {
|
||||||
|
signals.push({
|
||||||
|
id: 'first_try',
|
||||||
|
severity: 'info',
|
||||||
|
title: 'First-try rate',
|
||||||
|
detail: 'Not enough data yet to measure this.',
|
||||||
|
metric: 'first_try'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const v = kpis.firstTryRate;
|
||||||
|
const inBand = v >= settings.first_try_min && v <= settings.first_try_max;
|
||||||
|
const severe = v > 45 || v < 8;
|
||||||
|
signals.push({
|
||||||
|
id: 'first_try',
|
||||||
|
severity: inBand ? 'good' : severe ? 'bad' : 'warn',
|
||||||
|
title: 'Verse achievability',
|
||||||
|
detail: `First-try rate is ${v}% (target band ${settings.first_try_min}–${settings.first_try_max}%).${
|
||||||
|
v > settings.first_try_max
|
||||||
|
? ' Verses may be too easy / self-revealing.'
|
||||||
|
: v < settings.first_try_min
|
||||||
|
? ' Verses may be too hard.'
|
||||||
|
: ''
|
||||||
|
}`,
|
||||||
|
action: inBand ? undefined : 'Review the verse mix for the easiest / hardest books.',
|
||||||
|
metric: 'first_try',
|
||||||
|
value: v,
|
||||||
|
target: settings.first_try_max
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
signals.push(
|
||||||
|
evaluateTarget({
|
||||||
|
id: 'share_rate',
|
||||||
|
title: 'Share rate',
|
||||||
|
action: 'Make the share action more prominent or reward sharing.',
|
||||||
|
value: kpis.shareRate,
|
||||||
|
target: settings.share_target,
|
||||||
|
red: 15
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Core players: week-over-week direction.
|
||||||
|
if (activity.corePrevious === null) {
|
||||||
|
signals.push({
|
||||||
|
id: 'core_players',
|
||||||
|
severity: 'info',
|
||||||
|
title: 'Core players',
|
||||||
|
detail: `${activity.core} players finished 4+ times this week. No prior week to compare yet.`,
|
||||||
|
metric: 'core_players',
|
||||||
|
value: activity.core
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const delta = activity.core - activity.corePrevious;
|
||||||
|
signals.push({
|
||||||
|
id: 'core_players',
|
||||||
|
severity: delta >= 0 ? 'good' : 'warn',
|
||||||
|
title: 'Core players',
|
||||||
|
detail: `${activity.core} players finished 4+ times this week (${delta >= 0 ? '+' : ''}${delta} vs last week).`,
|
||||||
|
action: delta < 0 ? 'Check whether verse difficulty or reminders changed.' : undefined,
|
||||||
|
metric: 'core_players',
|
||||||
|
value: activity.core
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anomaly detection on the daily completions series.
|
||||||
|
const anomaly = detectAnomaly(
|
||||||
|
payload.dailySeries.map((d) => ({ date: d.date, value: d.completions })),
|
||||||
|
settings.anomaly_mad_threshold
|
||||||
|
);
|
||||||
|
if (anomaly) {
|
||||||
|
const direction = anomaly.score > 0 ? 'above' : 'below';
|
||||||
|
signals.push({
|
||||||
|
id: 'completions_anomaly',
|
||||||
|
severity: anomaly.score > 0 ? 'good' : 'warn',
|
||||||
|
title: 'Unusual day',
|
||||||
|
detail: `${anomaly.date} had ${anomaly.value} finishes, well ${direction} the usual ${anomaly.median} for that weekday.`,
|
||||||
|
action: anomaly.score < 0 ? 'Check for outages or a hard verse.' : undefined,
|
||||||
|
metric: 'completions',
|
||||||
|
value: anomaly.value,
|
||||||
|
target: anomaly.median
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return signals;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_ORDER: Record<SignalSeverity, number> = { bad: 0, warn: 1, info: 2, good: 3 };
|
||||||
|
|
||||||
|
export function sortSignals(signals: Signal[]): Signal[] {
|
||||||
|
return signals
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-line verdict for the Pulse banner. */
|
||||||
|
export function verdictLine(signals: Signal[]): string {
|
||||||
|
const top = sortSignals(signals)[0];
|
||||||
|
if (!top) return 'No signals yet.';
|
||||||
|
if (top.severity === 'good') return `All core metrics are on target. ${top.detail}`;
|
||||||
|
if (top.severity === 'info') return top.detail;
|
||||||
|
return top.detail;
|
||||||
|
}
|
||||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user