mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
Compare commits
104
Commits
stats
..
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 | ||
|
|
9406498cc9 | ||
|
|
3947e8adb0 | ||
|
|
244113671e | ||
|
|
5b9b2f76f4 | ||
|
|
f7ec0742e1 | ||
|
|
d797b980ea | ||
|
|
ff228fb547 |
No files matched your search
@@ -5,7 +5,6 @@
|
||||
"Read(./secrets/**)",
|
||||
"Read(./config/credentials.json)",
|
||||
"Read(./build)",
|
||||
"Read(./**.xml)",
|
||||
"Read(./embeddings**)"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
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
|
||||
|
||||
+5
-1
@@ -28,4 +28,8 @@ vite.config.ts.timestamp-*
|
||||
llms-*
|
||||
|
||||
embeddings*
|
||||
*.xml
|
||||
*bible.xml
|
||||
engwebu_usfx.xml
|
||||
|
||||
deploy.log
|
||||
bibdle.socket
|
||||
@@ -1,149 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Bibdle is a daily Bible verse guessing game built with SvelteKit 5. Players read a verse and try to guess which book of the Bible it comes from. The game provides feedback hints (Testament match, Section match, Adjacent book, etc.) similar to Wordle-style games. Progress is stored locally in the browser and a new verse is generated daily.
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
(Make sure you use the Svelte agent to execute these commands)
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: SvelteKit 5 with Svelte 5 (uses runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Database**: SQLite with Drizzle ORM
|
||||
- **Auth**: Session-based authentication using @oslojs/crypto (SHA-256 hashed tokens)
|
||||
- **Deployment**: Node.js adapter for production builds
|
||||
- **External API**: bible-api.com for fetching random verses
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
bun run dev
|
||||
|
||||
# Type checking
|
||||
bun run check
|
||||
bun run check:watch
|
||||
|
||||
# Build for production
|
||||
bun run build
|
||||
|
||||
# Preview production build
|
||||
bun run preview
|
||||
|
||||
# Database operations
|
||||
bun run db:push # Push schema changes to database
|
||||
bun run db:generate # Generate migrations
|
||||
bun run db:migrate # Run migrations
|
||||
bun run db:studio # Open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Schema (`src/lib/server/db/schema.ts`)
|
||||
|
||||
- **user**: User accounts with id and age
|
||||
- **session**: Auth sessions linked to users with expiration timestamps
|
||||
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
|
||||
|
||||
Sessions expire after 30 days and are automatically renewed when less than 15 days remain.
|
||||
|
||||
### Bible Data (`src/lib/types/bible.ts`)
|
||||
|
||||
The `bibleBooks` array contains all 66 Bible books with metadata:
|
||||
- Testament (old/new)
|
||||
- Section (Law, History, Wisdom, Prophets, Gospels, Epistles, Apocalyptic)
|
||||
- Order (1-66, used for adjacency detection)
|
||||
- Popularity (2-10, affects grading - higher is more popular)
|
||||
|
||||
### Daily Verse System (`src/routes/+page.server.ts`)
|
||||
|
||||
The `getTodayVerse()` function:
|
||||
1. Checks database for existing verse for today's date
|
||||
2. If none exists, fetches from bible-api.com (random verse + 2 consecutive verses)
|
||||
3. Caches in database with UTC date key
|
||||
4. Returns verse with book metadata for the game
|
||||
|
||||
### Game Logic (`src/routes/+page.svelte`)
|
||||
|
||||
**State Management:**
|
||||
- `guesses` array stores game state in localStorage keyed by date
|
||||
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
|
||||
- `isWon` is derived from whether any guess matches the correct book
|
||||
|
||||
**Grading System:**
|
||||
```javascript
|
||||
// Grade formula combines performance + difficulty
|
||||
performanceScore = max(0, 10 - numGuesses)
|
||||
difficulty = 14 - popularity
|
||||
totalScore = performanceScore + difficulty * 0.8
|
||||
|
||||
// S: 14+, A: 11+, B: 8+, C: 5+, C-: <5
|
||||
```
|
||||
|
||||
**Hint System:**
|
||||
- ✅ Green checkmark: Exact match
|
||||
- 🟩 Green square: Section matches
|
||||
- 🟧 Orange square: Testament matches (shared results)
|
||||
- ‼️ Double exclamation: Adjacent book in Bible order
|
||||
- 🟥 Red square: No match
|
||||
|
||||
### Authentication System (`src/lib/server/auth.ts`)
|
||||
|
||||
- Token-based sessions with SHA-256 hashing
|
||||
- Cookies store session tokens, validated on each request
|
||||
- Hook in `src/hooks.server.ts` populates `event.locals.user` and `event.locals.session`
|
||||
- Note: Currently the schema includes user table but auth UI is not yet implemented
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/routes/+page.svelte` - Main game UI and client-side logic
|
||||
- `src/routes/+page.server.ts` - Server load function, fetches/caches daily verse
|
||||
- `src/lib/server/bible-api.ts` - External API integration for verse fetching
|
||||
- `src/lib/server/bible.ts` - Bible book utility functions
|
||||
- `src/lib/types/bible.ts` - Bible books data and TypeScript types
|
||||
- `src/lib/server/db/schema.ts` - Drizzle ORM schema definitions
|
||||
- `src/hooks.server.ts` - SvelteKit server hook for session validation
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required in `.env`:
|
||||
- `DATABASE_URL` - Path to SQLite database file (e.g., `./local.db`)
|
||||
|
||||
## Deployment
|
||||
|
||||
The project uses `@sveltejs/adapter-node` for deployment. The build output is a Node.js server that can be run with systemd or similar process managers. See `bibdle.service` and `bibdle.socket` for systemd configuration.
|
||||
|
||||
## Important Notes
|
||||
|
||||
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`) - not stores or reactive declarations
|
||||
- The schema includes authentication tables but the login/signup UI is not yet implemented
|
||||
- Daily verses are cached permanently in the database to ensure consistency
|
||||
- LocalStorage persists guesses per day using the key pattern `bibdle-guesses-${date}`
|
||||
- The game validates book IDs from the API against the hardcoded `bibleBooks` array
|
||||
+2
-2
@@ -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="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="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="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>
|
||||
@@ -33616,4 +33616,4 @@
|
||||
</chapter>
|
||||
</book>
|
||||
</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
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
- **Framework**: SvelteKit 2 with Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
|
||||
- **Styling**: Tailwind CSS 4
|
||||
- **Database**: SQLite (`bun:sqlite`) with Drizzle ORM
|
||||
- **Auth**: Session-based — email/password (argon2id via `Bun.password`), plus Apple and Google OAuth
|
||||
- **Bible text**: Local NKJV XML (`EnglishNKJBible.xml`), parsed with `fast-xml-parser`; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
|
||||
- **ML** (currently disabled): `@xenova/transformers` verse embeddings for a similarity search route
|
||||
- **Deployment**: `@sveltejs/adapter-node`, run under Bun, managed by a systemd service (see `bibdle.service`)
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
## Getting Started
|
||||
|
||||
```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:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```bash
|
||||
bun run db:push # push schema changes directly (avoid in prod)
|
||||
bun run db:generate # generate migrations
|
||||
bun run db:migrate # run migrations
|
||||
bun run db:studio # open Drizzle Studio GUI
|
||||
```
|
||||
|
||||
## 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
|
||||
npm run build
|
||||
```
|
||||
See `.env.example`. Required/used variables:
|
||||
|
||||
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]
|
||||
Description=Bibdle SvelteKit App
|
||||
Documentation=https://github.com/sveltejs/kit/tree/main/packages/adapter-node
|
||||
Requires=bibdle.socket
|
||||
After=network-online.target bibdle.socket
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Environment=NODE_ENV=production
|
||||
Environment=ORIGIN=https://bibdle.com
|
||||
Environment=DATABASE_URL=local.db
|
||||
Environment=IDLE_TIMEOUT=60
|
||||
WorkingDirectory=/home/george/projects/bibdle
|
||||
ExecStart=/home/george/.nvm/versions/node/v24.12.0/bin/node build/index.js
|
||||
Environment=DATABASE_URL=prod.db
|
||||
Environment=IDLE_TIMEOUT=300
|
||||
Environment=PORT=5173
|
||||
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
|
||||
RestartSec=3
|
||||
|
||||
[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 |
@@ -3,16 +3,24 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BUN=$(which bun)
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
git pull
|
||||
PULL_OUTPUT=$(git pull)
|
||||
echo "$PULL_OUTPUT"
|
||||
if [ "$PULL_OUTPUT" = "Already up to date." ]; then
|
||||
echo "Nothing to deploy."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Installing dependencies..."
|
||||
bun i
|
||||
$BUN i
|
||||
|
||||
echo "Building..."
|
||||
bun run build
|
||||
$BUN --bun run build
|
||||
|
||||
echo "Restarting service..."
|
||||
sudo systemctl restart bibdle
|
||||
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,
|
||||
"tag": "0000_clumsy_impossible_man",
|
||||
"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"
|
||||
+7
-6
@@ -4,26 +4,26 @@
|
||||
"version": "3.0.0alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"dev": "bun --bun vite dev",
|
||||
"build": "bun --bun vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"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:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
"@sveltejs/adapter-node": "^5.5.2",
|
||||
"@sveltejs/kit": "^2.50.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^22.19.7",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -35,8 +35,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@xenova/transformers": "^2.17.2",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"drizzle": "^1.4.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"marked": "^17.0.4",
|
||||
"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
|
||||
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>
|
||||
<meta charset="utf-8" />
|
||||
<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%
|
||||
</head>
|
||||
<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);
|
||||
|
||||
if (session) {
|
||||
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||
auth.setSessionTokenCookie({ cookies: event.cookies }, sessionToken, session.expiresAt);
|
||||
} else {
|
||||
auth.deleteSessionTokenCookie(event);
|
||||
auth.deleteSessionTokenCookie({ cookies: event.cookies });
|
||||
}
|
||||
|
||||
event.locals.user = user;
|
||||
@@ -32,4 +32,4 @@ export const handle: Handle = handleAuth;
|
||||
|
||||
// Initialize embeddings on server start (runs once on module load)
|
||||
const verses = getAllNKJVVerses();
|
||||
await initializeEmbeddings(verses);
|
||||
// await initializeEmbeddings(verses);
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 248 204">
|
||||
<path fill="#1d9bf0" d="M221.95 51.29c.15 2.17.15 4.34.15 6.53 0 66.73-50.8 143.69-143.69 143.69v-.04c-27.44.04-54.31-7.82-77.41-22.64 3.99.48 8 .72 12.02.73 22.74.02 44.83-7.61 62.72-21.66-21.61-.41-40.56-14.5-47.18-35.07 7.57 1.46 15.37 1.16 22.8-.87-23.56-4.76-40.51-25.46-40.51-49.5v-.64c7.02 3.91 14.88 6.08 22.92 6.32C11.58 63.31 4.74 33.79 18.14 10.71c25.64 31.55 63.47 50.73 104.08 52.76-4.07-17.54 1.49-35.92 14.61-48.25 20.34-19.12 52.33-18.14 71.45 2.19 11.31-2.23 22.15-6.38 32.07-12.26-3.77 11.69-11.66 21.62-22.2 27.93 10.01-1.18 19.79-3.86 29-7.95-6.78 10.16-15.32 19.01-25.2 26.16z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 732 B |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
|
||||
interface Props {
|
||||
completions: Array<{ date: string; guessCount: number }>;
|
||||
}
|
||||
|
||||
let { completions }: Props = $props();
|
||||
|
||||
type CalendarCell = {
|
||||
date: string;
|
||||
dayNum: number;
|
||||
played: boolean;
|
||||
guessCount: number | null;
|
||||
} | null;
|
||||
|
||||
type CalendarRow = {
|
||||
cells: CalendarCell[];
|
||||
monthLabel: string | null;
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
function buildCalendar(
|
||||
completionList: Array<{ date: string; guessCount: number }>,
|
||||
localDate: string,
|
||||
): CalendarRow[] {
|
||||
const completionMap = new Map(
|
||||
completionList.map((c) => [c.date, c.guessCount]),
|
||||
);
|
||||
const today = new Date(localDate + "T00:00:00Z");
|
||||
|
||||
const days: Array<{
|
||||
date: string;
|
||||
dayNum: number;
|
||||
month: string;
|
||||
dayOfWeek: number;
|
||||
guessCount: number | null;
|
||||
}> = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setUTCDate(d.getUTCDate() - i);
|
||||
const dateStr = d.toISOString().slice(0, 10);
|
||||
days.push({
|
||||
date: dateStr,
|
||||
dayNum: d.getUTCDate(),
|
||||
month: dateStr.slice(0, 7),
|
||||
dayOfWeek: d.getUTCDay(),
|
||||
guessCount: completionMap.get(dateStr) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const rows: CalendarRow[] = [];
|
||||
let currentRow: CalendarCell[] = [];
|
||||
let currentMonth = "";
|
||||
let firstRowOfMonth = true;
|
||||
|
||||
for (const day of days) {
|
||||
if (day.month !== currentMonth) {
|
||||
if (currentRow.length > 0) {
|
||||
while (currentRow.length < 7) currentRow.push(null);
|
||||
rows.push({ cells: currentRow, monthLabel: null });
|
||||
currentRow = [];
|
||||
}
|
||||
for (let j = 0; j < day.dayOfWeek; j++) currentRow.push(null);
|
||||
currentMonth = day.month;
|
||||
firstRowOfMonth = true;
|
||||
}
|
||||
|
||||
currentRow.push({
|
||||
date: day.date,
|
||||
dayNum: day.dayNum,
|
||||
played: day.guessCount !== null,
|
||||
guessCount: day.guessCount ?? null,
|
||||
});
|
||||
|
||||
if (currentRow.length === 7) {
|
||||
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||
const label = firstRowOfMonth
|
||||
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||
: null;
|
||||
rows.push({ cells: currentRow, monthLabel: label });
|
||||
currentRow = [];
|
||||
firstRowOfMonth = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
while (currentRow.length < 7) currentRow.push(null);
|
||||
const [year, monthIdx] = currentMonth.split("-").map(Number);
|
||||
rows.push({
|
||||
cells: currentRow,
|
||||
monthLabel: firstRowOfMonth
|
||||
? `${MONTH_NAMES[monthIdx - 1]} ${year}`
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function calendarColor(day: {
|
||||
played: boolean;
|
||||
guessCount: number | null;
|
||||
}): string {
|
||||
if (!day.played) return "bg-gray-800/60 text-gray-400";
|
||||
const g = day.guessCount!;
|
||||
if (g === 1) return "bg-emerald-300";
|
||||
if (g <= 3) return "bg-emerald-500";
|
||||
if (g <= 5) return "bg-amber-400";
|
||||
if (g <= 7) return "bg-orange-500";
|
||||
return "bg-red-600";
|
||||
}
|
||||
|
||||
let calendarRows = $state<CalendarRow[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
const localDate = new Date().toLocaleDateString("en-CA");
|
||||
calendarRows = buildCalendar(completions, localDate);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Container class="p-4 md:p-6 w-full">
|
||||
<h2 class="text-xl font-bold text-gray-100 mb-3 w-full text-left">
|
||||
Activity
|
||||
</h2>
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="flex gap-1 mb-1">
|
||||
{#each ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as d (d)}
|
||||
<div
|
||||
class="w-10 h-5 text-center text-[10px] text-gray-500 font-medium shrink-0"
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<!-- Calendar rows -->
|
||||
{#each calendarRows as row, rowIdx (rowIdx)}
|
||||
{#if row.monthLabel}
|
||||
<div class="text-xs text-gray-400 font-semibold mt-3 mb-1">
|
||||
{row.monthLabel}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-1 mb-1">
|
||||
{#each row.cells as cell, cellIdx (cellIdx)}
|
||||
{#if cell}
|
||||
<div
|
||||
class="w-10 h-10 rounded flex items-center justify-center text-sm font-semibold shrink-0 {calendarColor(
|
||||
cell,
|
||||
)}"
|
||||
title={cell.played
|
||||
? `${cell.date}: ${cell.guessCount} guess${cell.guessCount === 1 ? "" : "es"}`
|
||||
: cell.date}
|
||||
>
|
||||
{cell.dayNum}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-10 h-10 shrink-0"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Legend -->
|
||||
<div class="flex flex-wrap gap-3 mt-3 text-xs text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-300"></span>
|
||||
1 guess
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-emerald-500"></span>
|
||||
2–3 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-amber-400"></span>
|
||||
4–5 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-orange-500"></span>
|
||||
6–7 guesses
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="inline-block w-3 h-3 rounded-sm bg-red-600"></span>
|
||||
8+ guesses
|
||||
</span>
|
||||
</div>
|
||||
</Container>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { browser } from '$app/environment';
|
||||
import Container from './Container.svelte';
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
anonymousId = ''
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
anonymousId: string;
|
||||
} = $props();
|
||||
|
||||
let mode = $state<'signin' | 'signup'>('signin');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let success = $state('');
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
|
||||
function resetForm() {
|
||||
email = '';
|
||||
password = '';
|
||||
firstName = '';
|
||||
lastName = '';
|
||||
error = '';
|
||||
success = '';
|
||||
}
|
||||
|
||||
function switchMode() {
|
||||
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
loading = true;
|
||||
error = '';
|
||||
success = '';
|
||||
}
|
||||
|
||||
function handleResult(event: any) {
|
||||
loading = false;
|
||||
const result = event.result;
|
||||
|
||||
if (result.type === 'success') {
|
||||
if (result.data?.success) {
|
||||
success = mode === 'signin' ? 'Signed in successfully!' : 'Account created successfully!';
|
||||
setTimeout(() => {
|
||||
if (browser) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, 1000);
|
||||
} else if (result.data?.error) {
|
||||
error = result.data.error;
|
||||
}
|
||||
} else if (result.type === 'failure') {
|
||||
error = result.data?.error || 'An error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm">
|
||||
<Container class="w-full max-w-md p-6 relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeModal}
|
||||
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">
|
||||
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/auth/apple">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium"
|
||||
data-umami-event="Sign in with Apple"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="/auth/google">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-white text-black rounded-md hover:bg-gray-100 transition-colors font-medium mt-3"
|
||||
data-umami-event="Sign in with Google"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="flex items-center my-4">
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
<span class="px-3 text-sm text-white/60">or</span>
|
||||
<div class="flex-1 border-t border-white/20"></div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={mode === 'signin' ? '/auth/signin' : '/auth/signup'}
|
||||
use:enhance={({ formData }) => {
|
||||
if (anonymousId) {
|
||||
formData.append('anonymousId', anonymousId);
|
||||
}
|
||||
handleSubmit();
|
||||
return handleResult;
|
||||
}}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if mode === 'signup'}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-sm font-medium text-white mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
type="text"
|
||||
bind:value={firstName}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="John"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-sm font-medium text-white mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
type="text"
|
||||
bind:value={lastName}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-white mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
bind:value={email}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-white mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
bind:value={password}
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-white bg-transparent placeholder-white/60"
|
||||
placeholder="••••••••"
|
||||
minlength="6"
|
||||
/>
|
||||
{#if mode === 'signup'}
|
||||
<p class="text-xs text-white/80 mt-1">Minimum 6 characters</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<p class="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<p class="text-sm text-green-600">{success}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full mt-6 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
||||
</span>
|
||||
{:else}
|
||||
{mode === 'signin' ? 'Sign In' : 'Create Account'}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-white">
|
||||
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchMode}
|
||||
class="text-blue-300 hover:text-blue-200 font-medium ml-1"
|
||||
>
|
||||
{mode === 'signin' ? 'Create one' : 'Sign in'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -7,6 +7,7 @@
|
||||
onclick?: () => void;
|
||||
class?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -15,6 +16,7 @@
|
||||
onclick,
|
||||
class: className = "",
|
||||
type = "button",
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
const variantClasses = {
|
||||
@@ -31,6 +33,7 @@
|
||||
<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}"
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
return chapterCounts[bookId] || 1;
|
||||
}
|
||||
|
||||
// Generate 6 random chapter options including the correct one
|
||||
// Generate 4 random chapter options including the correct one
|
||||
function generateChapterOptions(
|
||||
correctChapter: number,
|
||||
totalChapters: number,
|
||||
@@ -98,14 +98,14 @@
|
||||
const options = new Set<number>();
|
||||
options.add(correctChapter);
|
||||
|
||||
if (totalChapters >= 6) {
|
||||
while (options.size < 6) {
|
||||
if (totalChapters >= 4) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter =
|
||||
Math.floor(Math.random() * totalChapters) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
} else {
|
||||
while (options.size < 6) {
|
||||
while (options.size < 4) {
|
||||
const randomChapter = Math.floor(Math.random() * 10) + 1;
|
||||
options.add(randomChapter);
|
||||
}
|
||||
@@ -167,18 +167,18 @@
|
||||
</script>
|
||||
|
||||
<Container
|
||||
class="w-full p-6 sm:p-8 bg-linear-to-br from-yellow-100/80 to-amber-200/80 text-gray-800 shadow-md"
|
||||
class="w-full p-3 sm:p-4 bg-linear-to-br from-yellow-100/80 to-amber-200/80 dark:from-amber-900/40 dark:to-yellow-900/30 text-gray-800 dark:text-gray-100 shadow-md"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-xl sm:text-2xl font-bold mb-2">Bonus Challenge</p>
|
||||
<p class="text-sm sm:text-base opacity-80 mb-6">
|
||||
Guess the chapter for an even higher grade
|
||||
<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-3 lg:grid-cols-6 gap-3 sm:gap-4 justify-center mx-auto mb-6"
|
||||
>
|
||||
{#each chapterOptions as chapter}
|
||||
<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}
|
||||
@@ -193,8 +193,8 @@
|
||||
? isCorrect
|
||||
? "bg-green-500 text-white border-green-600 shadow-lg"
|
||||
: "bg-red-400 text-white border-red-500"
|
||||
: "bg-white/30 text-gray-400 border-gray-300 opacity-40"
|
||||
: "bg-white/80 hover:bg-white text-gray-800 border-gray-300 hover:border-amber-400 hover:shadow-md cursor-pointer"
|
||||
: "bg-white/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"
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts" generics="T">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Header {
|
||||
label: string;
|
||||
align?: 'left' | 'right';
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface Mode {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: T[];
|
||||
headers: Header[];
|
||||
row: Snippet<[item: T]>;
|
||||
empty?: Snippet;
|
||||
initialRows?: number;
|
||||
modes?: Mode[];
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
rows,
|
||||
headers,
|
||||
row: rowSnippet,
|
||||
empty,
|
||||
initialRows = 3,
|
||||
modes,
|
||||
mode = $bindable(modes && modes.length > 0 ? modes[0].value : undefined),
|
||||
}: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
// Reset expanded when mode changes (e.g. switching Rolling 30d ↔ Calendar)
|
||||
$effect(() => {
|
||||
mode;
|
||||
expanded = false;
|
||||
});
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded = !expanded;
|
||||
}
|
||||
|
||||
const displayedRows = $derived(expanded ? rows : rows.slice(0, initialRows));
|
||||
</script>
|
||||
|
||||
{#if modes && modes.length > 1}
|
||||
<div class="flex gap-1 bg-white/5 rounded-lg p-1 w-fit ml-auto mb-3">
|
||||
{#each modes as m (m.value)}
|
||||
{@const active = mode === m.value}
|
||||
<button
|
||||
onclick={() => (mode = m.value)}
|
||||
class="px-3 py-1 text-xs rounded-md transition-colors {active ? 'bg-white/10 text-gray-100' : 'text-gray-400 hover:text-gray-200'}"
|
||||
>
|
||||
{m.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rows.length === 0}
|
||||
{#if empty}
|
||||
{@render empty()}
|
||||
{:else}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">Not enough data yet.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-xl border border-white/10">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-white/5 text-gray-400 text-xs uppercase tracking-wide">
|
||||
{#each headers as header (header.label)}
|
||||
<th
|
||||
class="{header.align === 'right' ? 'text-right' : 'text-left'} px-4 py-3{header.width ? ' ' + header.width : ''}"
|
||||
>
|
||||
{header.label}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each displayedRows as item, i (i)}
|
||||
<tr class="border-t border-white/5 hover:bg-white/5 transition-colors">
|
||||
{@render rowSnippet(item)}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{#if rows.length > initialRows}
|
||||
<button
|
||||
onclick={toggleExpanded}
|
||||
class="mt-2 flex items-center gap-1 text-xs text-gray-500 hover:text-gray-300 transition-colors mx-auto"
|
||||
>
|
||||
<span>{expanded ? '▲ Show less' : '▼ Show more'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -10,7 +10,7 @@
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex flex-col items-center bg-white/50 backdrop-blur-sm rounded-2xl border border-white/50 shadow-sm {className}"
|
||||
class="inline-flex flex-col items-center bg-white/10 dark:bg-white/5 backdrop-blur-sm rounded-2xl border border-white/20 dark:border-white/10 shadow-sm {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
@@ -1,88 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
|
||||
let timeUntilNext = $state("");
|
||||
let intervalId: number | null = null;
|
||||
let timeUntilNext = $state("");
|
||||
let newVerseReady = $state(false);
|
||||
let showEncouragement = $state(false);
|
||||
let intervalId: number | null = null;
|
||||
let targetTime = 0;
|
||||
|
||||
function calculateTimeUntilFivePM(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
function initTarget() {
|
||||
const target = new Date();
|
||||
target.setHours(0, 0, 0, 0);
|
||||
if (Date.now() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
targetTime = target.getTime();
|
||||
}
|
||||
|
||||
// Set target to 5:00 PM today
|
||||
target.setHours(17, 0, 0, 0);
|
||||
function updateTimer() {
|
||||
const diff = targetTime - Date.now();
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (diff <= 0) {
|
||||
newVerseReady = true;
|
||||
timeUntilNext = "";
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
timeUntilNext = `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
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);
|
||||
onMount(() => {
|
||||
initTarget();
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
const winCount = Object.keys(localStorage).filter(
|
||||
(k) =>
|
||||
k.startsWith("bibdle-win-tracked-") &&
|
||||
localStorage.getItem(k) === "true",
|
||||
).length;
|
||||
showEncouragement = winCount < 3;
|
||||
});
|
||||
|
||||
function calculateTimeUntilMidnight(): string {
|
||||
const now = new Date();
|
||||
const target = new Date(now);
|
||||
|
||||
// Set target to midnight today
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
// If it's already past midnight, set target to tomorrow midnight
|
||||
if (now.getTime() >= target.getTime()) {
|
||||
target.setDate(target.getDate() + 1);
|
||||
}
|
||||
|
||||
const diff = target.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) {
|
||||
return "00:00:00";
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
return `${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
timeUntilNext = calculateTimeUntilMidnight();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
updateTimer();
|
||||
intervalId = window.setInterval(updateTimer, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="flex flex-col items-center bg-white/50 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm w-full"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold mb-2">
|
||||
Next Verse In
|
||||
</p>
|
||||
<p class="text-4xl font-triodion font-black text-gray-800 tabular-nums">
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full flex flex-col flex-1">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm w-full flex-1"
|
||||
>
|
||||
{#if newVerseReady}
|
||||
<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">Now</p>
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mt-2"
|
||||
>
|
||||
(refresh page to see the new verse)
|
||||
</p>
|
||||
{:else}
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold mb-2"
|
||||
>
|
||||
Next Verse In
|
||||
</p>
|
||||
<p
|
||||
class="text-4xl font-triodion font-black text-gray-800 dark:text-gray-100 tabular-nums whitespace-nowrap"
|
||||
>
|
||||
{timeUntilNext}
|
||||
</p>
|
||||
{#if showEncouragement}
|
||||
<p
|
||||
class="text-xs text-center uppercase tracking-[0.2em] text-gray-500 dark:text-gray-400 font-bold px-4 mt-3"
|
||||
>
|
||||
Come back tomorrow for a new verse!
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
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 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 shadow-sm"
|
||||
class="flex flex-col items-center gap-2 bg-white/50 dark:bg-black/30 backdrop-blur-sm px-8 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm"
|
||||
>
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
<p
|
||||
class="text-xs uppercase tracking-[0.2em] text-gray-500 dark:text-gray-300 font-bold"
|
||||
>
|
||||
A project by George Powell & Silent Summit Co.
|
||||
</p>
|
||||
<!-- <p class="text-xs uppercase tracking-[0.2em] text-gray-500 font-bold">
|
||||
@@ -25,38 +27,8 @@
|
||||
<!-- Bluesky Social Media Button -->
|
||||
</div>
|
||||
|
||||
<div class="mt-8 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"
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400"></div>
|
||||
|
||||
<a
|
||||
href="mailto:george+bibdle@silentsummit.co"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
<div class="mt-8">
|
||||
<SocialLinks />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,7 +1,44 @@
|
||||
<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
|
||||
@@ -22,6 +59,41 @@
|
||||
<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"
|
||||
@@ -86,4 +158,21 @@
|
||||
>
|
||||
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>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
let { guessCount }: { guessCount: number } = $props();
|
||||
|
||||
let promptText = $state("What book of the Bible is this verse from?");
|
||||
let visible = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
let fadeOutId: ReturnType<typeof setTimeout>;
|
||||
let changeId: ReturnType<typeof setTimeout>;
|
||||
|
||||
function animateTo(newText: string, delay = 0) {
|
||||
fadeOutId = setTimeout(() => {
|
||||
visible = false;
|
||||
changeId = setTimeout(() => {
|
||||
promptText = newText;
|
||||
visible = true;
|
||||
}, 300);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
if (guessCount === 0) {
|
||||
animateTo("What book of the Bible is this verse from?");
|
||||
} else {
|
||||
animateTo("Guess again", 2100);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(fadeOutId);
|
||||
clearTimeout(changeId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<p
|
||||
class="big-text text-center text-gray-800 dark:text-gray-100 mb-6 px-4"
|
||||
style="transition: opacity 0.3s ease; opacity: {visible ? 1 : 0};"
|
||||
>
|
||||
{promptText}
|
||||
</p>
|
||||
@@ -1,26 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
interface Guess {
|
||||
book: {
|
||||
id: string;
|
||||
name: string;
|
||||
testament: string;
|
||||
section: string;
|
||||
};
|
||||
testamentMatch: boolean;
|
||||
sectionMatch: boolean;
|
||||
adjacent: boolean;
|
||||
firstLetterMatch: boolean;
|
||||
}
|
||||
import { getFirstLetter, type Guess } from "$lib/utils/game";
|
||||
|
||||
let {
|
||||
guesses,
|
||||
correctBookId,
|
||||
}: { guesses: Guess[]; correctBookId: string } = $props();
|
||||
minimized = false,
|
||||
}: { guesses: Guess[]; correctBookId: string; minimized?: boolean } = $props();
|
||||
|
||||
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";
|
||||
@@ -28,6 +22,13 @@
|
||||
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",
|
||||
@@ -44,16 +45,27 @@
|
||||
(correctBook?.section === "Pauline Epistles" ||
|
||||
correctBook?.section === "General Epistles") &&
|
||||
correctBook.name[0] === "1";
|
||||
const guessStartsWithNumber = guess.book.name[0] === "1";
|
||||
const guessIsEpistlesWithNumber =
|
||||
(guess.book.section === "Pauline Epistles" ||
|
||||
guess.book.section === "General Epistles") &&
|
||||
guess.book.name[0] === "1";
|
||||
|
||||
if (
|
||||
correctIsEpistlesWithNumber &&
|
||||
guessStartsWithNumber &&
|
||||
guessIsEpistlesWithNumber &&
|
||||
guess.firstLetterMatch
|
||||
) {
|
||||
return "Yes"; // Special wordplay case
|
||||
const words = [
|
||||
"Exactly",
|
||||
"Right",
|
||||
"Yes",
|
||||
"Naturally",
|
||||
"Of course",
|
||||
"Sure",
|
||||
];
|
||||
return words[Math.floor(Math.random() * words.length)]; // Special wordplay case
|
||||
}
|
||||
return guess.book.name[0]; // Normal case: just show the first letter
|
||||
return getFirstLetter(guess.book.name); // Normal case: show first letter, ignoring numbers
|
||||
case "testament":
|
||||
return (
|
||||
guess.book.testament.charAt(0).toUpperCase() +
|
||||
@@ -65,82 +77,63 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !hasGuesses}
|
||||
<Container class="p-6 text-center">
|
||||
<h2 class="font-triodion text-xl italic mb-3 text-gray-800">
|
||||
Instructions
|
||||
</h2>
|
||||
<p class="text-gray-700 leading-relaxed italic">
|
||||
Guess what book of the bible you think the verse is from. You will
|
||||
get clues to tell you if your guess is close or not. Green means the
|
||||
category is correct; red means wrong.
|
||||
</p>
|
||||
</Container>
|
||||
{:else}
|
||||
{#if hasGuesses}
|
||||
<div class="space-y-3">
|
||||
<!-- Column Headers -->
|
||||
<div
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400"
|
||||
class="flex gap-2 justify-center mb-4 pb-2 border-b border-gray-400 dark:border-gray-600"
|
||||
>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Testament
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Section
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-semibold text-gray-700"
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First Letter
|
||||
</div>
|
||||
<div
|
||||
class="w-1/4 shrink-0 text-center text-sm font-bold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Book
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in {getBoxColor(
|
||||
guess.book.id === correctBookId,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 0 * 500}ms"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
{#if showMinimized && !expanded}
|
||||
<!-- Minimized view: first guess, divider, last two guesses -->
|
||||
|
||||
<!-- First guess (no animation since post-win) -->
|
||||
{@const firstGuess = guesses[0]}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- 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,
|
||||
firstGuess.testamentMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 1 * 500}ms"
|
||||
style="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
>{getBoxContent(firstGuess, "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,
|
||||
firstGuess.sectionMatch,
|
||||
firstGuess.adjacent,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 2 * 500}ms"
|
||||
style="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "section")}
|
||||
{#if guess.adjacent}
|
||||
>{getBoxContent(firstGuess, "section")}
|
||||
{#if firstGuess.adjacent}
|
||||
‼️
|
||||
{/if}
|
||||
</span>
|
||||
@@ -149,18 +142,173 @@
|
||||
<!-- 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,
|
||||
firstGuess.firstLetterMatch,
|
||||
)}"
|
||||
style="animation-delay: {rowIndex * 1000 + 3 * 500}ms"
|
||||
style="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>{getBoxContent(firstGuess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(firstGuess)}"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(firstGuess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Expand/collapse divider -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||
onclick={() => (expanded = true)}
|
||||
>
|
||||
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||
expand guesses ▼
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||
</button>
|
||||
|
||||
<!-- Last two guesses (no animation since post-win) -->
|
||||
{#each guesses.slice(-2) as guess (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- Testament Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.testamentMatch,
|
||||
)}"
|
||||
style="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Section Column -->
|
||||
<div
|
||||
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.sectionMatch,
|
||||
guess.adjacent,
|
||||
)}"
|
||||
style="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "section")}
|
||||
{#if guess.adjacent}
|
||||
‼️
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- First Letter Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.firstLetterMatch,
|
||||
)}"
|
||||
style="animation: none; opacity: 1; transform: none;"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||
style="animation: none; opacity: 1; transform: none; {getBookBoxStyle(guess)}"
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Full view: all guesses -->
|
||||
|
||||
{#each guesses as guess, rowIndex (guess.book.id)}
|
||||
<div class="flex gap-2 justify-center">
|
||||
<!-- Testament Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.testamentMatch,
|
||||
)}"
|
||||
style={showMinimized
|
||||
? "animation: none; opacity: 1; transform: none;"
|
||||
: `animation-delay: ${rowIndex * 1000 + 0 * 500}ms`}
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "testament")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Section Column -->
|
||||
<div
|
||||
class="relative w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.sectionMatch,
|
||||
guess.adjacent,
|
||||
)}"
|
||||
style={showMinimized
|
||||
? "animation: none; opacity: 1; transform: none;"
|
||||
: `animation-delay: ${rowIndex * 1000 + 1 * 500}ms`}
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "section")}
|
||||
{#if guess.adjacent}
|
||||
‼️
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- First Letter Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-2 border-opacity-80 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-md animate-flip-in {getBoxColor(
|
||||
guess.firstLetterMatch,
|
||||
)}"
|
||||
style={showMinimized
|
||||
? "animation: none; opacity: 1; transform: none;"
|
||||
: `animation-delay: ${rowIndex * 1000 + 2 * 500}ms`}
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-sm"
|
||||
>{getBoxContent(guess, "firstLetter")}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Book Column -->
|
||||
<div
|
||||
class="w-1/4 shrink-0 h-16 sm:h-20 md:h-24 border-4 rounded-lg flex items-center justify-center text-white font-bold text-base sm:text-lg md:text-xl shadow-lg animate-flip-in"
|
||||
style={showMinimized
|
||||
? `animation: none; opacity: 1; transform: none; ${getBookBoxStyle(guess)}`
|
||||
: `animation-delay: ${rowIndex * 1000 + 3 * 500}ms; ${getBookBoxStyle(guess)}`}
|
||||
>
|
||||
<span class="text-center leading-tight px-1 text-shadow-lg"
|
||||
>{getBoxContent(guess, "book")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{#if showMinimized && expanded && rowIndex === 0}
|
||||
<!-- Collapse divider shown right below the final (correct) guess -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 py-1 cursor-pointer group"
|
||||
onclick={() => (expanded = false)}
|
||||
>
|
||||
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400 group-hover:text-gray-700 dark:group-hover:text-gray-200 transition-colors whitespace-nowrap select-none">
|
||||
collapse guesses ▲
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-gray-300 dark:bg-gray-600 group-hover:bg-gray-400 dark:group-hover:bg-gray-500 transition-colors"></div>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
|
||||
+208
-203
@@ -1,241 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
interface ImposterData {
|
||||
verses: string[];
|
||||
refs: string[];
|
||||
imposterIndex: number;
|
||||
}
|
||||
|
||||
let data: ImposterData | null = null;
|
||||
let clicked: boolean[] = [];
|
||||
let gameOver = false;
|
||||
let loading = true;
|
||||
let error: string | null = null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 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();
|
||||
}
|
||||
function newGame() {
|
||||
loading = true;
|
||||
error = null;
|
||||
data = null;
|
||||
loadGame();
|
||||
}
|
||||
|
||||
onMount(loadGame);
|
||||
onMount(loadGame);
|
||||
|
||||
function formatVerse(verse: string): string {
|
||||
let formatted = verse;
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
formatted = formatted.replace(/[,:;-—]$/, "...");
|
||||
return formatted;
|
||||
}
|
||||
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 on:click={newGame}>Retry</button>
|
||||
</div>
|
||||
{:else if data}
|
||||
<!-- <div class="instructions">
|
||||
{#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}
|
||||
on:click={() => 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 on:click={newGame}>New Game</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
/*.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%;
|
||||
}
|
||||
.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-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 {
|
||||
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: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:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.verse-button.clicked {
|
||||
cursor: default;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.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.correct ~ .ref {
|
||||
color: #28a745;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
.verse-button.wrong ~ .ref {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.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,
|
||||
.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;
|
||||
}
|
||||
.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,92 +1,355 @@
|
||||
<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(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
);
|
||||
type DisplayMode = "simple" | "testament" | "sections";
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && filteredBooks.length > 0) {
|
||||
submitGuess(filteredBooks[0].id);
|
||||
}
|
||||
}
|
||||
const displayMode = $derived<DisplayMode>(
|
||||
guessCount >= 9 ? "sections" : guessCount >= 3 ? "testament" : "simple",
|
||||
);
|
||||
|
||||
const filteredBooks = $derived(
|
||||
bibleBooks.filter((book) =>
|
||||
book.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
),
|
||||
);
|
||||
|
||||
type SimpleGroup = { books: BibleBook[] };
|
||||
|
||||
type TestamentGroup = {
|
||||
testament: Testament;
|
||||
label: string;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
type SectionGroup = {
|
||||
testament: Testament;
|
||||
testamentLabel: string;
|
||||
showTestamentHeader: boolean;
|
||||
section: BibleSection;
|
||||
books: BibleBook[];
|
||||
};
|
||||
|
||||
const simpleGroup = $derived.by<SimpleGroup>(() => {
|
||||
const sorted = [...filteredBooks].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
return { books: sorted };
|
||||
});
|
||||
|
||||
const testamentGroups = $derived.by<TestamentGroup[]>(() => {
|
||||
const old = filteredBooks
|
||||
.filter((b) => b.testament === "old")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const newT = filteredBooks
|
||||
.filter((b) => b.testament === "new")
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const groups: TestamentGroup[] = [];
|
||||
if (old.length > 0) {
|
||||
groups.push({
|
||||
testament: "old",
|
||||
label: "Old Testament",
|
||||
books: old,
|
||||
});
|
||||
}
|
||||
if (newT.length > 0) {
|
||||
groups.push({
|
||||
testament: "new",
|
||||
label: "New Testament",
|
||||
books: newT,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const sectionGroups = $derived.by<SectionGroup[]>(() => {
|
||||
// Build an ordered list of (testament, section) pairs by iterating bibleBooks once
|
||||
const seenKeys: Record<string, true> = {};
|
||||
const orderedPairs: { testament: Testament; section: BibleSection }[] =
|
||||
[];
|
||||
|
||||
for (const book of bibleBooks) {
|
||||
const key = `${book.testament}:${book.section}`;
|
||||
if (!seenKeys[key]) {
|
||||
seenKeys[key] = true;
|
||||
orderedPairs.push({
|
||||
testament: book.testament,
|
||||
section: book.section,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const groups: SectionGroup[] = [];
|
||||
let lastTestament: Testament | null = null;
|
||||
|
||||
for (const pair of orderedPairs) {
|
||||
const books = filteredBooks.filter(
|
||||
(b) =>
|
||||
b.testament === pair.testament &&
|
||||
b.section === pair.section,
|
||||
);
|
||||
if (books.length === 0) continue;
|
||||
|
||||
const showTestamentHeader = pair.testament !== lastTestament;
|
||||
lastTestament = pair.testament;
|
||||
|
||||
groups.push({
|
||||
testament: pair.testament,
|
||||
testamentLabel:
|
||||
pair.testament === "old"
|
||||
? "Old Testament"
|
||||
: "New Testament",
|
||||
showTestamentHeader,
|
||||
section: pair.section,
|
||||
books,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
// First book in display order for Enter key submission
|
||||
const firstBookId = $derived.by<string | null>(() => {
|
||||
if (filteredBooks.length === 0) return null;
|
||||
if (displayMode === "simple") {
|
||||
return simpleGroup.books[0]?.id ?? null;
|
||||
}
|
||||
if (displayMode === "testament") {
|
||||
return testamentGroups[0]?.books[0]?.id ?? null;
|
||||
}
|
||||
return sectionGroups[0]?.books[0]?.id ?? null;
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && firstBookId) {
|
||||
submitGuess(firstBookId);
|
||||
}
|
||||
}
|
||||
|
||||
// const showBanner = $derived(guessCount >= 3);
|
||||
const showBanner = false;
|
||||
const bannerIsIndigo = $derived(guessCount >= 9);
|
||||
</script>
|
||||
|
||||
{#if showBanner}
|
||||
<p
|
||||
class="mb-3 text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{#if bannerIsIndigo}
|
||||
Testament & section groups now visible
|
||||
{:else}
|
||||
Old & New Testament groups now visible
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="absolute left-4 sm:left-6 top-1/2 transform -translate-y-1/2 w-5 h-5 sm:w-6 sm:h-6 text-gray-400"
|
||||
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="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 rounded-2xl text-base sm:text-lg md:text-xl focus:outline-none focus:border-blue-600 focus:ring-4 focus:ring-blue-200 transition-all bg-white"
|
||||
onkeydown={handleKeydown}
|
||||
autocomplete="off"
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-4 sm:right-6 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 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"
|
||||
>
|
||||
<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}
|
||||
<ul
|
||||
class="mt-4 max-h-60 sm:max-h-80 overflow-y-auto bg-white border border-gray-300 rounded-2xl shadow-xl"
|
||||
>
|
||||
{#each filteredBooks as book (book.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full p-4 sm:p-5 text-left {guessedIds.has(book.id)
|
||||
? 'opacity-60 cursor-not-allowed pointer-events-none hover:bg-gray-100 hover:text-gray-600'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'} transition-all border-b border-gray-100 last:border-b-0 flex items-center"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(book.id)
|
||||
? 'line-through text-gray-500'
|
||||
: ''}">{book.name}</span
|
||||
>
|
||||
<span class="ml-auto text-sm opacity-75"
|
||||
>({book.testament.toUpperCase()})</span
|
||||
>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 p-8">No books found</p>
|
||||
{/if}
|
||||
<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}
|
||||
<ul
|
||||
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"
|
||||
>
|
||||
{#if displayMode === "simple"}
|
||||
{#each simpleGroup.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 last:border-b-0 flex items-center transition-all
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 hover:text-blue-700'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id) ? -1 : 0}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-400"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||
></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={guessedIds.has(book.id)}
|
||||
>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id)
|
||||
? -1
|
||||
: 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(
|
||||
book.id,
|
||||
)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each sectionGroups as group (`${group.testament}:${group.section}`)}
|
||||
<li role="presentation">
|
||||
{#if group.showTestamentHeader}
|
||||
<div
|
||||
class="px-5 pt-3 pb-1 flex items-center gap-3 bg-gray-50 dark:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{group.testamentLabel}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-px bg-gray-200 dark:bg-gray-600"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="px-7 py-1.5 flex items-center gap-3 bg-gray-50/50 dark:bg-gray-700/30 border-b border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
<span
|
||||
class="text-[11px] font-medium uppercase tracking-wider text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{group.section}
|
||||
</span>
|
||||
<div
|
||||
class="flex-1 h-px bg-gray-100 dark:bg-gray-600"
|
||||
></div>
|
||||
</div>
|
||||
<ul>
|
||||
{#each group.books as book (book.id)}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={guessedIds.has(book.id)}
|
||||
>
|
||||
<button
|
||||
class="w-full px-5 py-4 text-left border-b border-gray-100 dark:border-gray-700 last:border-b-0 flex items-center transition-all dark:text-gray-200
|
||||
{guessedIds.has(book.id)
|
||||
? 'opacity-50 cursor-not-allowed pointer-events-none'
|
||||
: 'hover:bg-blue-50 dark:hover:bg-blue-900/40 hover:text-blue-700 dark:hover:text-blue-300'}"
|
||||
onclick={() => submitGuess(book.id)}
|
||||
tabindex={guessedIds.has(book.id)
|
||||
? -1
|
||||
: 0}
|
||||
>
|
||||
<span
|
||||
class="font-semibold {guessedIds.has(
|
||||
book.id,
|
||||
)
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: ''}"
|
||||
>
|
||||
{book.name}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{:else if searchQuery}
|
||||
<p class="mt-4 text-center text-gray-500 dark:text-gray-400 p-8">
|
||||
No books found
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import BlueskyLogo from "$lib/assets/Bluesky_Logo.svg";
|
||||
import TwitterLogo from "$lib/assets/Twitter_Logo.svg";
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center gap-6">
|
||||
<a
|
||||
href="https://bsky.app/profile/snail.city"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Bluesky"
|
||||
data-umami-event="Bluesky clicked"
|
||||
>
|
||||
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div>
|
||||
|
||||
<!-- <a
|
||||
href="https://x.com/pupperpowell"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Follow on Twitter"
|
||||
data-umami-event="Twitter clicked"
|
||||
>
|
||||
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
|
||||
</a>
|
||||
|
||||
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
|
||||
|
||||
<a
|
||||
href="mailto:george@snail.city"
|
||||
class="inline-flex hover:opacity-80 transition-opacity"
|
||||
aria-label="Send email"
|
||||
data-umami-event="Email clicked"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-700 dark:text-gray-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
streak,
|
||||
streakPercentile = null,
|
||||
}: {
|
||||
streak: number;
|
||||
streakPercentile?: number | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center justify-center bg-white/50 dark:bg-black/30 backdrop-blur-sm px-4 py-4 rounded-2xl border border-white/50 dark:border-white/10 shadow-sm flex-1 text-center"
|
||||
>
|
||||
<p
|
||||
class="text-5xl font-triodion font-black text-orange-500 leading-none tabular-nums"
|
||||
>
|
||||
{streak}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs uppercase justify-center tracking-widest text-gray-500 dark:text-gray-200 font-triodion font-bold py-2 leading-tight"
|
||||
>
|
||||
day{streak === 1 ? "" : "s"} in a row
|
||||
</p>
|
||||
{#if streakPercentile !== null && streakPercentile <= 50}
|
||||
<p
|
||||
class="text-xs text-black dark:text-gray-200 w-full tracking-widest uppercase font-semibold border-t border-t-stone-400 dark:border-t-stone-600 pt-2"
|
||||
>
|
||||
Top {streakPercentile}%
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,995 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { fly } from "svelte/transition";
|
||||
import { bibleBooks } from "$lib/types/bible";
|
||||
|
||||
let {
|
||||
isLoggedIn = false,
|
||||
anonymousId = "",
|
||||
localDate = "",
|
||||
}: {
|
||||
isLoggedIn?: boolean;
|
||||
anonymousId?: string;
|
||||
localDate?: string;
|
||||
} = $props();
|
||||
|
||||
// ── Status (logged-in state) ───────────────────────────────────────────
|
||||
type Status = {
|
||||
canSubmit: boolean;
|
||||
cooldownEndsAt: number | null;
|
||||
lastSubmission: { scheduledDate: string; reference: string } | null;
|
||||
upcoming: { scheduledDate: string; reference: string }[];
|
||||
};
|
||||
let status = $state<Status | null>(null);
|
||||
let statusError = $state<string | null>(null);
|
||||
|
||||
// ── Bible structure (Book → chapters → verse counts) ──────────────────
|
||||
type Structure = { bookId: string; chapters: number[] }[];
|
||||
let structure = $state<Structure | null>(null);
|
||||
|
||||
// ── Selector state ─────────────────────────────────────────────────────
|
||||
let selectedBookId = $state<string>("");
|
||||
let selectedChapter = $state<number>(0);
|
||||
let selectedVerse = $state<number>(0);
|
||||
|
||||
// ── Preview state ──────────────────────────────────────────────────────
|
||||
type Preview = {
|
||||
windowVerses: string[];
|
||||
reference: string;
|
||||
bookId: string;
|
||||
selectedVerse: number;
|
||||
};
|
||||
let preview = $state<Preview | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
|
||||
// ── Submission state ───────────────────────────────────────────────────
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<string | null>(null);
|
||||
let submitResult = $state<{ scheduledDate: string; reference: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// ── UI state ───────────────────────────────────────────────────────────
|
||||
let expanded = $state(false);
|
||||
let countdownText = $state("");
|
||||
let countdownId: number | null = null;
|
||||
|
||||
// Book list grouped by Testament for the <select> optgroups.
|
||||
const oldBooks = $derived(bibleBooks.filter((b) => b.testament === "old"));
|
||||
const newBooks = $derived(bibleBooks.filter((b) => b.testament === "new"));
|
||||
|
||||
// Chapter list for the currently selected book (1..N).
|
||||
const chapterCount = $derived(
|
||||
structure && selectedBookId
|
||||
? structure.find((b) => b.bookId === selectedBookId)?.chapters.length ??
|
||||
0
|
||||
: 0,
|
||||
);
|
||||
// Verse count for the currently selected chapter.
|
||||
const verseCount = $derived(
|
||||
structure && selectedBookId && selectedChapter
|
||||
? structure.find((b) => b.bookId === selectedBookId)?.chapters[
|
||||
selectedChapter - 1
|
||||
] ?? 0
|
||||
: 0,
|
||||
);
|
||||
|
||||
// Whether the user is currently on cooldown (only meaningful when logged in).
|
||||
const onCooldown = $derived(
|
||||
!!status && !status.canSubmit && status.cooldownEndsAt !== null,
|
||||
);
|
||||
|
||||
// Debounce handle for the live preview fetch.
|
||||
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function trackEvent(name: string) {
|
||||
try {
|
||||
(window as any).rybbit?.event?.(name);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateLong(dateStr: string): string {
|
||||
// dateStr is YYYY-MM-DD; format as "August 24, 2026" in UTC to avoid drift.
|
||||
const d = new Date(dateStr + "T00:00:00Z");
|
||||
return d.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDateVague(dateStr: string): string {
|
||||
// dateStr is YYYY-MM-DD. Return a deliberately vague timeframe instead of
|
||||
// revealing the exact scheduled date.
|
||||
const scheduled = new Date(dateStr + "T00:00:00Z");
|
||||
const now = new Date();
|
||||
const today = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
|
||||
);
|
||||
const diffDays = Math.round(
|
||||
(scheduled.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (diffDays <= 14) return "a few days from now";
|
||||
if (diffDays <= 60) return "a few weeks from now";
|
||||
return "within the next few months";
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
if (!isLoggedIn || !localDate) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submit-verse/status?localDate=${encodeURIComponent(localDate)}`,
|
||||
);
|
||||
if (res.status === 401) {
|
||||
status = null;
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
statusError = "Couldn't load submission status.";
|
||||
return;
|
||||
}
|
||||
status = await res.json();
|
||||
} catch {
|
||||
statusError = "Couldn't load submission status.";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStructure() {
|
||||
if (structure) return;
|
||||
try {
|
||||
const res = await fetch("/api/bible/structure");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
structure = data;
|
||||
// Default selection: first book, chapter 1, verse 1.
|
||||
if (data.length > 0 && !selectedBookId) {
|
||||
selectedBookId = data[0].bookId;
|
||||
selectedChapter = 1;
|
||||
selectedVerse = 1;
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPreview() {
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
previewTimer = setTimeout(async () => {
|
||||
if (!selectedBookId || !selectedChapter || !selectedVerse) {
|
||||
preview = null;
|
||||
return;
|
||||
}
|
||||
previewLoading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/verse-window?bookId=${encodeURIComponent(
|
||||
selectedBookId,
|
||||
)}&chapter=${selectedChapter}&verse=${selectedVerse}`,
|
||||
);
|
||||
if (res.ok) {
|
||||
preview = await res.json();
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
} finally {
|
||||
previewLoading = false;
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function onBookChange() {
|
||||
selectedChapter = 1;
|
||||
selectedVerse = 1;
|
||||
submitError = null;
|
||||
fetchPreview();
|
||||
}
|
||||
|
||||
function onChapterChange() {
|
||||
selectedVerse = 1;
|
||||
submitError = null;
|
||||
fetchPreview();
|
||||
}
|
||||
|
||||
function onVerseChange() {
|
||||
submitError = null;
|
||||
fetchPreview();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isLoggedIn) return;
|
||||
submitting = true;
|
||||
submitError = null;
|
||||
try {
|
||||
const res = await fetch("/api/submit-verse", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bookId: selectedBookId,
|
||||
chapter: selectedChapter,
|
||||
verse: selectedVerse,
|
||||
localDate,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 429 && data?.cooldownEndsAt) {
|
||||
// Refresh status so the cooldown UI kicks in.
|
||||
await loadStatus();
|
||||
submitError = "You're on cooldown — try again later.";
|
||||
} else {
|
||||
submitError = data?.error ?? "Submission failed.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
submitResult = {
|
||||
scheduledDate: data.scheduledDate,
|
||||
reference: data.reference,
|
||||
};
|
||||
trackEvent("Submit a verse");
|
||||
// Refresh status so the cooldown reflects the new submission.
|
||||
await loadStatus();
|
||||
} catch {
|
||||
submitError = "Network error — please try again.";
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!status?.cooldownEndsAt) {
|
||||
countdownText = "";
|
||||
return;
|
||||
}
|
||||
const diff = status.cooldownEndsAt - Date.now();
|
||||
if (diff <= 0) {
|
||||
countdownText = "";
|
||||
// Cooldown elapsed — refresh status once.
|
||||
loadStatus();
|
||||
if (countdownId) {
|
||||
clearInterval(countdownId);
|
||||
countdownId = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
countdownText =
|
||||
(days > 0 ? `${days}d ` : "") +
|
||||
`${hours.toString().padStart(2, "0")}h ${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isLoggedIn) {
|
||||
loadStatus();
|
||||
loadStructure();
|
||||
}
|
||||
});
|
||||
|
||||
// Start/stop the countdown timer as cooldown state changes.
|
||||
$effect(() => {
|
||||
if (onCooldown && status?.cooldownEndsAt && !countdownId) {
|
||||
updateCountdown();
|
||||
countdownId = window.setInterval(updateCountdown, 1000);
|
||||
} else if (!onCooldown && countdownId) {
|
||||
clearInterval(countdownId);
|
||||
countdownId = null;
|
||||
countdownText = "";
|
||||
}
|
||||
});
|
||||
|
||||
// (Re)load status + structure when the user logs in.
|
||||
$effect(() => {
|
||||
if (isLoggedIn) {
|
||||
loadStatus();
|
||||
loadStructure();
|
||||
} else {
|
||||
status = null;
|
||||
structure = null;
|
||||
expanded = false;
|
||||
submitResult = null;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (countdownId) clearInterval(countdownId);
|
||||
if (previewTimer) clearTimeout(previewTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="signin-prompt submit-verse-wrap">
|
||||
{#if submitResult}
|
||||
<!-- ── Confirmation state (replaces the panel after success) ──────── -->
|
||||
<div class="confirm-card" in:fly={{ y: -8, duration: 220 }}>
|
||||
<p class="confirm-title">
|
||||
✅ Your verse is scheduled for
|
||||
<strong>{fmtDateVague(submitResult.scheduledDate)}</strong>
|
||||
</p>
|
||||
<p class="confirm-ref">{submitResult.reference}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="submit-another-btn"
|
||||
onclick={() => {
|
||||
submitResult = null;
|
||||
expanded = false;
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
{:else if !isLoggedIn}
|
||||
<!-- ── State 1: logged out — sign-in dropdown ─────────────────────── -->
|
||||
<button
|
||||
type="button"
|
||||
class="progress-btn w-full"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
data-umami-event="Submit a verse (logged out)"
|
||||
>
|
||||
<span>✨ Submit a verse</span>
|
||||
<svg
|
||||
class="progress-chev"
|
||||
class:open={expanded}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||
Sign in to submit a verse for a future day
|
||||
</p>
|
||||
<form method="POST" action="/auth/apple" class="w-full">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="apple-signin-btn"
|
||||
data-umami-event="Sign in with Apple"
|
||||
>
|
||||
<svg class="apple-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/auth/google" class="w-full">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="google-signin-btn"
|
||||
data-umami-event="Sign in with Google"
|
||||
>
|
||||
<svg
|
||||
class="google-icon"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{:else if onCooldown}
|
||||
<!-- ── State 2: logged in, cooldown active — disabled + countdown on button ─── -->
|
||||
<button
|
||||
type="button"
|
||||
class="progress-btn w-full progress-btn-disabled"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
>
|
||||
<!-- <span>✨ Submit a verse</span> -->
|
||||
{#if countdownText}
|
||||
<span class="countdown-inline" aria-hidden="true"
|
||||
>⏳ {countdownText}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="lock-icon" aria-hidden="true">⏳</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- ── State 3: logged in, can submit — selector + preview ────────── -->
|
||||
<button
|
||||
type="button"
|
||||
class="progress-btn w-full"
|
||||
aria-expanded={expanded}
|
||||
onclick={() => (expanded = !expanded)}
|
||||
data-umami-event="Submit a verse (logged in)"
|
||||
>
|
||||
<span>✨ Submit a verse</span>
|
||||
<svg
|
||||
class="progress-chev"
|
||||
class:open={expanded}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<div class="submit-panel" in:fly={{ y: -8, duration: 220 }}>
|
||||
{#if statusError}
|
||||
<p class="panel-error">{statusError}</p>
|
||||
{/if}
|
||||
|
||||
{#if !structure}
|
||||
<p class="panel-hint">Loading Bible structure…</p>
|
||||
{:else}
|
||||
<div class="select-row">
|
||||
<label class="select-label">
|
||||
<span class="select-cap">Book</span>
|
||||
<select
|
||||
class="cascading-select"
|
||||
value={selectedBookId}
|
||||
onchange={(e) => {
|
||||
selectedBookId = e.currentTarget.value;
|
||||
onBookChange();
|
||||
}}
|
||||
>
|
||||
<optgroup label="Old Testament">
|
||||
{#each oldBooks as book (book.id)}
|
||||
<option value={book.id}>{book.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
<optgroup label="New Testament">
|
||||
{#each newBooks as book (book.id)}
|
||||
<option value={book.id}>{book.name}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="select-label">
|
||||
<span class="select-cap">Chapter</span>
|
||||
<select
|
||||
class="cascading-select"
|
||||
value={selectedChapter}
|
||||
onchange={(e) => {
|
||||
selectedChapter = Number(e.currentTarget.value);
|
||||
onChapterChange();
|
||||
}}
|
||||
>
|
||||
{#if chapterCount === 0}
|
||||
<option value={0}>—</option>
|
||||
{:else}
|
||||
{#each Array(chapterCount) as _, i (i)}
|
||||
<option value={i + 1}>{i + 1}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="select-label">
|
||||
<span class="select-cap">Verse</span>
|
||||
<select
|
||||
class="cascading-select"
|
||||
value={selectedVerse}
|
||||
onchange={(e) => {
|
||||
selectedVerse = Number(e.currentTarget.value);
|
||||
onVerseChange();
|
||||
}}
|
||||
>
|
||||
{#if verseCount === 0}
|
||||
<option value={0}>—</option>
|
||||
{:else}
|
||||
{#each Array(verseCount) as _, i (i)}
|
||||
<option value={i + 1}>{i + 1}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Live 3-verse preview -->
|
||||
<div class="preview-card">
|
||||
{#if previewLoading}
|
||||
<p class="preview-hint">Loading preview…</p>
|
||||
{:else if preview}
|
||||
<p class="preview-ref">{preview.reference}</p>
|
||||
<div class="preview-verses">
|
||||
{#each preview.windowVerses as verseText, i (i)}
|
||||
<p class="preview-verse">{verseText}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="preview-hint">Select a verse to preview.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if submitError}
|
||||
<p class="panel-error">{submitError}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="submit-btn"
|
||||
disabled={submitting ||
|
||||
!selectedBookId ||
|
||||
!selectedChapter ||
|
||||
!selectedVerse ||
|
||||
!preview}
|
||||
onclick={handleSubmit}
|
||||
>
|
||||
{submitting ? "Scheduling…" : "Submit verse"}
|
||||
</button>
|
||||
{#if status?.upcoming && status.upcoming.length > 0}
|
||||
<p class="upcoming-text">
|
||||
Your upcoming verse{status.upcoming.length > 1
|
||||
? "s"
|
||||
: ""}:
|
||||
{#each status.upcoming as u, i (i)}
|
||||
{#if i > 0}<span class="upcoming-sep">·</span>{/if}
|
||||
<span class="upcoming-item"
|
||||
>{u.reference} ({fmtDateVague(u.scheduledDate)})</span
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.submit-verse-wrap {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Reuse WinScreen's neobrutalist .progress-btn base (scoped here too). */
|
||||
:global(.submit-verse-wrap .progress-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 6px 6px 0 0 #000;
|
||||
transition:
|
||||
transform 80ms ease,
|
||||
box-shadow 80ms ease;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-btn:hover) {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 8px 8px 0 0 #000;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-btn:active) {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 0 #000;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-chev) {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-chev.open) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(.submit-verse-wrap .progress-btn) {
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
border-color: #fff;
|
||||
box-shadow: 6px 6px 0 0 #fff;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-btn:hover) {
|
||||
box-shadow: 8px 8px 0 0 #fff;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-btn:active) {
|
||||
box-shadow: 2px 2px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disabled (cooldown) variant. */
|
||||
:global(.submit-verse-wrap .progress-btn-disabled) {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
box-shadow: 4px 4px 0 0 #000;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-btn-disabled:hover),
|
||||
:global(.submit-verse-wrap .progress-btn-disabled:active) {
|
||||
transform: none;
|
||||
box-shadow: 4px 4px 0 0 #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(.submit-verse-wrap .progress-btn-disabled) {
|
||||
box-shadow: 4px 4px 0 0 #fff;
|
||||
}
|
||||
:global(.submit-verse-wrap .progress-btn-disabled:hover),
|
||||
:global(.submit-verse-wrap .progress-btn-disabled:active) {
|
||||
box-shadow: 4px 4px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.countdown-inline {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.01em;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.signin-text {
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Apple / Google sign-in buttons (mirror WinScreen) ── */
|
||||
:global(.submit-verse-wrap .apple-signin-btn),
|
||||
:global(.submit-verse-wrap .google-signin-btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
:global(.submit-verse-wrap .apple-signin-btn) {
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
:global(.submit-verse-wrap .apple-signin-btn:hover),
|
||||
:global(.submit-verse-wrap .google-signin-btn:hover) {
|
||||
background: #222;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
:global(.submit-verse-wrap .apple-signin-btn:active),
|
||||
:global(.submit-verse-wrap .google-signin-btn:active) {
|
||||
background: #111;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(.submit-verse-wrap .apple-signin-btn),
|
||||
:global(.submit-verse-wrap .google-signin-btn) {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
:global(.submit-verse-wrap .apple-signin-btn:hover),
|
||||
:global(.submit-verse-wrap .google-signin-btn:hover) {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
:global(.submit-verse-wrap .apple-signin-btn:active),
|
||||
:global(.submit-verse-wrap .google-signin-btn:active) {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
:global(.submit-verse-wrap .apple-icon),
|
||||
:global(.submit-verse-wrap .google-icon) {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Submit panel ── */
|
||||
.submit-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: oklch(94% 0.028 298.626);
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 6px 6px 0 0 #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.submit-panel {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: #fff;
|
||||
box-shadow: 6px 6px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-hint,
|
||||
.preview-hint {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.panel-hint,
|
||||
.preview-hint {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
font-size: 0.85rem;
|
||||
color: #b91c1c;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.panel-error {
|
||||
color: #f87171;
|
||||
}
|
||||
}
|
||||
|
||||
.select-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.select-cap {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6b7280;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.select-cap {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
.cascading-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.5rem;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cascading-select {
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
border-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Preview card ── */
|
||||
.preview-card {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.preview-card {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-ref {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.preview-ref {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-verses {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.preview-verse {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: #4b5563;
|
||||
font-style: italic;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.preview-verse {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Submit button (neobrutalist) ── */
|
||||
.submit-btn {
|
||||
padding: 0.7rem 1rem;
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 4px 4px 0 0 #000;
|
||||
transition:
|
||||
transform 80ms ease,
|
||||
box-shadow 80ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 6px 6px 0 0 #000;
|
||||
background: #15803d;
|
||||
}
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 0 #000;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.submit-btn {
|
||||
border-color: #fff;
|
||||
box-shadow: 4px 4px 0 0 #fff;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
box-shadow: 6px 6px 0 0 #fff;
|
||||
}
|
||||
.submit-btn:active:not(:disabled) {
|
||||
box-shadow: 2px 2px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Upcoming list ── */
|
||||
.upcoming-text {
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.upcoming-text {
|
||||
color: #9ca3af;
|
||||
}
|
||||
}
|
||||
.upcoming-item {
|
||||
font-weight: 600;
|
||||
}
|
||||
.upcoming-sep {
|
||||
margin: 0 0.25rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ── Confirmation card ── */
|
||||
.confirm-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 1.1rem;
|
||||
background: oklch(94% 0.028 298.626);
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 6px 6px 0 0 #000;
|
||||
text-align: center;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.confirm-card {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: #fff;
|
||||
box-shadow: 6px 6px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.confirm-title {
|
||||
color: #f9fafb;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-ref {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.confirm-ref {
|
||||
color: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-another-btn {
|
||||
padding: 0.5rem 1.1rem;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 3px 3px 0 0 #000;
|
||||
transition:
|
||||
transform 80ms ease,
|
||||
box-shadow 80ms ease;
|
||||
}
|
||||
.submit-another-btn:hover {
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: 4px 4px 0 0 #000;
|
||||
}
|
||||
.submit-another-btn:active {
|
||||
transform: translate(1px, 1px);
|
||||
box-shadow: 2px 2px 0 0 #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.submit-another-btn {
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
border-color: #fff;
|
||||
box-shadow: 3px 3px 0 0 #fff;
|
||||
}
|
||||
.submit-another-btn:hover {
|
||||
box-shadow: 4px 4px 0 0 #fff;
|
||||
}
|
||||
.submit-another-btn:active {
|
||||
box-shadow: 2px 2px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let isDarkMode = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
const stored = localStorage.getItem('bibdle-theme');
|
||||
if (stored === 'dark') {
|
||||
isDarkMode = true;
|
||||
} else if (stored === 'light') {
|
||||
isDarkMode = false;
|
||||
} else {
|
||||
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
document.documentElement.classList.remove('light');
|
||||
localStorage.setItem('bibdle-theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
document.documentElement.classList.add('light');
|
||||
localStorage.setItem('bibdle-theme', 'light');
|
||||
}
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
isDarkMode = !isDarkMode;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if browser}
|
||||
<button
|
||||
onclick={toggleTheme}
|
||||
aria-label={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
class="flex items-center gap-2 p-1 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100 transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{#if isDarkMode}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4"/>
|
||||
<path d="M12 2v2"/>
|
||||
<path d="M12 20v2"/>
|
||||
<path d="m4.93 4.93 1.41 1.41"/>
|
||||
<path d="m17.66 17.66 1.41 1.41"/>
|
||||
<path d="M2 12h2"/>
|
||||
<path d="M20 12h2"/>
|
||||
<path d="m6.34 17.66-1.41 1.41"/>
|
||||
<path d="m19.07 4.93-1.41 1.41"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-xs uppercase tracking-widest">
|
||||
{isDarkMode ? 'Light mode' : 'Dark mode'}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,38 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
|
||||
import Container from "./Container.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { fade } from "svelte/transition";
|
||||
import Container from "./Container.svelte";
|
||||
|
||||
let {
|
||||
data,
|
||||
isWon,
|
||||
blurChapter = false,
|
||||
}: { data: PageData; 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) => c.toUpperCase())
|
||||
.replace(/[,:;-—]$/, "...")
|
||||
);
|
||||
interface VerseDisplayData {
|
||||
dailyVerse: {
|
||||
date: string;
|
||||
reference: string;
|
||||
verseText: string;
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<Container class="w-full p-8 sm:p-12 bg-white/70">
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
{#if isWon}
|
||||
<p
|
||||
class="text-center text-lg! big-text text-green-600! font-bold mt-8 bg-white/70 rounded-xl px-4 py-2"
|
||||
>
|
||||
{displayReference}
|
||||
</p>
|
||||
{/if}
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-white/70 dark:bg-black/30 overflow-hidden"
|
||||
>
|
||||
<blockquote
|
||||
class="text-xl sm:text-2xl font-triodion leading-relaxed text-gray-700 dark:text-gray-200 text-center"
|
||||
>
|
||||
{displayVerseText}
|
||||
</blockquote>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out overflow-hidden"
|
||||
style="max-height: {showReference ? '200px' : '0px'};"
|
||||
>
|
||||
{#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>
|
||||
@@ -1,14 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { fade } from "svelte/transition";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { getBookById, toOrdinal } from "$lib/utils/game";
|
||||
import {
|
||||
getBookById,
|
||||
toOrdinal,
|
||||
getNextGradeMessage,
|
||||
} from "$lib/utils/game";
|
||||
import { onMount } from "svelte";
|
||||
getVerseSnippet,
|
||||
shareResult,
|
||||
copyToClipboard as clipboardCopy,
|
||||
} from "$lib/utils/share";
|
||||
import Container from "./Container.svelte";
|
||||
import CountdownTimer from "./CountdownTimer.svelte";
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
import SubmitVerse from "./SubmitVerse.svelte";
|
||||
import { track } from "$lib/utils/analytics";
|
||||
|
||||
interface StatsData {
|
||||
solveRank: number;
|
||||
@@ -25,7 +28,6 @@
|
||||
}
|
||||
|
||||
let {
|
||||
grade,
|
||||
statsData,
|
||||
correctBookId,
|
||||
handleShare,
|
||||
@@ -35,6 +37,30 @@
|
||||
guessCount,
|
||||
reference,
|
||||
onChapterGuessCompleted,
|
||||
shareText,
|
||||
verseText,
|
||||
streak = 0,
|
||||
streakPercentile = null,
|
||||
isLoggedIn = false,
|
||||
anonymousId = "",
|
||||
localDate = "",
|
||||
}: {
|
||||
statsData: StatsData | null;
|
||||
correctBookId: string;
|
||||
handleShare: () => void;
|
||||
copyToClipboard: () => void;
|
||||
copied: boolean;
|
||||
statsSubmitted: boolean;
|
||||
guessCount: number;
|
||||
reference: string;
|
||||
onChapterGuessCompleted: () => void;
|
||||
shareText: string;
|
||||
verseText: string;
|
||||
streak?: number;
|
||||
streakPercentile?: number | null;
|
||||
isLoggedIn?: boolean;
|
||||
anonymousId?: string;
|
||||
localDate?: string;
|
||||
} = $props();
|
||||
|
||||
let bookName = $derived(getBookById(correctBookId)?.name ?? "");
|
||||
@@ -42,6 +68,25 @@
|
||||
typeof navigator !== "undefined" && "share" in navigator,
|
||||
);
|
||||
let copySuccess = $state(false);
|
||||
let bubbleCopied = $state(false);
|
||||
let copyTracked = $state(false);
|
||||
let showSnippetOption = $state(false);
|
||||
let includeSnippet = $state(false);
|
||||
let showProgressDropdown = $state(false);
|
||||
|
||||
let effectiveShareText = $derived(
|
||||
includeSnippet
|
||||
? (() => {
|
||||
const snippet = getVerseSnippet(verseText);
|
||||
const lines = shareText.split("\n");
|
||||
return [
|
||||
...lines.slice(0, -1),
|
||||
snippet,
|
||||
lines[lines.length - 1],
|
||||
].join("\n");
|
||||
})()
|
||||
: shareText,
|
||||
);
|
||||
|
||||
// List of congratulations messages with weights
|
||||
const congratulationsMessages: WeightedMessage[] = [
|
||||
@@ -57,9 +102,9 @@
|
||||
if (guessCount === 1) {
|
||||
const n = Math.random();
|
||||
if (n < 0.99) {
|
||||
return "🌟 First try! 🌟";
|
||||
return "First try!";
|
||||
} else {
|
||||
return "🗣️ Axios! 🗣️";
|
||||
return "Axios!";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,66 +131,27 @@
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Container
|
||||
class="w-full p-8 sm:p-12 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 shadow-2xl text-center fade-in"
|
||||
class="w-full px-4 sm:px-6 py-6 sm:py-8 bg-linear-to-r from-green-400/10 to-green-600/30 text-gray-800 dark:text-gray-100 shadow-2xl text-center fade-in"
|
||||
>
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-relaxed">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black text-3xl md:text-4xl">{bookName}</span>.
|
||||
</p>
|
||||
<p class="text-lg sm:text-xl md:text-2xl mt-4">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
<span class="font-bold bg-white/40 rounded px-1.5 py-0.75"
|
||||
>{grade}</span
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center mt-6">
|
||||
{#if hasWebShare}
|
||||
<!-- mobile and arc in production -->
|
||||
<button
|
||||
onclick={handleShare}
|
||||
data-umami-event="Share"
|
||||
class="text-2xl font-bold p-4 bg-white/70 hover:bg-white/80 rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none"
|
||||
>
|
||||
📤 Share
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
copyToClipboard();
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}}
|
||||
data-umami-event="Copy to Clipboard"
|
||||
class={`text-2xl font-bold p-4 rounded-lg inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none ${
|
||||
copySuccess
|
||||
? "bg-white/30"
|
||||
: "bg-white/70 hover:bg-white/80"
|
||||
}`}
|
||||
>
|
||||
{copySuccess ? "✅ Copied!" : "📋 Copy"}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- dev mode and desktop browsers -->
|
||||
<button
|
||||
onclick={handleShare}
|
||||
data-umami-event="Copy to Clipboard"
|
||||
class={`text-2xl font-bold p-4 ${
|
||||
copied ? "bg-white/30" : "bg-white/70 hover:bg-white/80"
|
||||
} rounded-xl inline-block transition-all shadow-lg mx-2 cursor-pointer border-none appearance-none`}
|
||||
>
|
||||
{copied ? "✅ Copied!" : "📋 Share"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if guessCount !== 1}
|
||||
<p class="pt-6 big-text text-gray-700!">
|
||||
{getNextGradeMessage(guessCount)}
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-2xl sm:text-3xl md:text-4xl leading-tight">
|
||||
{congratulationsMessage} The verse is from
|
||||
<span class="font-black font-triodion text-3xl md:text-4xl"
|
||||
>{bookName}</span
|
||||
>.
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-lg sm:text-xl md:text-2xl">
|
||||
You guessed correctly after {guessCount}
|
||||
{guessCount === 1 ? "guess" : "guesses"}.
|
||||
</p>
|
||||
<!-- {#if streak >= 7}
|
||||
<p
|
||||
class="italic tracking-wider px-8 font-semibold text-gray-500"
|
||||
>
|
||||
Thank you for making BIBDLE part of your daily routine!
|
||||
</p>
|
||||
{/if} -->
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
<!-- S++ Bonus Challenge for first try -->
|
||||
@@ -157,12 +163,21 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<CountdownTimer />
|
||||
<div class="flex flex-row gap-3 items-stretch w-full">
|
||||
<div class="flex-2 min-w-0 flex flex-col">
|
||||
<CountdownTimer />
|
||||
</div>
|
||||
{#if streak > 0}
|
||||
<div class="flex-1 min-w-0 flex flex-col">
|
||||
<StreakCounter {streak} {streakPercentile} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Statistics Display -->
|
||||
{#if statsData}
|
||||
<Container
|
||||
class="w-full p-4 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||
class="w-full p-4 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-3 gap-4 gap-x-8 text-center"
|
||||
@@ -171,7 +186,7 @@
|
||||
<!-- Solve Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
#{statsData.solveRank}
|
||||
</div>
|
||||
@@ -184,7 +199,7 @@
|
||||
<!-- Guess Rank Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{toOrdinal(statsData.guessRank)}
|
||||
</div>
|
||||
@@ -206,7 +221,7 @@
|
||||
<!-- Average Column -->
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 pb-2"
|
||||
class="text-3xl sm:text-4xl font-black border-b border-gray-300 dark:border-gray-600 pb-2"
|
||||
>
|
||||
{statsData.averageGuesses}
|
||||
</div>
|
||||
@@ -220,11 +235,241 @@
|
||||
</Container>
|
||||
{:else if !statsSubmitted}
|
||||
<Container
|
||||
class="w-full p-6 bg-white/50 backdrop-blur-sm text-gray-800 shadow-lg text-center"
|
||||
class="w-full p-6 bg-white/50 dark:bg-black/30 backdrop-blur-sm text-gray-800 dark:text-gray-100 shadow-lg text-center"
|
||||
>
|
||||
<div class="text-sm opacity-80">Submitting stats...</div>
|
||||
</Container>
|
||||
{/if}
|
||||
|
||||
<div class="share-card" in:fly={{ y: 40, duration: 400, delay: 600 }}>
|
||||
<div class="big-text font-black! text-center text-gray-300!">
|
||||
Share your result
|
||||
</div>
|
||||
<div class="chat-window">
|
||||
<!-- Received bubble: primary action (share / copy) -->
|
||||
<div class="bubble-wrapper received-wrapper">
|
||||
<button
|
||||
class="bubble bubble-received"
|
||||
class:success={copySuccess}
|
||||
aria-label={hasWebShare ? "Share" : "Copy to clipboard"}
|
||||
data-umami-event={hasWebShare
|
||||
? "Share"
|
||||
: "Copy to Clipboard"}
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
track("share", { snippet: includeSnippet });
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
if (!copyTracked) {
|
||||
copyTracked = true;
|
||||
}
|
||||
track("share_copy", { snippet: includeSnippet });
|
||||
clipboardCopy(effectiveShareText);
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
copySuccess = false;
|
||||
}, 3000);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if hasWebShare}
|
||||
📤 Tap here to share
|
||||
{:else if copySuccess}
|
||||
✅ Copied!
|
||||
{:else}
|
||||
📋 Copy to clipboard
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sent bubble: share text preview -->
|
||||
<div class="bubble-wrapper">
|
||||
<button
|
||||
class="bubble bubble-sent"
|
||||
aria-label="Copy to clipboard"
|
||||
data-umami-event="Copy to Clipboard"
|
||||
onclick={() => {
|
||||
if (!copyTracked) {
|
||||
copyTracked = true;
|
||||
}
|
||||
track("share_copy", { snippet: includeSnippet });
|
||||
clipboardCopy(effectiveShareText);
|
||||
showSnippetOption = true;
|
||||
bubbleCopied = true;
|
||||
setTimeout(() => {
|
||||
bubbleCopied = false;
|
||||
}, 2000);
|
||||
}}>{effectiveShareText}</button
|
||||
>
|
||||
{#if hasWebShare}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : "(tap to copy)"}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="copy-hint"
|
||||
>{bubbleCopied ? "copied!" : ""}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if streak >= 7}
|
||||
<div class="big-text tracking-widest! font-black! text-center mt-4">
|
||||
Thank you for making Bibdle part of your daily routine! —George
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showSnippetOption}
|
||||
<div class="snippet-toggle-row mr-4" in:fly={{ y: -8, duration: 220 }}>
|
||||
<span class="snippet-label">Show verse snippet in share?</span>
|
||||
<button
|
||||
class="snippet-toggle"
|
||||
class:on={includeSnippet}
|
||||
onclick={() => (includeSnippet = !includeSnippet)}
|
||||
aria-pressed={includeSnippet}
|
||||
aria-label="Show snippet in share"
|
||||
>
|
||||
<span class="toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="signin-prompt">
|
||||
<a
|
||||
href="/progress"
|
||||
class="progress-btn w-full"
|
||||
data-umami-event="See your progress (logged in)"
|
||||
>
|
||||
<span>📈 See your progress</span>
|
||||
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="signin-prompt">
|
||||
<button
|
||||
type="button"
|
||||
class="progress-btn w-full"
|
||||
aria-expanded={showProgressDropdown}
|
||||
onclick={() => (showProgressDropdown = !showProgressDropdown)}
|
||||
data-umami-event="See your progress (logged out)"
|
||||
>
|
||||
<span>📈 See your progress</span>
|
||||
<svg class="progress-chev" class:open={showProgressDropdown} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if showProgressDropdown}
|
||||
<p class="signin-text text-gray-800 dark:text-gray-300">
|
||||
Create an account (or sign in) to track your progress
|
||||
</p>
|
||||
<form method="POST" action="/auth/apple" class="w-full">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="apple-signin-btn"
|
||||
data-umami-event="Sign in with Apple"
|
||||
>
|
||||
<svg
|
||||
class="apple-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Apple
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/auth/google" class="w-full">
|
||||
<input type="hidden" name="anonymousId" value={anonymousId} />
|
||||
<button
|
||||
type="submit"
|
||||
class="google-signin-btn"
|
||||
data-umami-event="Sign in with Google"
|
||||
>
|
||||
<svg
|
||||
class="google-icon"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SubmitVerse
|
||||
{isLoggedIn}
|
||||
{anonymousId}
|
||||
{localDate}
|
||||
/>
|
||||
|
||||
<div class="signin-prompt">
|
||||
<a
|
||||
href="https://discord.gg/yWQXbGK8SD"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="discord-btn w-full"
|
||||
data-umami-event="Join the BIBDLE Discord"
|
||||
onclick={() => track("discord_click")}
|
||||
>
|
||||
<svg
|
||||
class="discord-icon"
|
||||
viewBox="0 0 127.14 96.36"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Join the BIBDLE Discord!</span>
|
||||
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="signin-prompt">
|
||||
<a
|
||||
href="https://donate.stripe.com/9B6aEZ9WgeZF3qYfF987K00"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="progress-btn w-full"
|
||||
data-umami-event="Support bibdle's development"
|
||||
onclick={() => track("donate_click")}
|
||||
>
|
||||
<span>❤️ Support bibdle's development</span>
|
||||
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</a>
|
||||
<div class="big-text font-black! text-center text-gray-300! mt-2">
|
||||
BIBDLE will be ad-free forever.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -239,7 +484,489 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
:global(.fade-in) {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* ── Share card ── */
|
||||
.share-card {
|
||||
background: oklch(94% 0.028 298.626);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 1.25rem;
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.share-card {
|
||||
background: oklch(22% 0.025 298.626);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.share-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E");
|
||||
opacity: 0.04;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Chat window ── */
|
||||
.chat-window {
|
||||
--sent-color: #0b93f6;
|
||||
--received-color: #3a3a3c;
|
||||
--bg: oklch(94% 0.028 298.626);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.5rem 0;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-window {
|
||||
--bg: oklch(22% 0.025 298.626);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bubble wrappers ── */
|
||||
.bubble-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.received-wrapper {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Shared bubble base ── */
|
||||
.bubble {
|
||||
position: relative;
|
||||
max-width: 255px;
|
||||
margin-bottom: 0;
|
||||
padding: 10px 20px;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word;
|
||||
border-radius: 25px;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
filter 80ms ease,
|
||||
transform 80ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Sent bubble (share text preview) ── */
|
||||
.bubble-sent {
|
||||
color: white;
|
||||
background: var(--sent-color);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.bubble-sent:hover {
|
||||
background-color: #2ea8ff;
|
||||
transform: rotate(-2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-sent:hover::before {
|
||||
background-color: #2ea8ff;
|
||||
}
|
||||
|
||||
.bubble-sent:active {
|
||||
background-color: #0878d4;
|
||||
transform: rotate(-2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-sent:active::before {
|
||||
background-color: #0878d4;
|
||||
}
|
||||
|
||||
/* Sent tail: bottom-right */
|
||||
.bubble-sent::before,
|
||||
.bubble-sent::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-sent::before {
|
||||
width: 20px;
|
||||
right: -7px;
|
||||
background-color: var(--sent-color);
|
||||
border-bottom-left-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-sent::after {
|
||||
width: 26px;
|
||||
right: -26px;
|
||||
border-bottom-left-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Received bubble (action button) ── */
|
||||
.bubble-received {
|
||||
color: #f5f5f7;
|
||||
background: var(--received-color);
|
||||
transform: rotate(2deg);
|
||||
padding: 14px 24px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
min-width: 14rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bubble-received:hover {
|
||||
background-color: #4a4a4e;
|
||||
transform: rotate(2deg) translateY(-2px);
|
||||
}
|
||||
|
||||
.bubble-received:hover::before {
|
||||
background-color: #4a4a4e;
|
||||
}
|
||||
|
||||
.bubble-received:active {
|
||||
background-color: #2a2a2c;
|
||||
transform: rotate(2deg) scale(0.97);
|
||||
}
|
||||
|
||||
.bubble-received:active::before {
|
||||
background-color: #2a2a2c;
|
||||
}
|
||||
|
||||
.bubble-received.success {
|
||||
background: #c7f7d4;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
/* Received tail: bottom-left (mirror of sent) */
|
||||
.bubble-received::before,
|
||||
.bubble-received::after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 25px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.bubble-received::before {
|
||||
width: 20px;
|
||||
left: -7px;
|
||||
background-color: var(--received-color);
|
||||
border-bottom-right-radius: 16px 14px;
|
||||
}
|
||||
|
||||
.bubble-received::after {
|
||||
width: 26px;
|
||||
left: -26px;
|
||||
border-bottom-right-radius: 10px;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.bubble-received.success::before {
|
||||
background-color: #c7f7d4;
|
||||
}
|
||||
|
||||
/* ── Copy hints ── */
|
||||
.copy-hint {
|
||||
font-size: 0.68rem;
|
||||
color: #444;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
padding-right: 32px;
|
||||
transform: rotate(-2deg);
|
||||
transform-origin: right center;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.copy-hint {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Snippet toggle row ── */
|
||||
.snippet-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.snippet-label {
|
||||
font-size: 0.72rem;
|
||||
color: #666;
|
||||
letter-spacing: 0.01em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.snippet-label {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.snippet-toggle {
|
||||
position: relative;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: #ccc;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 200ms ease;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snippet-toggle.on {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.snippet-toggle.on .toggle-thumb {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* ── See your progress button (neobrutalist) ── */
|
||||
.progress-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
background: #fff;
|
||||
color: #111;
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 6px 6px 0 0 #000;
|
||||
transition:
|
||||
transform 80ms ease,
|
||||
box-shadow 80ms ease;
|
||||
}
|
||||
|
||||
.progress-btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 8px 8px 0 0 #000;
|
||||
}
|
||||
|
||||
.progress-btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 0 #000;
|
||||
}
|
||||
|
||||
.progress-chev {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
transition: transform 200ms ease;
|
||||
}
|
||||
|
||||
.progress-chev.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.progress-btn {
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
border-color: #fff;
|
||||
box-shadow: 6px 6px 0 0 #fff;
|
||||
}
|
||||
.progress-btn:hover {
|
||||
box-shadow: 8px 8px 0 0 #fff;
|
||||
}
|
||||
.progress-btn:active {
|
||||
box-shadow: 2px 2px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.signin-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
/*padding: 1rem 0 0.25rem;*/
|
||||
}
|
||||
|
||||
/* ── Discord button (neobrutalist, discord purple) ── */
|
||||
.discord-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
background: #5865f2;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
border-radius: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 6px 6px 0 0 #000;
|
||||
transition:
|
||||
transform 80ms ease,
|
||||
box-shadow 80ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.discord-btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: 8px 8px 0 0 #000;
|
||||
background: #4752c4;
|
||||
}
|
||||
|
||||
.discord-btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 0 #000;
|
||||
background: #3c45a5;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.discord-btn {
|
||||
border-color: #fff;
|
||||
box-shadow: 6px 6px 0 0 #fff;
|
||||
}
|
||||
.discord-btn:hover {
|
||||
box-shadow: 8px 8px 0 0 #fff;
|
||||
}
|
||||
.discord-btn:active {
|
||||
box-shadow: 2px 2px 0 0 #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.discord-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.signin-text {
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.apple-signin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
margin-bottom: 0.6rem;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
|
||||
.apple-signin-btn:hover {
|
||||
background: #222;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.apple-signin-btn:active {
|
||||
background: #111;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.apple-signin-btn {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.apple-signin-btn:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
.apple-signin-btn:active {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.google-signin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
width: 100%;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
transform 80ms ease;
|
||||
}
|
||||
|
||||
.google-signin-btn:hover {
|
||||
background: #222;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.google-signin-btn:active {
|
||||
background: #111;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.google-signin-btn {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.google-signin-btn:hover {
|
||||
background: #e5e5e5;
|
||||
}
|
||||
.google-signin-btn:active {
|
||||
background: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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.
|
||||
|
||||
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 { sha256 } from '@oslojs/crypto/sha2';
|
||||
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as table from '$lib/server/db/schema';
|
||||
|
||||
@@ -11,12 +9,11 @@ export const sessionCookieName = 'auth-session';
|
||||
|
||||
export function generateSessionToken() {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(18));
|
||||
const token = encodeBase64url(bytes);
|
||||
return token;
|
||||
return Buffer.from(bytes).toString('base64url');
|
||||
}
|
||||
|
||||
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 = {
|
||||
id: sessionId,
|
||||
userId,
|
||||
@@ -27,11 +24,11 @@ export async function createSession(token: string, userId: 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
|
||||
.select({
|
||||
// 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
|
||||
})
|
||||
.from(table.session)
|
||||
@@ -67,15 +64,102 @@ export async function invalidateSession(sessionId: string) {
|
||||
await db.delete(table.session).where(eq(table.session.id, sessionId));
|
||||
}
|
||||
|
||||
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) {
|
||||
event.cookies.set(sessionCookieName, token, {
|
||||
export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
|
||||
cookies.set(sessionCookieName, token, {
|
||||
expires: expiresAt,
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSessionTokenCookie(event: RequestEvent) {
|
||||
event.cookies.delete(sessionCookieName, {
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { Database } from 'bun:sqlite';
|
||||
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 });
|
||||
@@ -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';
|
||||
|
||||
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', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -30,11 +41,83 @@ export const dailyCompletions = sqliteTable('daily_completions', {
|
||||
anonymousId: text('anonymous_id').notNull(),
|
||||
date: text('date').notNull(),
|
||||
guessCount: integer('guess_count').notNull(),
|
||||
guesses: text('guesses'), // nullable; only stored for logged-in users
|
||||
completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
uniqueCompletion: unique().on(table.anonymousId, table.date),
|
||||
dateIndex: index('date_idx').on(table.date),
|
||||
dateGuessIndex: index('date_guess_idx').on(table.date, table.guessCount),
|
||||
}));
|
||||
}, (table) => [
|
||||
index('anonymous_id_date_idx').on(table.anonymousId, table.date),
|
||||
index('date_idx').on(table.date),
|
||||
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 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;
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { db as defaultDb } from '$lib/server/db';
|
||||
import {
|
||||
analyticsEvents,
|
||||
dailyCompletions,
|
||||
dailyVerses,
|
||||
statsSnapshot,
|
||||
user
|
||||
} from '$lib/server/db/schema';
|
||||
import type { StatsDb } from './db';
|
||||
import type {
|
||||
Activity,
|
||||
CompletionRow,
|
||||
DailyPoint,
|
||||
HabitData,
|
||||
HistogramBin,
|
||||
Kpis,
|
||||
PlayerState,
|
||||
SeriesPoint,
|
||||
SnapshotPayload,
|
||||
StatsSettings,
|
||||
VerseRow
|
||||
} from '$lib/types/stats';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
import { getSettings, hashSettings } from './settings';
|
||||
import { deriveSegments, countSegments } from './segments';
|
||||
import { evaluateSignals } from './signals';
|
||||
import {
|
||||
activePlayers,
|
||||
averageGuesses,
|
||||
bookCoverage,
|
||||
cohortTriangle,
|
||||
dailyCompletionsSeries,
|
||||
entropy,
|
||||
firstPlayDates,
|
||||
firstTryRate,
|
||||
groupDatesByPlayer,
|
||||
guessHistogram,
|
||||
netGrowth,
|
||||
newVsReturning,
|
||||
overallReturnRate,
|
||||
repeatIntervals,
|
||||
retentionByGuessBucket,
|
||||
streakSurvival,
|
||||
timeToSecondPlayHistogram,
|
||||
weekdayHourHeatmap
|
||||
} from './metrics';
|
||||
import { addDays, dateRange, dayDiff, startOfWeek, todayUtc } from './dates';
|
||||
|
||||
export type BuildKind = 'scheduled' | 'lazy' | 'reconstructed';
|
||||
|
||||
/**
|
||||
* Bump whenever the snapshot payload shape changes. Older snapshots are
|
||||
* rebuilt lazily so the UI never reads a stale shape.
|
||||
*/
|
||||
export const PAYLOAD_SCHEMA_VERSION = 2;
|
||||
|
||||
const RETENTION_WINDOW_DAYS = 180;
|
||||
const SERIES_DAYS = 90;
|
||||
const RECENT_KPI_DAYS = 30;
|
||||
|
||||
function endOfDay(dateStr: string): Date {
|
||||
return new Date(dateStr + 'T23:59:59.999Z');
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
export interface LoadedData {
|
||||
completions: CompletionRow[];
|
||||
verses: VerseRow[];
|
||||
startedByDate: Map<string, number>;
|
||||
shareEvents: number;
|
||||
firstEventDate: string | null;
|
||||
signupDates: string[];
|
||||
}
|
||||
|
||||
/** Load all rows needed to build a snapshot as of `cutoff` (inclusive). */
|
||||
export async function loadStatsData(
|
||||
cutoff: string,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<LoadedData> {
|
||||
const cutoffDate = endOfDay(cutoff);
|
||||
|
||||
const completionRows = await database
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
date: dailyCompletions.date,
|
||||
guessCount: dailyCompletions.guessCount,
|
||||
guesses: dailyCompletions.guesses,
|
||||
completedAt: dailyCompletions.completedAt
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.where(and(lte(dailyCompletions.date, cutoff), lte(dailyCompletions.completedAt, cutoffDate)));
|
||||
|
||||
const verseRows = await database
|
||||
.select({ date: dailyVerses.date, bookId: dailyVerses.bookId })
|
||||
.from(dailyVerses)
|
||||
.where(lte(dailyVerses.date, cutoff));
|
||||
|
||||
const eventRows = await database
|
||||
.select({
|
||||
name: analyticsEvents.name,
|
||||
date: analyticsEvents.date,
|
||||
createdAt: analyticsEvents.createdAt
|
||||
})
|
||||
.from(analyticsEvents)
|
||||
.where(lte(analyticsEvents.createdAt, cutoffDate));
|
||||
|
||||
const startedByDate = new Map<string, number>();
|
||||
let shareEvents = 0;
|
||||
let firstEventDate: string | null = null;
|
||||
for (const e of eventRows) {
|
||||
const eventDate = e.date ?? new Date(e.createdAt).toISOString().slice(0, 10);
|
||||
if (!firstEventDate || eventDate < firstEventDate) firstEventDate = eventDate;
|
||||
if (e.name === 'verse_served' && e.date && e.date <= cutoff) {
|
||||
startedByDate.set(e.date, (startedByDate.get(e.date) ?? 0) + 1);
|
||||
}
|
||||
if ((e.name === 'share' || e.name === 'share_copy') && eventDate <= cutoff) {
|
||||
shareEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
const userRows = await database
|
||||
.select({ id: user.id, createdAt: user.createdAt })
|
||||
.from(user)
|
||||
.where(lte(user.createdAt, cutoffDate));
|
||||
|
||||
return {
|
||||
completions: completionRows as CompletionRow[],
|
||||
verses: verseRows,
|
||||
startedByDate,
|
||||
shareEvents,
|
||||
firstEventDate,
|
||||
signupDates: userRows
|
||||
.filter((u) => u.createdAt)
|
||||
.map((u) => new Date(u.createdAt as Date).toISOString().slice(0, 10))
|
||||
};
|
||||
}
|
||||
|
||||
/** Compact per-player state as of `asOfDate`, for players active in the window. */
|
||||
export function computePlayerState(
|
||||
completions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
activeWindowDays: number
|
||||
): PlayerState[] {
|
||||
const windowStart = addDays(asOfDate, -(activeWindowDays - 1));
|
||||
const sevenStart = addDays(asOfDate, -6);
|
||||
const thirtyStart = addDays(asOfDate, -29);
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const out: PlayerState[] = [];
|
||||
|
||||
for (const [id, dates] of datesByPlayer) {
|
||||
const inScope = dates.filter((d) => d <= asOfDate);
|
||||
if (inScope.length === 0) continue;
|
||||
const firstDate = inScope[0];
|
||||
const lastDate = inScope[inScope.length - 1];
|
||||
if (lastDate < windowStart) continue;
|
||||
|
||||
// Current consecutive-day streak ending at the last active day.
|
||||
let streak = 1;
|
||||
for (let i = inScope.length - 1; i > 0; i--) {
|
||||
if (dayDiff(inScope[i - 1], inScope[i]) === 1) streak++;
|
||||
else break;
|
||||
}
|
||||
|
||||
out.push({
|
||||
id,
|
||||
firstDate,
|
||||
lastDate,
|
||||
totalPlays: inScope.length,
|
||||
plays7: inScope.filter((d) => d >= sevenStart).length,
|
||||
plays30: inScope.filter((d) => d >= thirtyStart).length,
|
||||
streak
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pooled return rate over a run of cohort days (more stable than single days). */
|
||||
function pooledReturn(
|
||||
completions: CompletionRow[],
|
||||
cohortEnd: string,
|
||||
cohortDays: number,
|
||||
n: number
|
||||
): number | null {
|
||||
const firsts = firstPlayDates(completions);
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const start = addDays(cohortEnd, -(cohortDays - 1));
|
||||
let cohort = 0;
|
||||
let returned = 0;
|
||||
for (const [id, first] of firsts) {
|
||||
if (first < start || first > cohortEnd) continue;
|
||||
cohort++;
|
||||
const limit = addDays(first, n);
|
||||
const dates = datesByPlayer.get(id) ?? [];
|
||||
if (dates.some((d) => d > first && d <= limit)) returned++;
|
||||
}
|
||||
if (cohort === 0) return null;
|
||||
return round1((returned / cohort) * 100);
|
||||
}
|
||||
|
||||
function buildActivity(
|
||||
pointCompletions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
settings: StatsSettings
|
||||
): Activity {
|
||||
const dau = activePlayers(pointCompletions, asOfDate, 1);
|
||||
const wau = activePlayers(pointCompletions, asOfDate, 7);
|
||||
const mau = activePlayers(pointCompletions, asOfDate, 30);
|
||||
const { returningShare } = newVsReturning(pointCompletions, asOfDate);
|
||||
const growth = netGrowth(pointCompletions, asOfDate, 7, settings.resurrect_gap_days);
|
||||
|
||||
// Core count current + one week earlier, both with current thresholds.
|
||||
const core = deriveSegments(
|
||||
computePlayerState(pointCompletions, asOfDate, settings.active_window_days),
|
||||
asOfDate,
|
||||
settings
|
||||
).core.length;
|
||||
const prevDate = addDays(asOfDate, -7);
|
||||
const corePrevious = deriveSegments(
|
||||
computePlayerState(
|
||||
pointCompletions.filter((c) => c.date <= prevDate),
|
||||
prevDate,
|
||||
settings.active_window_days
|
||||
),
|
||||
prevDate,
|
||||
settings
|
||||
).core.length;
|
||||
|
||||
const prev = addDays(asOfDate, -7);
|
||||
const previous = {
|
||||
dau: activePlayers(pointCompletions, prev, 1),
|
||||
wau: activePlayers(pointCompletions, prev, 7),
|
||||
mau: activePlayers(pointCompletions, prev, 30),
|
||||
core: corePrevious
|
||||
};
|
||||
|
||||
return {
|
||||
dau,
|
||||
wau,
|
||||
mau,
|
||||
core,
|
||||
returningShare,
|
||||
newPlayers: growth.newPlayers,
|
||||
resurrected: growth.resurrected,
|
||||
churned: growth.churned,
|
||||
netGrowth: growth.netGrowth,
|
||||
corePrevious,
|
||||
previous
|
||||
};
|
||||
}
|
||||
|
||||
function buildKpis(
|
||||
pointCompletions: CompletionRow[],
|
||||
data: LoadedData,
|
||||
asOfDate: string
|
||||
): Kpis {
|
||||
const recentStart = addDays(asOfDate, -(RECENT_KPI_DAYS - 1));
|
||||
const recent = pointCompletions.filter((c) => c.date >= recentStart);
|
||||
|
||||
const shares = data.shareEvents;
|
||||
const completionsInRecent = recent.length;
|
||||
|
||||
// Completion rate: only meaningful once verse_served events exist.
|
||||
let started = 0;
|
||||
for (const [date, count] of data.startedByDate) {
|
||||
if (date >= recentStart && date <= asOfDate) started += count;
|
||||
}
|
||||
const completionRate = started > 0 ? round1((completionsInRecent / started) * 100) : null;
|
||||
const shareRate = shares > 0 && completionsInRecent > 0
|
||||
? round1((shares / completionsInRecent) * 100)
|
||||
: null;
|
||||
|
||||
return {
|
||||
// Most recent resolvable cohorts (as-of yesterday can't resolve its own D1).
|
||||
d1Return: pooledReturn(pointCompletions, addDays(asOfDate, -1), 7, 1),
|
||||
return7: pooledReturn(pointCompletions, addDays(asOfDate, -7), 7, 7),
|
||||
overallReturnRate: overallReturnRate(pointCompletions),
|
||||
completionRate,
|
||||
shareRate,
|
||||
firstTryRate: firstTryRate(recent),
|
||||
avgGuesses: averageGuesses(recent)
|
||||
};
|
||||
}
|
||||
|
||||
function buildCurves(
|
||||
pointCompletions: CompletionRow[],
|
||||
allCompletions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
n: number,
|
||||
matured: boolean
|
||||
): SeriesPoint[] {
|
||||
const series: SeriesPoint[] = [];
|
||||
// Only cohorts whose window has resolved by `asOfDate` are point-in-time
|
||||
// honest; for matured builds the effective end is today.
|
||||
const effectiveEnd = matured ? todayUtc() : asOfDate;
|
||||
const latest = addDays(effectiveEnd, -n);
|
||||
const start = addDays(latest, -29);
|
||||
const firsts = firstPlayDates(pointCompletions);
|
||||
const datesByPlayer = groupDatesByPlayer(allCompletions);
|
||||
for (const date of dateRange(start, latest)) {
|
||||
const limit = addDays(date, n);
|
||||
let cohort = 0;
|
||||
let returned = 0;
|
||||
for (const [id, first] of firsts) {
|
||||
if (first !== date) continue;
|
||||
cohort++;
|
||||
const dates = datesByPlayer.get(id) ?? [];
|
||||
if (dates.some((d) => d > date && d <= limit)) returned++;
|
||||
}
|
||||
series.push({ date, value: cohort >= 3 ? round1((returned / cohort) * 100) : null });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
function buildHabit(pointCompletions: CompletionRow[], players: PlayerState[], asOfDate: string): HabitData {
|
||||
const calendarStart = addDays(asOfDate, -119);
|
||||
const calendarCounts = new Map<string, number>();
|
||||
for (const c of pointCompletions) {
|
||||
if (c.date < calendarStart || c.date > asOfDate) continue;
|
||||
calendarCounts.set(c.date, (calendarCounts.get(c.date) ?? 0) + 1);
|
||||
}
|
||||
const calendar = dateRange(calendarStart, asOfDate).map((date) => ({
|
||||
date,
|
||||
count: calendarCounts.get(date) ?? 0
|
||||
}));
|
||||
|
||||
// Current-streak distribution, bucketed.
|
||||
const buckets = new Map<string, number>([
|
||||
['1', 0],
|
||||
['2', 0],
|
||||
['3–4', 0],
|
||||
['5–6', 0],
|
||||
['7–13', 0],
|
||||
['14+', 0]
|
||||
]);
|
||||
for (const p of players) {
|
||||
if (p.streak <= 1) continue;
|
||||
const label =
|
||||
p.streak === 2
|
||||
? '2'
|
||||
: p.streak <= 4
|
||||
? '3–4'
|
||||
: p.streak <= 6
|
||||
? '5–6'
|
||||
: p.streak <= 13
|
||||
? '7–13'
|
||||
: '14+';
|
||||
buckets.set(label, (buckets.get(label) ?? 0) + 1);
|
||||
}
|
||||
const streakLengths: HistogramBin[] = [...buckets.entries()].map(([label, value]) => ({
|
||||
label,
|
||||
value
|
||||
}));
|
||||
|
||||
return {
|
||||
streakSurvival: streakSurvival(pointCompletions),
|
||||
streakLengths,
|
||||
calendar,
|
||||
weekdayHour: weekdayHourHeatmap(pointCompletions)
|
||||
};
|
||||
}
|
||||
|
||||
function buildSegmentMix(
|
||||
pointCompletions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
settings: StatsSettings
|
||||
): SnapshotPayload['retention']['segmentMix'] {
|
||||
const start = addDays(asOfDate, -29);
|
||||
const out: SnapshotPayload['retention']['segmentMix'] = [];
|
||||
for (const date of dateRange(start, asOfDate)) {
|
||||
const point = pointCompletions.filter((c) => c.date <= date);
|
||||
const players = computePlayerState(point, date, settings.active_window_days);
|
||||
const counts = countSegments(deriveSegments(players, date, settings));
|
||||
out.push({
|
||||
date,
|
||||
new: counts.new,
|
||||
core: counts.core,
|
||||
returning: counts.returning,
|
||||
resurrected: counts.resurrected,
|
||||
atRisk: counts.atRisk,
|
||||
churned: counts.churned
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildGrowth(
|
||||
data: LoadedData,
|
||||
players: PlayerState[],
|
||||
asOfDate: string
|
||||
): SnapshotPayload['growth'] {
|
||||
const thresholds = [1, 2, 5, 10, 25, 50];
|
||||
const lifecycleFunnel = thresholds.map((t) => ({
|
||||
label: `${t}+`,
|
||||
players: players.filter((p) => p.totalPlays >= t).length
|
||||
}));
|
||||
const start = addDays(asOfDate, -89);
|
||||
const counts = new Map<string, number>();
|
||||
for (const d of data.signupDates) {
|
||||
if (d >= start && d <= asOfDate) counts.set(d, (counts.get(d) ?? 0) + 1);
|
||||
}
|
||||
const signups = dateRange(start, asOfDate).map((date) => ({
|
||||
date,
|
||||
count: counts.get(date) ?? 0
|
||||
}));
|
||||
return { lifecycleFunnel, signups };
|
||||
}
|
||||
|
||||
function buildVerseMix(
|
||||
pointCompletions: CompletionRow[],
|
||||
verses: VerseRow[],
|
||||
asOfDate: string
|
||||
): SnapshotPayload['verseMix'] {
|
||||
const mixStart = addDays(asOfDate, -179);
|
||||
const inRangeVerses = verses.filter((v) => v.date >= mixStart && v.date <= asOfDate);
|
||||
const oldTestament = new Set(
|
||||
bibleBooks.filter((b) => b.testament === 'old').map((b) => b.id)
|
||||
);
|
||||
const coverage = bookCoverage(inRangeVerses, pointCompletions, {
|
||||
start: mixStart,
|
||||
end: asOfDate
|
||||
});
|
||||
|
||||
// Weekly testament mix + book-distribution entropy.
|
||||
const weeksBack = 12;
|
||||
const weekStart = startOfWeek(addDays(asOfDate, -(weeksBack - 1) * 7));
|
||||
const testamentMix: { date: string; old: number; new: number }[] = [];
|
||||
const entropySeries: SeriesPoint[] = [];
|
||||
for (let w = 0; w < weeksBack; w++) {
|
||||
const wStart = addDays(weekStart, w * 7);
|
||||
const wEnd = addDays(wStart, 6);
|
||||
const rows = inRangeVerses.filter((v) => v.date >= wStart && v.date <= wEnd);
|
||||
if (rows.length === 0) {
|
||||
testamentMix.push({ date: wStart, old: 0, new: 0 });
|
||||
entropySeries.push({ date: wStart, value: null });
|
||||
continue;
|
||||
}
|
||||
const bookCounts = new Map<string, number>();
|
||||
let old = 0;
|
||||
let nt = 0;
|
||||
for (const v of rows) {
|
||||
bookCounts.set(v.bookId, (bookCounts.get(v.bookId) ?? 0) + 1);
|
||||
if (oldTestament.has(v.bookId)) old++;
|
||||
else nt++;
|
||||
}
|
||||
testamentMix.push({ date: wStart, old, new: nt });
|
||||
entropySeries.push({ date: wStart, value: entropy([...bookCounts.values()]) });
|
||||
}
|
||||
|
||||
const firstTryByBook = coverage
|
||||
.filter((b) => b.plays >= 3)
|
||||
.sort((a, b) => b.plays - a.plays);
|
||||
|
||||
return {
|
||||
coverage,
|
||||
testamentMix,
|
||||
entropy: entropySeries,
|
||||
repeatIntervals: repeatIntervals(verses),
|
||||
firstTryByBook,
|
||||
retentionByBook: coverage.filter((b) => b.d1Return !== null),
|
||||
retentionByGuessBucket: retentionByGuessBucket(pointCompletions),
|
||||
achievability: guessHistogram(pointCompletions.filter((c) => c.date >= addDays(asOfDate, -29)))
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComputeOptions {
|
||||
/** Data cutoff for retention resolution (defaults to asOfDate). */
|
||||
matureThrough?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a full snapshot payload. Pure with respect to I/O: everything it
|
||||
* needs is passed in, which keeps the point-in-time cutoff testable.
|
||||
*/
|
||||
export function computePayload(
|
||||
asOfDate: string,
|
||||
settings: StatsSettings,
|
||||
data: LoadedData,
|
||||
opts: ComputeOptions = {}
|
||||
): SnapshotPayload {
|
||||
const pointCompletions = data.completions.filter((c) => c.date <= asOfDate);
|
||||
// Retention uses everything loaded: identical for point-in-time builds, and
|
||||
// extends past `asOfDate` for matured builds.
|
||||
const allCompletions = data.completions;
|
||||
const matured = !!opts.matureThrough && opts.matureThrough > asOfDate;
|
||||
|
||||
const activity = buildActivity(pointCompletions, asOfDate, settings);
|
||||
const kpis = buildKpis(pointCompletions, data, asOfDate);
|
||||
|
||||
const seriesStart = addDays(asOfDate, -(SERIES_DAYS - 1));
|
||||
const baseSeries = dailyCompletionsSeries(pointCompletions, seriesStart, asOfDate);
|
||||
const dailySeries: DailyPoint[] = baseSeries.map((d) => ({
|
||||
...d,
|
||||
started: data.startedByDate.get(d.date) ?? 0
|
||||
}));
|
||||
|
||||
const players = computePlayerState(pointCompletions, asOfDate, settings.active_window_days);
|
||||
|
||||
const cohortStart = startOfWeek(addDays(asOfDate, -11 * 7));
|
||||
const cohorts = cohortTriangle(allCompletions, {
|
||||
start: cohortStart,
|
||||
weeks: 12,
|
||||
asOf: matured ? todayUtc() : asOfDate,
|
||||
minSize: 3
|
||||
});
|
||||
const retention: SnapshotPayload['retention'] = {
|
||||
cohorts,
|
||||
curves: {
|
||||
d1: buildCurves(pointCompletions, allCompletions, asOfDate, 1, matured),
|
||||
d7: buildCurves(pointCompletions, allCompletions, asOfDate, 7, matured),
|
||||
d30: buildCurves(pointCompletions, allCompletions, asOfDate, 30, matured)
|
||||
},
|
||||
timeToSecondPlay: timeToSecondPlayHistogram(allCompletions),
|
||||
segmentMix: buildSegmentMix(pointCompletions, asOfDate, settings)
|
||||
};
|
||||
|
||||
const growth = buildGrowth(data, players, asOfDate);
|
||||
const verseMix = buildVerseMix(pointCompletions, data.verses, asOfDate);
|
||||
const habit = buildHabit(pointCompletions, players, asOfDate);
|
||||
const yesterday = pointCompletions.filter((c) => c.date === asOfDate);
|
||||
const totals = {
|
||||
completions: pointCompletions.length,
|
||||
players: firstPlayDates(pointCompletions).size,
|
||||
yesterdayCompletions: yesterday.length,
|
||||
yesterdayAvgGuesses: averageGuesses(yesterday)
|
||||
};
|
||||
|
||||
const payload: SnapshotPayload = {
|
||||
asOfDate,
|
||||
activity,
|
||||
dailySeries,
|
||||
kpis,
|
||||
retention,
|
||||
verseMix,
|
||||
habit,
|
||||
growth,
|
||||
totals,
|
||||
signals: [],
|
||||
meta: {
|
||||
hasStartedData: data.startedByDate.size > 0,
|
||||
hasShareData: data.shareEvents > 0,
|
||||
firstEventDate: data.firstEventDate,
|
||||
generatedAt: Date.now(),
|
||||
schemaVersion: PAYLOAD_SCHEMA_VERSION
|
||||
}
|
||||
};
|
||||
|
||||
payload.signals = evaluateSignals(payload, settings);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export interface StoredSnapshot {
|
||||
date: string;
|
||||
generatedAt: Date;
|
||||
buildKind: BuildKind;
|
||||
configVersion: string;
|
||||
payload: SnapshotPayload;
|
||||
config: StatsSettings;
|
||||
playerState: PlayerState[];
|
||||
}
|
||||
|
||||
function parseRow(row: typeof statsSnapshot.$inferSelect): StoredSnapshot {
|
||||
return {
|
||||
date: row.date,
|
||||
generatedAt: row.generatedAt,
|
||||
buildKind: row.buildKind as BuildKind,
|
||||
configVersion: row.configVersion,
|
||||
payload: JSON.parse(row.payload) as SnapshotPayload,
|
||||
config: JSON.parse(row.config) as StatsSettings,
|
||||
playerState: row.playerState ? (JSON.parse(row.playerState) as PlayerState[]) : []
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSnapshot(
|
||||
date: string,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<StoredSnapshot | null> {
|
||||
const [row] = await database.select().from(statsSnapshot).where(eq(statsSnapshot.date, date));
|
||||
return row ? parseRow(row) : null;
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
snapshot: StoredSnapshot;
|
||||
settings: StatsSettings;
|
||||
buildKind: BuildKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and persist a snapshot. `replace=false` never overwrites an existing
|
||||
* (immutable) snapshot; the caller re-reads instead.
|
||||
*/
|
||||
export async function buildSnapshot(
|
||||
asOfDate: string,
|
||||
opts: { kind: BuildKind; replace?: boolean; matureThrough?: string; persist?: boolean },
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<BuildResult> {
|
||||
const settings = await getSettings(database);
|
||||
const version = hashSettings(settings);
|
||||
const cutoff = opts.matureThrough ?? asOfDate;
|
||||
const data = await loadStatsData(cutoff, database);
|
||||
const payload = computePayload(asOfDate, settings, data, {
|
||||
matureThrough: opts.matureThrough
|
||||
});
|
||||
const playerState = computePlayerState(
|
||||
data.completions.filter((c) => c.date <= asOfDate),
|
||||
asOfDate,
|
||||
settings.active_window_days
|
||||
);
|
||||
|
||||
const row = {
|
||||
date: asOfDate,
|
||||
generatedAt: new Date(),
|
||||
buildKind: opts.kind,
|
||||
configVersion: version,
|
||||
payload: JSON.stringify(payload),
|
||||
playerState: JSON.stringify(playerState),
|
||||
config: JSON.stringify(settings)
|
||||
};
|
||||
|
||||
if (opts.persist !== false) {
|
||||
if (opts.replace) {
|
||||
await database
|
||||
.insert(statsSnapshot)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: statsSnapshot.date, set: row });
|
||||
} else {
|
||||
await database.insert(statsSnapshot).values(row).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
date: asOfDate,
|
||||
generatedAt: row.generatedAt,
|
||||
buildKind: opts.kind,
|
||||
configVersion: version,
|
||||
payload,
|
||||
config: settings,
|
||||
playerState
|
||||
},
|
||||
settings,
|
||||
buildKind: opts.kind
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the snapshot for `date`, building it lazily if missing. Race-safe via
|
||||
* `INSERT OR IGNORE` followed by a re-read.
|
||||
*/
|
||||
export async function ensureSnapshot(
|
||||
date: string,
|
||||
opts: { matureThrough?: string } = {},
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<StoredSnapshot> {
|
||||
const existing = await getSnapshot(date, database);
|
||||
if (existing && existing.payload.meta?.schemaVersion === PAYLOAD_SCHEMA_VERSION) {
|
||||
return existing;
|
||||
}
|
||||
// Missing or built with an older payload shape: (re)build deterministically
|
||||
// for this point in time. The point-in-time cutoff keeps it faithful.
|
||||
await buildSnapshot(
|
||||
date,
|
||||
{ kind: 'lazy', matureThrough: opts.matureThrough, replace: !!existing },
|
||||
database
|
||||
);
|
||||
const stored = await getSnapshot(date, database);
|
||||
if (!stored) throw new Error(`Failed to build snapshot for ${date}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** True when a stored snapshot was built with different thresholds or shape. */
|
||||
export async function isSnapshotStale(
|
||||
date: string,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<boolean> {
|
||||
const snapshot = await getSnapshot(date, database);
|
||||
if (!snapshot) return false;
|
||||
if (snapshot.payload.meta?.schemaVersion !== PAYLOAD_SCHEMA_VERSION) return true;
|
||||
const current = hashSettings(await getSettings(database));
|
||||
return snapshot.configVersion !== current;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { db as defaultDb } from '$lib/server/db';
|
||||
import { dailyVerses, verseSubmissions } from '$lib/server/db/schema';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { getBookById } from '$lib/server/bible';
|
||||
|
||||
// Drizzle instance type alias (the production db or the test db).
|
||||
export type Db = typeof defaultDb;
|
||||
import {
|
||||
composeVerseWindow,
|
||||
formatWindowReference,
|
||||
getChapterCount,
|
||||
getVerseCount
|
||||
} from '$lib/server/xml-bible';
|
||||
|
||||
// Server-side constants for the community-verse-submission feature
|
||||
// (spec: "Rate Limiting & Cooldown", "Scheduling Algorithm").
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
export const COOLDOWN_MS = 7 * DAY_MS;
|
||||
export const REPEAT_WINDOW_DAYS = 60;
|
||||
const MAX_SCHEDULE_RETRIES = 5;
|
||||
// Safety cap on the day-by-day forward scan. 10k days (~27 years) is well
|
||||
// beyond any realistic calendar density; prevents an accidental infinite loop.
|
||||
const MAX_SCAN_DAYS = 10_000;
|
||||
|
||||
export interface SubmissionInput {
|
||||
bookId: string;
|
||||
chapter: number;
|
||||
verse: number;
|
||||
}
|
||||
|
||||
export interface ScheduleResult {
|
||||
scheduledDate: string;
|
||||
reference: string;
|
||||
windowText: string;
|
||||
bookId: string;
|
||||
}
|
||||
|
||||
/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
|
||||
export function addDays(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whole-day difference (b - a) between two YYYY-MM-DD strings, UTC. */
|
||||
export function dayDiff(a: string, b: string): number {
|
||||
const ta = new Date(a + 'T00:00:00Z').getTime();
|
||||
const tb = new Date(b + 'T00:00:00Z').getTime();
|
||||
return Math.round((tb - ta) / DAY_MS);
|
||||
}
|
||||
|
||||
/** Current server UTC date as YYYY-MM-DD. */
|
||||
export function todayUtcStr(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical identity of a 3-verse window, used by the 60-day repeat rule.
|
||||
* Combines bookId + formatted reference so identical windows collide regardless
|
||||
* of how the underlying row was produced (submission vs. random gap-fill).
|
||||
*/
|
||||
function windowRef(bookId: string, reference: string): string {
|
||||
return `${bookId}|${reference}`;
|
||||
}
|
||||
|
||||
/** Is this a SQLite unique-constraint violation (used for retry-on-conflict)? */
|
||||
export function isUniqueConstraintError(err: unknown): boolean {
|
||||
const e = err as { code?: string; message?: string } | null;
|
||||
return !!e && (
|
||||
e.code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
||||
e.code === 'SQLITE_CONSTRAINT' ||
|
||||
!!(e.message && /UNIQUE/i.test(e.message))
|
||||
);
|
||||
}
|
||||
|
||||
export interface CooldownState {
|
||||
/** True if the user submitted within the last 7 days. */
|
||||
onCooldown: boolean;
|
||||
/** Server UTC millis when the cooldown expires, or null. */
|
||||
cooldownEndsAt: number | null;
|
||||
}
|
||||
|
||||
/** Compute the rolling 7-day cooldown state for a user (server UTC). */
|
||||
export async function getCooldownState(
|
||||
db: Db,
|
||||
userId: string,
|
||||
now: number = Date.now()
|
||||
): Promise<CooldownState> {
|
||||
const [last] = await db
|
||||
.select({ submittedAt: verseSubmissions.submittedAt })
|
||||
.from(verseSubmissions)
|
||||
.where(eq(verseSubmissions.userId, userId))
|
||||
.orderBy(desc(verseSubmissions.submittedAt))
|
||||
.limit(1);
|
||||
|
||||
if (!last) {
|
||||
return { onCooldown: false, cooldownEndsAt: null };
|
||||
}
|
||||
|
||||
const cooldownEndsAt = last.submittedAt + COOLDOWN_MS;
|
||||
if (now < cooldownEndsAt) {
|
||||
return { onCooldown: true, cooldownEndsAt };
|
||||
}
|
||||
return { onCooldown: false, cooldownEndsAt: null };
|
||||
}
|
||||
|
||||
/** Structural validation of a user-supplied (bookId, chapter, verse) selection. */
|
||||
export function validateSelection(input: SubmissionInput): string | null {
|
||||
const book = getBookById(input.bookId);
|
||||
if (!book) return 'Unknown bookId';
|
||||
|
||||
if (!Number.isInteger(input.chapter) || input.chapter < 1) {
|
||||
return 'chapter must be a positive integer';
|
||||
}
|
||||
const chapterCount = getChapterCount(book.order);
|
||||
if (input.chapter > chapterCount) {
|
||||
return `chapter out of range (1-${chapterCount})`;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(input.verse) || input.verse < 1) {
|
||||
return 'verse must be a positive integer';
|
||||
}
|
||||
const verseCount = getVerseCount(book.order, input.chapter);
|
||||
if (input.verse > verseCount) {
|
||||
return `verse out of range (1-${verseCount})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface LoadedCalendar {
|
||||
byDate: Map<string, { bookId: string; windowRef: string }>;
|
||||
datesByWindowRef: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/** Load every committed daily_verses row into in-memory indexes for the scan. */
|
||||
async function loadCalendar(db: Db): Promise<LoadedCalendar> {
|
||||
const rows = await db
|
||||
.select({
|
||||
date: dailyVerses.date,
|
||||
bookId: dailyVerses.bookId,
|
||||
reference: dailyVerses.reference
|
||||
})
|
||||
.from(dailyVerses);
|
||||
|
||||
const byDate = new Map<string, { bookId: string; windowRef: string }>();
|
||||
const datesByWindowRef = new Map<string, string[]>();
|
||||
|
||||
for (const r of rows) {
|
||||
const wr = windowRef(r.bookId, r.reference);
|
||||
byDate.set(r.date, { bookId: r.bookId, windowRef: wr });
|
||||
const arr = datesByWindowRef.get(wr);
|
||||
if (arr) arr.push(r.date);
|
||||
else datesByWindowRef.set(wr, [r.date]);
|
||||
}
|
||||
|
||||
return { byDate, datesByWindowRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the earliest valid candidate date `D` (scanning forward from tomorrow,
|
||||
* server UTC) for a submission with the given book + window identity.
|
||||
*
|
||||
* Validity (spec "Scheduling Algorithm"):
|
||||
* 1. Empty: no daily_verses row at D.
|
||||
* 2. No back-to-back: D-1 (if committed) has a different book.
|
||||
* 3. No back-to-back: D+1 (if committed) has a different book.
|
||||
* 4. 60-day repeat: no identical window within [D-60, D+60] inclusive.
|
||||
*/
|
||||
async function findCandidateDate(
|
||||
db: Db,
|
||||
bookId: string,
|
||||
submissionWindowRef: string,
|
||||
opts?: { startFrom?: string; today?: string }
|
||||
): Promise<string | null> {
|
||||
const { byDate, datesByWindowRef } = await loadCalendar(db);
|
||||
|
||||
let cursor = opts?.startFrom ?? addDays(opts?.today ?? todayUtcStr(), 1);
|
||||
for (let i = 0; i < MAX_SCAN_DAYS; i++) {
|
||||
// Rule 1: must be empty.
|
||||
if (!byDate.has(cursor)) {
|
||||
// Rules 2 & 3: no back-to-back with committed neighbors.
|
||||
const prev = byDate.get(addDays(cursor, -1));
|
||||
const next = byDate.get(addDays(cursor, 1));
|
||||
const backToBack =
|
||||
(!!prev && prev.bookId === bookId) ||
|
||||
(!!next && next.bookId === bookId);
|
||||
|
||||
if (!backToBack) {
|
||||
// Rule 4: 60-day repeat distance for the identical window.
|
||||
const sameWindowDates = datesByWindowRef.get(submissionWindowRef) ?? [];
|
||||
const tooClose = sameWindowDates.some(
|
||||
(d) => Math.abs(dayDiff(d, cursor)) <= REPEAT_WINDOW_DAYS
|
||||
);
|
||||
if (!tooClose) {
|
||||
return cursor;
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the scheduling scan and write the daily_verses + verse_submissions rows
|
||||
* in a transaction. On a unique-constraint conflict (concurrent submission
|
||||
* picked the same date), re-run the scan from the next day; retry up to a
|
||||
* small bound.
|
||||
*/
|
||||
export async function scheduleSubmission(
|
||||
db: Db,
|
||||
input: SubmissionInput,
|
||||
userId: string,
|
||||
now: number = Date.now(),
|
||||
opts?: { today?: string }
|
||||
): Promise<ScheduleResult> {
|
||||
const book = getBookById(input.bookId);
|
||||
if (!book) throw new Error('Invalid bookId');
|
||||
|
||||
const window = composeVerseWindow(book.order, input.chapter, input.verse);
|
||||
if (!window) throw new Error('Invalid chapter/verse for this book');
|
||||
|
||||
const reference = formatWindowReference(
|
||||
window.bookName,
|
||||
window.startChapter,
|
||||
window.startVerse,
|
||||
window.endChapter,
|
||||
window.endVerse
|
||||
);
|
||||
const windowText = window.verses.join(' ');
|
||||
const submissionWindowRef = windowRef(book.id, reference);
|
||||
let lastCandidate = '';
|
||||
|
||||
for (let attempt = 0; attempt < MAX_SCHEDULE_RETRIES; attempt++) {
|
||||
// Re-compute the candidate each attempt — a concurrent submission may
|
||||
// have claimed the previous candidate between scan and insert. After a
|
||||
// conflict, re-scan from the day *after* the contested candidate.
|
||||
const candidate = await findCandidateDate(
|
||||
db,
|
||||
book.id,
|
||||
submissionWindowRef,
|
||||
attempt === 0
|
||||
? { today: opts?.today }
|
||||
: { startFrom: addDays(lastCandidate, 1) }
|
||||
);
|
||||
if (!candidate) {
|
||||
throw new Error('No valid candidate date found within the scan window');
|
||||
}
|
||||
lastCandidate = candidate;
|
||||
|
||||
try {
|
||||
db.transaction((tx) => {
|
||||
tx.insert(dailyVerses)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
date: candidate,
|
||||
bookId: book.id,
|
||||
verseText: windowText,
|
||||
reference,
|
||||
createdAt: sql`${Math.floor(now / 1000)}`
|
||||
})
|
||||
.run();
|
||||
tx.insert(verseSubmissions)
|
||||
.values({
|
||||
id: Bun.randomUUIDv7(),
|
||||
userId,
|
||||
scheduledDate: candidate,
|
||||
selectedBookId: input.bookId,
|
||||
selectedChapter: input.chapter,
|
||||
selectedVerse: input.verse,
|
||||
submittedAt: now
|
||||
})
|
||||
.run();
|
||||
});
|
||||
|
||||
return {
|
||||
scheduledDate: candidate,
|
||||
reference,
|
||||
windowText,
|
||||
bookId: book.id
|
||||
};
|
||||
} catch (err) {
|
||||
if (isUniqueConstraintError(err)) {
|
||||
continue; // retry — re-scan from tomorrow
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to schedule submission after retries');
|
||||
}
|
||||
+126
-2
@@ -135,7 +135,7 @@ function getGreekChapter(bookNumber: number, chapterNumber: number): ChapterData
|
||||
/**
|
||||
* Get the number of verses in a specific chapter
|
||||
*/
|
||||
function getVerseCount(bookNumber: number, chapterNumber: number): number {
|
||||
export function getVerseCount(bookNumber: number, chapterNumber: number): number {
|
||||
const chapter = getChapter(bookNumber, chapterNumber);
|
||||
return chapter ? chapter.verse.length : 0;
|
||||
}
|
||||
@@ -151,7 +151,7 @@ function getGreekVerseCount(bookNumber: number, chapterNumber: number): number {
|
||||
/**
|
||||
* Get the number of chapters in a specific book
|
||||
*/
|
||||
function getChapterCount(bookNumber: number): number {
|
||||
export function getChapterCount(bookNumber: number): number {
|
||||
const book = getBook(bookNumber);
|
||||
return book ? book.chapter.length : 0;
|
||||
}
|
||||
@@ -439,3 +439,127 @@ export function getAllNKJVVerses(): Array<{ text: string; book: string; chapter:
|
||||
}
|
||||
return verses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a 1-based book-wide verse index to { chapter, verse }.
|
||||
* The index is contiguous across all chapters of the book.
|
||||
*/
|
||||
function resolveBookVerseIndex(
|
||||
bookNumber: number,
|
||||
index: number
|
||||
): { chapter: number; verse: number } | null {
|
||||
const chapterCount = getChapterCount(bookNumber);
|
||||
let cumulative = 0;
|
||||
for (let c = 1; c <= chapterCount; c++) {
|
||||
const vCount = getVerseCount(bookNumber, c);
|
||||
if (index <= cumulative + vCount) {
|
||||
return { chapter: c, verse: index - cumulative };
|
||||
}
|
||||
cumulative += vCount;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ComposedWindow {
|
||||
bookId: string;
|
||||
bookName: string;
|
||||
verses: string[]; // the 3 verse texts in order
|
||||
startChapter: number;
|
||||
startVerse: number;
|
||||
endChapter: number;
|
||||
endVerse: number;
|
||||
selectedVerse: number; // the anchor verse number within its chapter
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a 3-verse window anchored on (book, chapter, verse), constrained to
|
||||
* a single book. Default window is [v-2, v-1, v] in book-wide verse indices;
|
||||
* if that would run before the book's start (v is 1 or 2), fall forward to
|
||||
* [v, v+1, v+2]. Cross-chapter windows within the same book are allowed.
|
||||
*
|
||||
* Mirrors the spec's "Verse Windowing" section.
|
||||
*/
|
||||
export function composeVerseWindow(
|
||||
bookNumber: number,
|
||||
chapter: number,
|
||||
verse: number
|
||||
): ComposedWindow | null {
|
||||
const book = getBookByNumber(bookNumber);
|
||||
if (!book) return null;
|
||||
|
||||
const chapterCount = getChapterCount(bookNumber);
|
||||
if (chapter < 1 || chapter > chapterCount) return null;
|
||||
const verseCount = getVerseCount(bookNumber, chapter);
|
||||
if (verse < 1 || verse > verseCount) return null;
|
||||
|
||||
// Book-wide 1-based index of the selected verse.
|
||||
let selectedIndex = 0;
|
||||
for (let c = 1; c < chapter; c++) selectedIndex += getVerseCount(bookNumber, c);
|
||||
selectedIndex += verse;
|
||||
|
||||
let startIdx: number;
|
||||
let endIdx: number;
|
||||
if (selectedIndex - 2 < 1) {
|
||||
// Fall forward: [v, v+1, v+2]
|
||||
startIdx = selectedIndex;
|
||||
endIdx = selectedIndex + 2;
|
||||
} else {
|
||||
startIdx = selectedIndex - 2;
|
||||
endIdx = selectedIndex;
|
||||
}
|
||||
|
||||
const start = resolveBookVerseIndex(bookNumber, startIdx);
|
||||
const end = resolveBookVerseIndex(bookNumber, endIdx);
|
||||
if (!start || !end) return null;
|
||||
|
||||
// Extract the contiguous window, crossing chapter boundaries within the book as needed.
|
||||
const verses: string[] = [];
|
||||
let curChapter = start.chapter;
|
||||
let curVerse = start.verse;
|
||||
while (verses.length < 3 && curChapter <= chapterCount) {
|
||||
const vCount = getVerseCount(bookNumber, curChapter);
|
||||
while (curVerse <= vCount && verses.length < 3) {
|
||||
const ex = extractVerses(bookNumber, curChapter, curVerse, 1);
|
||||
if (ex.length === 1) {
|
||||
verses.push(ex[0]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
curVerse++;
|
||||
}
|
||||
curChapter++;
|
||||
curVerse = 1;
|
||||
}
|
||||
|
||||
if (verses.length < 3) return null;
|
||||
|
||||
return {
|
||||
bookId: book.id,
|
||||
bookName: book.name,
|
||||
verses,
|
||||
startChapter: start.chapter,
|
||||
startVerse: start.verse,
|
||||
endChapter: end.chapter,
|
||||
endVerse: end.verse,
|
||||
selectedVerse: verse
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a reference for a window that may span chapters within a book.
|
||||
* Same-chapter: "Genesis 1:1-3" (hyphen)
|
||||
* Cross-chapter: "Genesis 1:31–2:1" (en-dash)
|
||||
*/
|
||||
export function formatWindowReference(
|
||||
bookName: string,
|
||||
startChapter: number,
|
||||
startVerse: number,
|
||||
endChapter: number,
|
||||
endVerse: number
|
||||
): string {
|
||||
if (startChapter === endChapter) {
|
||||
if (startVerse === endVerse) return `${bookName} ${startChapter}:${startVerse}`;
|
||||
return `${bookName} ${startChapter}:${startVerse}-${endVerse}`;
|
||||
}
|
||||
return `${bookName} ${startChapter}:${startVerse}\u2013${endChapter}:${endVerse}`;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { evaluateGuess, type Guess } from "$lib/utils/game";
|
||||
import { getOrCreateAnonymousId } from "$lib/utils/analytics";
|
||||
|
||||
// Reactive store that keeps in-memory game state in sync with localStorage.
|
||||
// Accepts getter functions (rather than plain values) so Svelte's reactivity
|
||||
// system can track dependencies and re-run effects when they change.
|
||||
type AuthUser = {
|
||||
id: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export function createGamePersistence(
|
||||
getDate: () => string,
|
||||
getReference: () => string,
|
||||
getCorrectBookId: () => string,
|
||||
getUser: () => AuthUser | null | undefined,
|
||||
) {
|
||||
let guesses = $state<Guess[]>([]);
|
||||
let anonymousId = $state("");
|
||||
let statsSubmitted = $state(false);
|
||||
let chapterGuessCompleted = $state(false);
|
||||
let chapterCorrect = $state(false);
|
||||
|
||||
// On mount (and if the user logs in/out), resolve the player's identity and
|
||||
// restore per-day flags from localStorage.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const user = getUser();
|
||||
// CRITICAL: If user is logged in, ALWAYS use their user ID
|
||||
if (user) {
|
||||
anonymousId = user.id;
|
||||
} else {
|
||||
anonymousId = getOrCreateAnonymousId();
|
||||
}
|
||||
|
||||
// Tell analytics which player this is so events are grouped correctly.
|
||||
if ((window as any).umami) {
|
||||
(window as any).umami.identify(anonymousId);
|
||||
}
|
||||
|
||||
|
||||
const date = getDate();
|
||||
const reference = getReference();
|
||||
|
||||
// Restore whether today's completion was already submitted to the server.
|
||||
statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true";
|
||||
|
||||
// Restore the chapter bonus guess result. The stored value includes the
|
||||
// chapter the player selected, so we can re-derive whether it was correct.
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null;
|
||||
if (chapterGuessCompleted) {
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On mount (and if the date or correct answer changes), load today's guesses
|
||||
// from localStorage and reconstruct them as typed Guess objects by re-evaluating
|
||||
// each stored book ID against the correct answer.
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
|
||||
const date = getDate();
|
||||
const correctBookId = getCorrectBookId();
|
||||
const key = `bibdle-guesses-${date}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) {
|
||||
guesses = [];
|
||||
return;
|
||||
}
|
||||
|
||||
let savedIds: string[] = JSON.parse(saved);
|
||||
savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case
|
||||
guesses = savedIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
});
|
||||
|
||||
// Persist guesses to localStorage whenever they change. Only the book IDs are
|
||||
// stored — the full Guess shape is re-derived on load (see effect above).
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const date = getDate();
|
||||
localStorage.setItem(
|
||||
`bibdle-guesses-${date}`,
|
||||
JSON.stringify(guesses.map((g) => g.book.id)),
|
||||
);
|
||||
});
|
||||
|
||||
// Called after stats are successfully submitted to the server so that
|
||||
// returning to the page doesn't trigger a duplicate submission.
|
||||
function markStatsSubmitted() {
|
||||
if (!browser) return;
|
||||
statsSubmitted = true;
|
||||
localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true");
|
||||
}
|
||||
|
||||
// Marks the win as tracked for analytics. Returns true the first time (new
|
||||
// win), false on subsequent calls so the analytics event fires exactly once.
|
||||
function markWinTracked() {
|
||||
if (!browser) return;
|
||||
const key = `bibdle-win-tracked-${getDate()}`;
|
||||
if (localStorage.getItem(key) === "true") return false;
|
||||
localStorage.setItem(key, "true");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns true if the win has already been tracked in a previous render/session.
|
||||
// Used to skip the animation delay when returning to an already-won game.
|
||||
function isWinAlreadyTracked(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true";
|
||||
}
|
||||
|
||||
// Overwrites local state with the server's authoritative guess record.
|
||||
// Called when a logged-in user opens the game on a new device so their
|
||||
// progress from another device is restored.
|
||||
function hydrateFromServer(guessIds: string[]) {
|
||||
if (!browser) return;
|
||||
const correctBookId = getCorrectBookId();
|
||||
const date = getDate();
|
||||
guesses = guessIds
|
||||
.map((bookId) => evaluateGuess(bookId, correctBookId))
|
||||
.filter((g): g is Guess => g !== null);
|
||||
}
|
||||
|
||||
// Called by the WinScreen after the player submits their chapter bonus guess.
|
||||
// Reads the result written to localStorage by WinScreen and updates reactive state.
|
||||
function onChapterGuessCompleted() {
|
||||
if (!browser) return;
|
||||
chapterGuessCompleted = true;
|
||||
const reference = getReference();
|
||||
const chapterGuessKey = `bibdle-chapter-guess-${reference}`;
|
||||
const saved = localStorage.getItem(chapterGuessKey);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
const match = reference.match(/\s(\d+):/);
|
||||
const correctChapter = match ? parseInt(match[1], 10) : 1;
|
||||
chapterCorrect = data.selectedChapter === correctChapter;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get guesses() { return guesses; },
|
||||
set guesses(v: Guess[]) { guesses = v; },
|
||||
get anonymousId() { return anonymousId; },
|
||||
get statsSubmitted() { return statsSubmitted; },
|
||||
get chapterGuessCompleted() { return chapterGuessCompleted; },
|
||||
get chapterCorrect() { return chapterCorrect; },
|
||||
markStatsSubmitted,
|
||||
markWinTracked,
|
||||
isWinAlreadyTracked,
|
||||
onChapterGuessCompleted,
|
||||
hydrateFromServer,
|
||||
};
|
||||
}
|
||||
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user