13 Commits
Author SHA1 Message Date
George Powell 4d5893ce14 chore(docs): note community stats and admin graph todos 2026-09-28 20:21:49 -04:00
George Powell da29ee7196 feat(stats): quantile-scaled, annotated calendar heatmap
Replace the linear count/max alpha with quantile buckets (GitHub-style)
so a single outlier day no longer flattens the rest of the graph, and
render best/median/worst day labels inside their cells.

- Add buildCalendarScale utility with empty-bucket handling.
- Make CalendarHeatmap size, gap, and annotations configurable; align
  weeks to Monday.
- Expand the community calendar to 90 days, add play totals/averages and
  all-time best day, and format large numbers with locale separators.
2026-09-28 20:21:48 -04:00
George Powell a562ee3cc9 fix(stats): finalize snapshots on the verse-day window
A verse date is a player's local calendar day, spanning D-1 10:00Z
(UTC+14) through D+1 12:00Z (UTC-12). The old UTC end-of-day cutoff both
dropped late-night finishes in negative timezones and could persist a
partial day.

- Scope snapshot loads by the verse date column; use completedBefore only
  to pin an in-progress point-in-time build.
- Gate persistence on isDayClosed (D+1 12:00Z); open days are computed
  live and never written.
- Build the cron snapshot for latestClosedDate instead of EST yesterday,
  and read the same date for the community page.
- Add all-time best single day to snapshot totals.
- Bump PAYLOAD_SCHEMA_VERSION to 4 and extend snapshot/window tests.
2026-09-28 20:21:47 -04:00
George Powell 45ffa39f2a first pass analytics redo 2026-09-22 12:15:56 -04:00
George Powell b656fd04c0 Merge pull request #3 from pupperpowell/rss-feed-fixes
fix(rss): stop future-verse leaks, correct timezone dates, fix feed discovery
2026-09-22 12:01:19 -04:00
George Powell 97bff35323 fix(rss): halt future-verse leaks, correct timezone dates, fix discovery
- Cap /feed.xml at the current America/New_York calendar day so
  pre-scheduled future verses (and stray 2124 rows) can no longer
  surface at the top of the feed
- Emit DST-aware RFC 822 pubDates (correct -04:00/-05:00 offset) instead
  of a hardcoded EST offset, and parse calendar days as UTC so titles
  no longer shift by one day on UTC hosts
- Add content-addressed ETag + Last-Modified with If-None-Match /
  If-Modified-Since conditional handling
- Add atom:link rel=self, content:encoded, channel pubDate/generator/image
- Move RSS auto-discovery <link> into app.html: the root route sets
  ssr=false, so the layout <svelte:head> link never reached the raw HTML
  and feed readers could not discover /feed.xml
- Extract feed helpers into $lib/server/rss and add unit tests
2026-09-22 11:31:36 -04:00
George Powell 4a53e09ab3 documentation 2026-07-07 17:49:33 -04:00
George Powell 2fb484ebaa Minor button cooldown change 2026-07-07 17:06:12 -04:00
George Powell 21efdd4eef New scheduling UI! 2026-07-07 17:02:24 -04:00
George Powell be0d7ad297 Added verse-submission infrastructure 2026-07-07 15:41:10 -04:00
George Powell 99552c57ad Added donation button 2026-07-07 13:12:17 -04:00
George Powell efc9900de1 Updated documentation 2026-07-07 12:13:35 -04:00
George Powell e3ca264c54 new email link 2026-05-26 17:59:56 -04:00
100 changed files with 10824 additions and 1515 deletions

No files matched your search

+16
View File
@@ -1,11 +1,27 @@
DATABASE_URL=example.db 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 job secret for protected endpoints (e.g. send-daily-verse)
CRON_SECRET=your-cron-secret-here CRON_SECRET=your-cron-secret-here
# Discord webhook URL for posting the daily verse # Discord webhook URL for posting the daily verse
DISCORD_DAILY_WEBHOOK=https://discord.com/api/webhooks/your-webhook-url 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 PUBLIC_SITE_URL=https://bibdle.com
# nodemailer # nodemailer
-165
View File
@@ -1,165 +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.
## 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 Bun's built-in cryptographically secure functions
- **Deployment**: Node.js adapter for production builds
- **ML**: `@xenova/transformers` for verse embeddings (initialized in server hook) (currently disabled, was a test for a cancelled project)
## Development Commands
```bash
# Start development server
bun run dev
# Type checking
bun run check
bun run check:watch
# Run tests
bun test
bun test --watch
bun test tests/timezone-handling.test.ts # Run a single test file
# Build for production
bun run build
# Preview production build
bun run preview
# Database operations
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
```
## Critical: Date/Time Handling
**Bibdle is played by users across many timezones worldwide. The verse shown to a player must always be the verse for the calendar date at *their* location — not the server's timezone, not UTC. A user in Tokyo on Wednesday must see Wednesday's verse, even if the server (or a user in New York) is still on Tuesday.**
**NEVER use server time or UTC time for user-facing date calculations.**
- Get today's date client-side: `new Date().toLocaleDateString("en-CA")` → `YYYY-MM-DD`
- Pass the date to the server as a query param or POST body (`localDate`)
- Server-side date arithmetic must use UTC methods on the client-provided date string: `new Date(dateStr + 'T00:00:00Z')` + `setUTCDate`/`getUTCDate`
- `src/routes/+page.ts` has `ssr = false` so the load runs client-side with the true local date
- Never set the user-facing URL to include their date as a parameter. It should always be passed to an API route behind the scenes if needed.
### Streak Calculation
A streak counts consecutive calendar days (in the user's local timezone) on which the user completed the puzzle. The rules:
- The client passes its local date (`localDate`) to the streak API. The server never uses its own clock.
- A streak is **active** if the user has completed today's puzzle *or* yesterday's puzzle (they still have time to play today).
- Walk backwards from `localDate` through the `dailyCompletions` records, counting each day that has a completion. Stop as soon as a day is missing.
- A streak of 1 (completed only today or only yesterday, with no prior consecutive days) is **not displayed** — the minimum shown streak is 2.
- "Yesterday" and all date arithmetic on the server must use UTC methods on the client-provided date string to avoid timezone drift: `new Date(localDate + 'T00:00:00Z')`, then `setUTCDate`/`getUTCDate`.
## Architecture
### Database Schema (`src/lib/server/db/schema.ts`)
- **user**: `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `isPrivate`
- **session**: `id` (SHA-256 hash of token), `userId` (FK), `expiresAt`
- **daily_verses**: Cached daily verses with book ID, verse text, reference, and date
- **dailyCompletions**: Game results per user/date with guess count, grade, book; unique on `(userId, date)`
Sessions expire after 30 days and auto-renew when < 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)
### Daily Verse System (`src/routes/+page.server.ts`)
`getTodayVerse()` checks the database for today's date, fetches a verse if missing, caches permanently, and returns verse with book metadata.
### Game Logic (`src/routes/+page.svelte`)
**State Management:**
- `guesses` array stored in localStorage keyed by date: `bibdle-guesses-${date}`
- Each guess tracks: book, testamentMatch, sectionMatch, adjacent
- `isWon` derived from whether any guess matches the correct book
**Hint System, for share grid:**
- ✅ Exact match | 🟩 Section match | 🟧 Testament match | ‼️ Adjacent book | 🟥 No match
### Authentication System (`src/lib/server/auth.ts`)
- Token generation: base64-encoded random bytes; stored as SHA-256 hash in DB
- Cookie name: `auth-session`
- Anonymous users: identified by a client-generated ID; stats migrate on sign-up via `migrateAnonymousStats()`
- Apple Sign-In supported via `appleId` field
### Stats & Streak (`src/routes/stats/`)
- Stats page requires auth; returns `requiresAuth: true` if unauthenticated
- Streak calculated client-side by calling `GET /api/streak?userId=X&localDate=Y`
- Streak walk-back: counts consecutive days backwards from `localDate` through completed dates
- Minimum displayed streak is 2 (single-day streaks suppressed)
## API Endpoints
- `POST /api/daily-verse` — Fetch verse for a specific date
- `POST /api/submit-completion` — Submit game result with stats
- `GET /api/streak?userId=X&localDate=Y` — Current streak for user
- `GET /api/streak-percentile` — Streak percentile ranking
## Key Files
- `src/routes/+page.svelte` — Main game UI and client-side logic
- `src/routes/+page.server.ts` / `+page.ts` — Server load (verse) + client load (`ssr: false`)
- `src/routes/stats/+page.svelte` / `+page.server.ts` — Stats UI and server calculations
- `src/lib/server/auth.ts` — Session management, password hashing, anonymous migration
- `src/lib/server/bible-api.ts` — Random verse fetching from local XML Bible
- `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
- `src/hooks.server.ts` — Session validation hook; initializes ML embeddings
- `tests/` — Bun test suites (timezone, game, bible, stats, share, auth migration)
## Environment Variables
Required in `.env`:
- `DATABASE_URL` — Path to SQLite database file (e.g., `./local.db`)
## Deployment
Uses `@sveltejs/adapter-node`. See `bibdle.service` systemd configuration.
## A Note
The main developer of this project is still learning a lot about developing full-stack applications. If they ask you to do something, make sure they understand how it will be implemented before proceeding.
+194 -23
View File
@@ -1,38 +1,209 @@
# sv # Bibdle
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). A daily Bible verse guessing game. Each day a verse is shown and players try to identify which of the 66 books of the Bible it comes from, receiving Wordle-style feedback (Testament match, Section match, Adjacent book, First letter, etc.) after each guess. A new verse is generated daily and progress is tracked across timezones.
## Creating a project Live at [bibdle.com](https://bibdle.com).
If you're seeing this, you've probably already done this step. Congrats! ## Tech Stack
```sh - **Framework**: SvelteKit 2 with Svelte 5 (runes: `$state`, `$derived`, `$effect`, `$props`)
# create a new project in the current directory - **Styling**: Tailwind CSS 4
bunx sv create - **Database**: SQLite (`bun:sqlite`) with Drizzle ORM
- **Auth**: Session-based — email/password (argon2id via `Bun.password`), plus Apple and Google OAuth
- **Bible text**: Local NKJV XML (`EnglishNKJBible.xml`), parsed with `fast-xml-parser`; a Greek 1904 and Swedish 2000 translation are also bundled for alternate modes
- **ML** (currently disabled): `@xenova/transformers` verse embeddings for a similarity search route
- **Deployment**: `@sveltejs/adapter-node`, run under Bun, managed by a systemd service (see `bibdle.service`)
# create a new project in my-app ## Getting Started
bunx sv create my-app
```
## Developing ```bash
bun install
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: # Start the dev server (Vite, Bun runtime)
```sh
bun run dev bun run dev
# or start the server and open the app in a new browser tab # Type checking
bun run dev -- --open bun run check
```
## Building # Tests (Bun test)
bun test
bun test tests/timezone-handling.test.ts # single file
bun test --watch
To create a production version of your app: # Production build & preview
```sh
bun run build bun run build
bun run preview
``` ```
You can preview the production build with `bun run preview`. ### Database
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. ```bash
bun run db:push # push schema changes directly (avoid in prod)
bun run db:generate # generate migrations
bun run db:migrate # run migrations
bun run db:studio # open Drizzle Studio GUI
```
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.
### Environment Variables
See `.env.example`. Required/used variables:
- `DATABASE_URL` — path to the SQLite database file (e.g. `prod.db`)
- `PUBLIC_SITE_URL` — canonical site URL
- `CRON_SECRET` — bearer token protecting the cron-only `/api/send-daily-verse` 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)
## 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 the most recent snapshot whose global data window has closed (`D+1 12:00Z`) 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.
-10
View File
@@ -224,8 +224,6 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="], "@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -418,10 +416,6 @@
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"blakejs": ["blakejs@1.2.1", "", {}, "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ=="], "blakejs": ["blakejs@1.2.1", "", {}, "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ=="],
@@ -698,8 +692,6 @@
"file-type": ["file-type@5.2.0", "", {}, "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ=="], "file-type": ["file-type@5.2.0", "", {}, "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
"flatbuffers": ["flatbuffers@1.12.0", "", {}, "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ=="], "flatbuffers": ["flatbuffers@1.12.0", "", {}, "sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ=="],
@@ -1498,8 +1490,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/better-sqlite3/@types/node": ["@types/node@22.19.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA=="],
"asn1.js/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], "asn1.js/bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
"babel-code-frame/js-tokens": ["js-tokens@3.0.2", "", {}, "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg=="], "babel-code-frame/js-tokens": ["js-tokens@3.0.2", "", {}, "sha512-RjTcuD4xjtthQkaWH7dFlH85L+QaVtSoOyGdZ3g6HFhS9dFNDfLyqgm2NFe2X6cQpeFmt0452FJjFG5UameExg=="],
+31
View File
@@ -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;
+88
View File
@@ -281,6 +281,94 @@
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {}, "uniqueConstraints": {},
"checkConstraints": {} "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": {}, "views": {},
+581
View File
@@ -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": {}
}
}
+7
View File
@@ -29,6 +29,13 @@
"when": 1774416309647, "when": 1774416309647,
"tag": "0003_overjoyed_mindworm", "tag": "0003_overjoyed_mindworm",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1789505566968,
"tag": "0004_slim_nomad",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun --bun vite dev", "dev": "bun --bun vite dev",
"build": "vite build", "build": "bun --bun vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+67
View File
@@ -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.
+299
View File
@@ -0,0 +1,299 @@
# Spec: Community Verse Submissions
## TODO
- [x] **Step 1 — Schema & migration:** add `verse_submissions` table to `src/lib/server/db/schema.ts` (nullable `user_id` FK → `user.id` ON DELETE SET NULL, unique `scheduled_date`, index on `user_id`), pushed to dev.db.
- [x] **Step 2 — Bible structure + windowing helpers:** export `getChapterCount`/`getVerseCount` from `xml-bible.ts`; add `composeVerseWindow()` (fall-forward 3-verse window, never crosses book, crosses chapter within book) and `formatWindowReference()` (hyphen same-chapter, en-dash cross-chapter). Tests in `tests/verse-window.test.ts` (17 pass).
- [x] **Step 3 — Public APIs:** `GET /api/bible/structure` (66-book verse counts for cascading dropdowns) and `GET /api/verse-window` (live 3-verse preview).
- [x] **Step 4 — Admin module + `/scheduled-verses` page:** `src/lib/server/admin.ts` (`ADMIN_EMAIL`), auth-walled admin view joining `verse_submissions` → `daily_verses` → `user`.
- [x] **Step 5 — Submit backend + lazy gap-fill:** `POST /api/submit-verse` (solved-today gate, 7-day cooldown, structural validation, scheduling scan with empty/no-back-to-back/60-day-repeat rules, transaction + retry), `GET /api/submit-verse/status`, modified `getVerseForDate` neighbor avoidance.
- [x] **Step 6 — Frontend `SubmitVerse.svelte`:** three states (logged-out sign-in dropdown, cooldown + countdown, cascading selects + preview + submit), wired into `WinScreen.svelte`.
- [x] **Step 7 — Tests:** scheduling validity, cooldown arithmetic, concurrency.
- [x] **Step 8 — Docs:** update README schema + routes/API tables.
---
Let authenticated users submit a Bible verse they love. Submissions are scheduled as future "verses of the day," mixed so that **no two consecutive calendar days ever feature the same book**, and spaced so an exact 3-verse window never repeats within 60 days. Selection is dropdown-based (cascading `<select>`s) so the system can compose verses purely from existing Bible-lookup functions already in the codebase — no free-text entry, so typos are impossible and content is always canonical NKJV text.
## Goals
- A new button on the **win screen** (`WinScreen.svelte`), visually consistent with the existing "📈 See your progress" neobrutalist button.
- Logged-out users who click it get a dropdown prompting sign-in (mirroring the existing progress button's Apple/Google dropdown).
- Logged-in users get a cascading Book → Chapter → Verse selector with a live 3-verse preview.
- Each submission immediately reserves a concrete future date; the submitter is shown that date on success.
- A 7-day rolling cooldown (server UTC) gates submissions, with the button greyed out and a countdown timer shown while active.
- Attribution is **anonymous everywhere** — submissions are never credited to a user publicly.
## Non-goals (explicitly decided)
- **No giveaway-text filtering.** Verses that mention their own book/author (e.g. Eph 1:1, Isa 1:1) are allowed. The random generator already permits these.
- **No persistent pending queue / no cron.** Assignment happens at submit time, not via a background job.
- **No withdrawal / deletion / editing.** Submissions are final (references come from dropdowns, so typos are impossible).
- **No guardrail on how far out a verse is scheduled.** If the calendar is dense, a submission may land months or years in the future; this is accepted.
- **No per-user pending cap, no global cap.** The 7-day rolling cooldown is the only rate limit.
- **No attribution / leaderboard / share-text changes.** Submitter identity is recorded only for rate-limiting and the solved-today gate; it is never displayed.
- **No backfill of gap days.** Sparse future `dailyVerses` rows are fine.
---
## Data Model
### New table: `verse_submissions`
A log of who submitted what and when, plus the assigned scheduled date. The canonical verse text/reference lives in `daily_verses` (keyed by `date`); this table links a user to a scheduled date.
| Column | Type | Notes |
| --- | --- | --- |
| `id` | text (pk) | `Bun.randomUUIDv7()` |
| `user_id` | text, not null, FK → `user.id` | the submitter (never displayed) |
| `scheduled_date` | text, not null, **unique** | `YYYY-MM-DD` of the reserved `daily_verses` row |
| `selected_book_id` | text, not null | the book the user chose |
| `selected_chapter` | integer, not null | the chapter the user chose |
| `selected_verse` | integer, not null | the single verse the user chose (the "anchor") |
| `submitted_at` | integer (timestamp), not null | server UTC millis — drives the 7-day cooldown |
Indexes: `user_id` (cooldown lookup + "your upcoming verses"), `scheduled_date` (unique — one submission per scheduled date).
No `status`, `withdrawn_at`, or attribution columns. Denormalized window/verseText/reference are **not** stored here; they live on `daily_verses` and are joined by `scheduled_date`.
### `daily_verses` (unchanged schema)
Submissions simply write a new row with a future `date`, exactly as the existing lazy path does for the current day. `getVerseForDate(date)` already returns a pre-existing row if present, so a pre-written submission row for a future date is served unchanged when its day arrives.
The new behavior:
- **Submission rows** are written at submit time for a future date (the date returned by the scheduling scan).
- **Gap days** (dates with no committed row when a player arrives) are filled lazily by the existing random path — extended to respect the no-back-to-back rule (see *Lazy gap-fill* below).
---
## Verse Windowing
A submission selects **one** verse (book → chapter → verse). The displayed daily verse is a **3-verse window** anchored on that verse, for difficulty consistency with the existing game (`getRandomVerses` defaults to 3 consecutive verses).
### Window algorithm (deterministic)
Let `v` be the selected verse's index within its **book** (a contiguous 1-based index across all chapters of that book). Let `bookStart` = 1 (first verse index in the book) and `bookEnd` = last verse index in the book.
- Default window: `[v-2, v-1, v]` (the two preceding in-book verses + the selected verse).
- If `v - 2 < bookStart` (not enough preceding verses in-book — only happens at a book's very start, e.g. Gen 1:1, John 1:1): **fall forward** so the window is 3 consecutive in-book verses that still includes `v`, i.e. start = `v`, window = `[v, v+1, v+2]`. (Every Bible book has ≥3 verses after its opening, so this always succeeds.)
- The window **never crosses a book boundary**. Crossing a chapter boundary **within the same book** is allowed and expected (e.g. a window spanning the end of chapter 1 and the start of chapter 2).
This reuses existing functions: `getChapterCount`, `getVerseCount`, `extractVerses` (called per chapter; for cross-chapter windows, call `extractVerses` twice and concatenate), `getBookByNumber`/`getBookById`, and `formatReference` (extended to format cross-chapter ranges, e.g. `Genesis 1:31–2:1`; same-chapter stays `Genesis 1:1-3`).
The selected verse's **book** is the day's `bookId` (the guess target). All three displayed verses come from that same book, so the guessing game stays fair.
---
## Scheduling Algorithm (assign-at-submit)
When a submission is accepted, the server scans forward from **tomorrow** (server UTC date) to find the earliest valid candidate date `D`, then writes the `daily_verses` row for `D` immediately and records a `verse_submissions` row. The submitter is shown `D`.
### Validity checks for candidate date `D` and submission book `B`
`D` is valid iff **all** hold:
1. **Empty:** no `daily_verses` row exists at `D`.
2. **No back-to-back with prior committed neighbor:** the `daily_verses` row at `D-1` (if one exists) must have `book_id != B`.
3. **No back-to-back with next committed neighbor:** the `daily_verses` row at `D+1` (if one exists) must have `book_id != B`.
4. **60-day repeat distance:** no `daily_verses` row with the **same 3-verse window** exists whose date is within `[D-60, D+60]` days inclusive. The window identity is its canonical reference string (`book_id` + formatted `chapter:startverse-endverse`, cross-chapter-aware). This is a *soft* repeat rule: repeats are allowed, just not within ±60 days of any prior/scheduled appearance.
### Implementation note (performance)
Don't query per candidate date. Instead, fetch all `daily_verses` rows with `date > today` ordered by date into memory, then walk day-by-day from tomorrow, checking the four rules against the in-memory set + a single range query for the 60-day repeat check (windowed by reference). Historical rows (date ≤ today) only matter for rule 4; a bounded lookup (rows with the same window-ref within 60 days of `D`) suffices.
### Concurrency
The scan-then-insert is a critical section. Two concurrent submissions could both select the same `D`. Wrap insert in a transaction; on a `SQLITE_CONSTRAINT_UNIQUE` failure on `daily_verses.date` (or `verse_submissions.scheduled_date`), re-run the scan from `D+1`. Retry up to a small bound (e.g. 5). SQLite under `bun:sqlite` handles this with a transaction.
### Why this preserves no-back-to-back globally
Adjacency is only ever checked against **committed** rows (D-1, D+1). Gap days (empty between two committed rows) are filled lazily later by a random verse whose generator also respects committed neighbors (see below). So at play-time, every adjacent pair of days has been checked at write-time. The invariant is maintained inductively.
---
## Lazy Gap-Fill (modified `getVerseForDate`)
When a player opens a date with no committed `daily_verses` row, the existing random path runs — **extended** so the random verse's book differs from any committed neighbor:
- If `D-1` has a committed row with book `X`, the random book must be `!= X`.
- If `D+1` has a committed row with book `Y`, the random book must be `!= Y`.
- Re-try `getRandomVerses()` until both hold (66 books, ≤2 excluded → ~97% success per try; cap at ~20 retries, then accept). `getRandomVerses` already returns 3 consecutive in-book verses; no windowing change needed for random fillers.
Submission days are pre-written, so they skip this path entirely (`getVerseForDate` returns the existing row).
---
## Rate Limiting & Cooldown
- **One submission per user per rolling 7×24h window**, measured in **server UTC** (not gameable; smooth countdown).
- `cooldownEndsAt = lastSubmission.submitted_at + 7 days` (server UTC millis).
- The win-screen button is **always visible**; when the cooldown is active it is **greyed out and disabled**, with a small countdown timer below it (target `cooldownEndsAt`).
- Query: select the user's most recent `verse_submissions.submitted_at`; if `now - submitted_at < 7d`, cooldown is active.
### Solved-today gate (API-level)
The `POST /api/submit-verse` endpoint requires that the user has a `daily_completions` row for **their local today**. The client sends `localDate` (the same `YYYY-MM-DD` it already computes via `toLocaleDateString("en-CA")` and sends to other endpoints). The server checks `dailyCompletions` for a row with `anonymous_id = user.id` (for logged-in users, `anonymousId` is the user id after migration) and `date = localDate`. If absent → `403` with `{ error: "Solve today's puzzle first" }`.
This is an **engagement gate**, not a security necessity (submissions are anonymous → low spam incentive), but it ties the feature to real play and blocks scripted submissions.
---
## Validation (no content moderation)
Because verses are chosen from canonical NKJV via dropdowns, no profanity/inappropriate-content filter is needed. Pre-submit validation is purely structural:
- `bookId` is one of the 66 known books (`getBookById`).
- `chapter` is in `[1, getChapterCount(bookNumber)]`.
- `verse` is in `[1, getVerseCount(bookNumber, chapter)]`.
- Cooldown not active.
- Solved-today gate passed.
- (The 60-day repeat and no-back-to-back rules are enforced by the scheduling scan, not by pre-rejecting the user's selection. A user can pick any valid verse; the scan will simply place it on the earliest valid date, possibly far out.)
---
## API Endpoints
### `/scheduled-verses` (auth-walled, admin-only)
A private admin page gated to the single account `geohpowell@gmail.com`. This is the **one place** where submitter identity is surfaced — attribution is anonymous everywhere else (public game, share text, etc.), but this admin view is the exception.
**Access control:**
- `+page.server.ts` `load` requires `locals.user` and `locals.user.email === 'geohpowell@gmail.com'`.
- Not authenticated → redirect to the sign-in modal/flow (same pattern as `/progress`, `/stats`).
- Authenticated but wrong account → `403` / a plain "not authorized" page. Do not leak the existence of privileged data; the page simply renders an access-denied state.
- The email check is a literal string match; if the admin email ever changes, update the constant (single source in one server module, e.g. `src/lib/server/admin.ts` exporting `ADMIN_EMAIL`).
- No `isPrivate` bypass needed — the admin is trusted to see all submitters regardless of their `isPrivate` flag.
**Page contents:** a table of all `verse_submissions` rows joined to `daily_verses` (by `scheduled_date = daily_verses.date`) and `user` (by `user_id`), one row per submission:
| Column | Source |
| --- | --- |
| Scheduled date | `verse_submissions.scheduled_date` (rendered human-readable) |
| Reference | `daily_verses.reference` |
| Window text | `daily_verses.verse_text` (the 3-verse window) |
| Book | `daily_verses.book_id` (resolve to name via `getBookById`) |
| Submitted by | `user.email` (and `firstName lastName` if present) |
| Submitted at | `verse_submissions.submitted_at` (server UTC, rendered) |
| Selected verse | `verse_submissions.selected_book_id` / `selected_chapter` / `selected_verse` (the anchor the user picked) |
Sorted by `scheduled_date` ascending (so the upcoming calendar reads top-to-bottom). Include past rows (already-played submissions) — the table is a full historical + future log, not just pending. A small filter toggle (All / Upcoming / Past) is a nice-to-have, not required.
Since attribution appears here, `verse_submissions.user_id` must resolve even for deleted accounts — use a `LEFT JOIN` to `user` and render "(deleted user)" when the join is null. (Matches the `ON DELETE SET NULL` decision in the edge-cases section: the scheduled verse still plays out; the admin just sees the attribution is gone.)
This route is **not** linked from the UI (no nav entry, no win-screen button) — it's a direct-URL admin tool.
### `POST /api/submit-verse` (auth required)
**Body:** `{ bookId, chapter, verse, localDate }`
**Flow:**
1. Require `locals.user` (else `401`).
2. Solved-today gate: `dailyCompletions` row for `(anonymousId=user.id, date=localDate)` else `403`.
3. Cooldown: most recent `verse_submissions.submitted_at` for user; if `< 7d` ago → `429 { cooldownEndsAt }`.
4. Structural validation of `bookId/chapter/verse`.
5. Compute the 3-verse window (fall-forward algorithm).
6. Run the scheduling scan; on unique-constraint conflict, retry.
7. In a transaction: insert `daily_verses` row `{ id, date: D, bookId, reference, verseText, createdAt: now }` and `verse_submissions` row `{ id, user_id, scheduled_date: D, selected_book_id, selected_chapter, selected_verse, submitted_at: now }`.
8. Return `{ scheduledDate: D, reference, windowText }`.
Analytics: emit `(window as any).rybbit?.event("Submit a verse")`.
### `GET /api/submit-verse/status?localDate=YYYY-MM-DD` (auth required)
Returns the state for the win-screen button:
```json
{
"canSubmit": true | false,
"cooldownEndsAt": <server UTC millis> | null,
"lastSubmission": { "scheduledDate": "2026-08-24", "reference": "..." } | null,
"upcoming": [ { "scheduledDate": "...", "reference": "..." }, ... ] // this user's not-yet-reached submissions, for an optional small list
}
```
### `GET /api/bible/structure` (public)
Returns the static structure for populating the cascading dropdowns client-side (cached indefinitely):
```json
[
{ "bookId": "gen", "chapters": [31, 25, 24, ...] }, // verse counts per chapter
...
]
```
Built from `getChapterCount` + `getVerseCount` over all 66 books. Small payload (~1189 chapter entries).
### `GET /api/verse-window?bookId=gen&chapter=1&verse=1` (public)
Returns the 3-verse preview for the live preview pane, computed via the fall-forward window algorithm:
```json
{
"windowVerses": ["In the beginning...", "And the earth...", "And God said..."],
"reference": "Genesis 1:1-3",
"bookId": "gen",
"selectedVerse": 1
}
```
Called debounced (e.g. 250ms) as the selector changes.
---
## Frontend
### New component: `src/lib/components/SubmitVerse.svelte`
Rendered inside `WinScreen.svelte`, near the existing "📈 See your progress" button, sharing the same neobrutalist `.progress-btn` styling.
**Three visual states:**
1. **Logged out** — button reads "✨ Submit a verse" with a chevron; clicking expands a dropdown identical in structure to the existing logged-out progress dropdown: helper text ("Sign in to submit a verse for a future day") + Apple Sign-In form (`POST /auth/apple`, with hidden `anonymousId`) + Google Sign-In form (`POST /auth/google`). Mirrors the existing markup/classes exactly.
2. **Logged in, cooldown active** — button is greyed out / disabled ("✨ Submit a verse" + lock/⏳), with a small countdown timer below it counting down to `cooldownEndsAt` (reuse the `CountdownTimer` pattern / a compact timer). The dropdown does not expand.
3. **Logged in, can submit** — clicking expands a panel with:
- Three cascading native `<select>` dropdowns: **Book** (grouped by Testament, `<optgroup>`), **Chapter** (1..chapterCount), **Verse** (1..verseCount). Book list/chapter counts come from `/api/bible/structure` (fetched once, cached). Selecting book populates chapters; selecting chapter populates verses; selecting verse (or any change) triggers the debounced preview fetch.
- A **live preview** card showing the 3-verse window text + reference, updating in real time as the selector changes. (This is not today's verse — it's the user's prospective submission — so showing the book/reference is fine.)
- A **Submit** button (neobrutalist). On success, replace the panel with a confirmation: "✅ Your verse is scheduled for **August 24, 2026**" + the reference, plus a rybbit event. On `429`/`403`, show the server's error message inline.
The win screen already receives `isLoggedIn` and `anonymousId` as props; pass them through to `SubmitVerse`.
### Placement note
The button is **win-screen-only**, which implicitly requires the user to have solved today's puzzle to reach it (the win screen only renders post-solve, and reloads still render it via `game-persistence` restoring today's completed guesses). This matches the API-level solved-today gate.
---
## Reused Existing Functions
| Function | Used for |
| --- | --- |
| `getChapterCount(bookNumber)` | Dropdown chapter list bounds |
| `getVerseCount(bookNumber, chapter)` | Dropdown verse list bounds |
| `extractVerses(bookNumber, chapter, startVerse, count)` | Composing the 3-verse window (per chapter; concat for cross-chapter) |
| `getBookById` / `getBookByNumber` | Book id ↔ number ↔ name resolution |
| `formatReference(bookName, chapter, startVerse, endVerse)` | Reference formatting (extend for cross-chapter) |
| `getRandomVerses()` | Lazy gap-fill random verse (extended with neighbor avoidance) |
| `getVerseForDate(date)` | Returns pre-written submission row unchanged on its day |
| Auth (`locals.user`, session cookie) | Identifying the submitter |
| `dailyCompletions` (`anonymousId = user.id`, `date`) | Solved-today gate |
---
## Edge Cases & Decisions Log
- **Selected verse at a book's start (v1/v2 of chapter 1):** fall forward to `[v, v+1, v+2]`. Always 3 verses, same book.
- **Cross-chapter window within a book:** allowed; reference formatted as `Book C1:S1–C2:E2`.
- **Cross-book window:** never happens (algorithm constrains to one book).
- **Concurrent submissions picking the same date:** transaction + retry-on-unique-conflict.
- **Repeat of an identical 3-verse window:** allowed, but the scan skips any candidate date within ±60 days of an existing same-window row, so repeats land far out (this is what "schedule them out a month or two" means in practice).
- **Calendar drift (submissions outpace 1/day demand):** accepted — no guardrail; a submission may land far in the future. The submitter is shown the resulting date regardless.
- **Account deletion:** submissions are anonymous; `daily_verses` rows stay (canonical text). The `verse_submissions` row's `user_id` FK should be `ON DELETE SET NULL` (or cascade-delete the log row) — either is fine since attribution is never shown. The scheduled verse plays out regardless.
- **Discord daily-verse cron / RSS / sitemap:** unchanged — they call `getVerseForDate`, which serves pre-written submission rows transparently.
- **`createdAt` on submission-written `daily_verses` rows:** equals submit time, not the future play date. This is consistent with the existing field's meaning ("when the row was created").
- **Admin `/scheduled-verses` view is the sole attribution exception:** the public-facing anonymity guarantee does not apply to this admin-only route, which deliberately surfaces `user.email` for the `geohpowell@gmail.com` account. All other surfaces (win screen, share text, future potential leaderboard) remain anonymous.
---
## Open / Deferred (not blocking v1)
- A small "your upcoming scheduled verses" list on the win screen (data already available from `/api/submit-verse/status`). Nice-to-have, not required for v1.
- Balancing book distribution beyond no-back-to-back (e.g. avoiding clustering the same book within a month). Explicitly out of scope — only adjacent-day collisions are prevented.
- Email/notification when a user's scheduled verse goes live. Out of scope.
+1 -1
View File
@@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<script src="https://rybbit.snail.city/api/script.js" data-site-id="9abf0e81d024" defer></script>
<link rel="icon" href="/favicon.png" type="image/png" /> <link rel="icon" href="/favicon.png" type="image/png" />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+2 -2
View File
@@ -17,9 +17,9 @@ const handleAuth: Handle = async ({ event, resolve }) => {
const { session, user } = await auth.validateSessionToken(sessionToken); const { session, user } = await auth.validateSessionToken(sessionToken);
if (session) { if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt); auth.setSessionTokenCookie({ cookies: event.cookies }, sessionToken, session.expiresAt);
} else { } else {
auth.deleteSessionTokenCookie(event); auth.deleteSessionTokenCookie({ cookies: event.cookies });
} }
event.locals.user = user; event.locals.user = user;
+3
View File
@@ -7,6 +7,7 @@
onclick?: () => void; onclick?: () => void;
class?: string; class?: string;
type?: "button" | "submit" | "reset"; type?: "button" | "submit" | "reset";
disabled?: boolean;
} }
let { let {
@@ -15,6 +16,7 @@
onclick, onclick,
class: className = "", class: className = "",
type = "button", type = "button",
disabled = false,
}: Props = $props(); }: Props = $props();
const variantClasses = { const variantClasses = {
@@ -31,6 +33,7 @@
<button <button
{type} {type}
{onclick} {onclick}
{disabled}
class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[ class="inline-flex items-center justify-center px-4 py-2 rounded-lg border-2 font-bold text-sm transition-all duration-200 {variantClasses[
variant variant
]} {className}" ]} {className}"
-2
View File
@@ -6,7 +6,6 @@
$effect(() => { $effect(() => {
let fadeOutId: ReturnType<typeof setTimeout>; let fadeOutId: ReturnType<typeof setTimeout>;
let fadeInId: ReturnType<typeof setTimeout>;
let changeId: ReturnType<typeof setTimeout>; let changeId: ReturnType<typeof setTimeout>;
function animateTo(newText: string, delay = 0) { function animateTo(newText: string, delay = 0) {
@@ -27,7 +26,6 @@
return () => { return () => {
clearTimeout(fadeOutId); clearTimeout(fadeOutId);
clearTimeout(fadeInId);
clearTimeout(changeId); clearTimeout(changeId);
}; };
}); });
+1 -4
View File
@@ -11,7 +11,6 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Bluesky" aria-label="Follow on Bluesky"
data-umami-event="Bluesky clicked" data-umami-event="Bluesky clicked"
onclick={() => (window as any).rybbit?.event("Bluesky clicked")}
> >
<img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" /> <img src={BlueskyLogo} alt="Bluesky" class="w-8 h-8" />
</a> </a>
@@ -25,7 +24,6 @@
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Follow on Twitter" aria-label="Follow on Twitter"
data-umami-event="Twitter clicked" data-umami-event="Twitter clicked"
onclick={() => (window as any).rybbit?.event("Twitter clicked")}
> >
<img src={TwitterLogo} alt="Twitter" class="w-8 h-8" /> <img src={TwitterLogo} alt="Twitter" class="w-8 h-8" />
</a> </a>
@@ -33,11 +31,10 @@
<div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> --> <div class="w-0.5 h-8 bg-gray-400 dark:bg-gray-600"></div> -->
<a <a
href="mailto:george+bibdle@silentsummit.co" href="mailto:george@snail.city"
class="inline-flex hover:opacity-80 transition-opacity" class="inline-flex hover:opacity-80 transition-opacity"
aria-label="Send email" aria-label="Send email"
data-umami-event="Email clicked" data-umami-event="Email clicked"
onclick={() => (window as any).rybbit?.event("Email clicked")}
> >
<svg <svg
class="w-8 h-8 text-gray-700 dark:text-gray-300" class="w-8 h-8 text-gray-700 dark:text-gray-300"
+995
View File
@@ -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>
+10 -4
View File
@@ -1,14 +1,21 @@
<script lang="ts"> <script lang="ts">
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import type { PageData } from "../../routes/$types.js"; // Approximate type; adjust if needed
import Container from "./Container.svelte"; import Container from "./Container.svelte";
interface VerseDisplayData {
dailyVerse: {
date: string;
reference: string;
verseText: string;
};
}
let { let {
data, data,
isWon, isWon,
blurChapter = false, blurChapter = false,
}: { data: PageData; isWon: boolean; blurChapter?: boolean } = $props(); }: { data: VerseDisplayData; isWon: boolean; blurChapter?: boolean } = $props();
let dailyVerse = $derived(data.dailyVerse); let dailyVerse = $derived(data.dailyVerse);
let displayReference = $derived( let displayReference = $derived(
blurChapter blurChapter
@@ -19,7 +26,7 @@
); );
let displayVerseText = $derived( let displayVerseText = $derived(
dailyVerse.verseText dailyVerse.verseText
.replace(/^([a-z])/, (c) => c.toUpperCase()) .replace(/^([a-z])/, (c: string) => c.toUpperCase())
.replace(/[,:;-—]$/, "..."), .replace(/[,:;-—]$/, "..."),
); );
@@ -55,7 +62,6 @@
function copyVerse() { function copyVerse() {
navigator.clipboard.writeText(displayVerseText).then(() => { navigator.clipboard.writeText(displayVerseText).then(() => {
copied = true; copied = true;
(window as any).rybbit?.event("Copy Verse");
setTimeout(() => { setTimeout(() => {
copied = false; copied = false;
}, 2000); }, 2000);
+178 -41
View File
@@ -10,6 +10,8 @@
import CountdownTimer from "./CountdownTimer.svelte"; import CountdownTimer from "./CountdownTimer.svelte";
import StreakCounter from "./StreakCounter.svelte"; import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte"; import ChapterGuess from "./ChapterGuess.svelte";
import SubmitVerse from "./SubmitVerse.svelte";
import { track } from "$lib/utils/analytics";
interface StatsData { interface StatsData {
solveRank: number; solveRank: number;
@@ -41,6 +43,7 @@
streakPercentile = null, streakPercentile = null,
isLoggedIn = false, isLoggedIn = false,
anonymousId = "", anonymousId = "",
localDate = "",
}: { }: {
statsData: StatsData | null; statsData: StatsData | null;
correctBookId: string; correctBookId: string;
@@ -57,6 +60,7 @@
streakPercentile?: number | null; streakPercentile?: number | null;
isLoggedIn?: boolean; isLoggedIn?: boolean;
anonymousId?: string; anonymousId?: string;
localDate?: string;
} = $props(); } = $props();
let bookName = $derived(getBookById(correctBookId)?.name ?? ""); let bookName = $derived(getBookById(correctBookId)?.name ?? "");
@@ -68,6 +72,7 @@
let copyTracked = $state(false); let copyTracked = $state(false);
let showSnippetOption = $state(false); let showSnippetOption = $state(false);
let includeSnippet = $state(false); let includeSnippet = $state(false);
let showProgressDropdown = $state(false);
let effectiveShareText = $derived( let effectiveShareText = $derived(
includeSnippet includeSnippet
@@ -252,15 +257,13 @@
: "Copy to Clipboard"} : "Copy to Clipboard"}
onclick={() => { onclick={() => {
if (hasWebShare) { if (hasWebShare) {
(window as any).rybbit?.event("Share"); track("share", { snippet: includeSnippet });
shareResult(effectiveShareText); shareResult(effectiveShareText);
} else { } else {
if (!copyTracked) { if (!copyTracked) {
(window as any).rybbit?.event(
"Copy to Clipboard",
);
copyTracked = true; copyTracked = true;
} }
track("share_copy", { snippet: includeSnippet });
clipboardCopy(effectiveShareText); clipboardCopy(effectiveShareText);
copySuccess = true; copySuccess = true;
setTimeout(() => { setTimeout(() => {
@@ -287,9 +290,9 @@
data-umami-event="Copy to Clipboard" data-umami-event="Copy to Clipboard"
onclick={() => { onclick={() => {
if (!copyTracked) { if (!copyTracked) {
(window as any).rybbit?.event("Copy to Clipboard");
copyTracked = true; copyTracked = true;
} }
track("share_copy", { snippet: includeSnippet });
clipboardCopy(effectiveShareText); clipboardCopy(effectiveShareText);
showSnippetOption = true; showSnippetOption = true;
bubbleCopied = true; bubbleCopied = true;
@@ -333,17 +336,32 @@
{#if isLoggedIn} {#if isLoggedIn}
<div class="signin-prompt"> <div class="signin-prompt">
<div class="rainbow-glow w-full">
<a <a
href="/progress" href="/progress"
class="flex flex-col items-center justify-center gap-1 w-full p-4 mb-2 bg-white dark:bg-gray-900 border-2 border-black/40 dark:border-white/40 rounded-2xl shadow-sm text-gray-800 dark:text-gray-100 text-base font-semibold no-underline transition-transform duration-100 hover:-translate-y-px active:scale-[0.98]" class="progress-btn w-full"
data-umami-event="See your progress (logged in)"
> >
📈 See your progress <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> </a>
</div> </div>
</div>
{:else} {:else}
<div class="signin-prompt"> <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"> <p class="signin-text text-gray-800 dark:text-gray-300">
Create an account (or sign in) to track your progress Create an account (or sign in) to track your progress
</p> </p>
@@ -398,8 +416,60 @@
Sign in with Google Sign in with Google
</button> </button>
</form> </form>
{/if}
</div> </div>
{/if} {/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> </div>
<style> <style>
@@ -680,45 +750,59 @@
transform: translateX(16px); transform: translateX(16px);
} }
/* ── Apple Sign In prompt ── */ /* ── See your progress button (neobrutalist) ── */
.rainbow-glow { .progress-btn {
position: relative; display: flex;
border-radius: 1rem; 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;
} }
.rainbow-glow::before { .progress-btn:hover {
content: ""; transform: translate(-2px, -2px);
position: absolute; box-shadow: 8px 8px 0 0 #000;
inset: 0px;
border-radius: 1.25rem;
background: conic-gradient(
from var(--angle, 0deg),
#ff0080,
#ff8c00,
#ffd700,
#00ff88,
#00cfff,
#a855f7,
#ff0080
);
animation: rainbow-rotate 6s linear infinite;
filter: blur(8px);
opacity: 0.75;
z-index: -1;
} }
@property --angle { .progress-btn:active {
syntax: "<angle>"; transform: translate(2px, 2px);
initial-value: 0deg; box-shadow: 2px 2px 0 0 #000;
inherits: false;
} }
@keyframes rainbow-rotate { .progress-chev {
0% { width: 1.25rem;
--angle: 0deg; height: 1.25rem;
transition: transform 200ms ease;
} }
100% {
--angle: 360deg; .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;
} }
} }
@@ -730,6 +814,59 @@
/*padding: 1rem 0 0.25rem;*/ /*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 { .signin-text {
font-size: 0.85rem; font-size: 0.85rem;
text-align: center; text-align: center;
+33
View File
@@ -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,105 @@
<script lang="ts">
import { buildCalendarScale } from '$lib/utils/calendar-scale';
interface Props {
data: { date: string; count: number }[];
color?: string;
/** Pixel size of each day square. */
cellSize?: number;
/** Gap between squares in pixels. */
gap?: number;
/** Optional date → short label rendered inside the square. */
annotations?: Record<string, string>;
}
let {
data,
color = '52,211,153',
cellSize = 12,
gap = 3,
annotations = {}
}: Props = $props();
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
const scale = $derived(buildCalendarScale(data.map((d) => d.count)));
const fontSize = $derived(Math.max(8, Math.round(cellSize * 0.42)));
const radius = $derived(Math.max(2, Math.round(cellSize * 0.16)));
// Build Monday-aligned weeks ending on the last date (weekend at the bottom).
const weeks = $derived.by(() => {
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
const mondayOffset = (date: Date) => (date.getUTCDay() + 6) % 7;
const first = new Date(data[0].date + 'T00:00:00Z');
const start = new Date(first);
start.setUTCDate(start.getUTCDate() - mondayOffset(start));
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
const end = new Date(last);
end.setUTCDate(end.getUTCDate() + (6 - mondayOffset(end)));
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" style={`gap:${Math.max(4, gap)}px`}>
<div
class="flex flex-col justify-between py-0.5 text-gray-500"
style={`font-size:${fontSize}px`}
>
<span>Mon</span><span>Thu</span><span>Sun</span>
</div>
<div class="flex" style={`gap:${gap}px`}>
{#each weeks as week, wi (wi)}
<div class="flex flex-col" style={`gap:${gap}px`}>
{#each week as cell (cell.date)}
{@const count = values.get(cell.date) ?? 0}
{@const alpha = scale.alphaFor(count) || 0.06}
{@const label = annotations[cell.date]}
<div
class="cell-label flex items-center justify-center font-semibold tabular-nums"
data-pale={label && alpha < 0.4 ? 'true' : undefined}
style={`width:${cellSize}px;height:${cellSize}px;border-radius:${radius}px;font-size:${fontSize}px;line-height:1;${
cell.inRange ? `background:rgba(${color},${alpha})` : 'background:transparent'
}`}
title={cell.inRange
? `${cell.date}: ${count.toLocaleString('en-US')} finishes`
: cell.date}
>{label ?? ''}</div>
{/each}
</div>
{/each}
</div>
</div>
</div>
<style>
/*
* Annotated numbers need to read on both light and dark cell backgrounds.
* Intensity is the dominant factor: the bright/high buckets are light enough
* for dark ink in either theme, while the lowest bucket is a dark green on a
* dark page and needs light ink there.
*/
.cell-label {
color: rgb(17 24 39 / 0.95);
}
:global(html.dark) .cell-label[data-pale='true'] {
color: rgb(249 250 251 / 0.95);
}
@media (prefers-color-scheme: dark) {
:global(html:not(.light)) .cell-label[data-pale='true'] {
color: rgb(249 250 251 / 0.95);
}
}
</style>
@@ -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>
+54
View File
@@ -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 &amp; 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>
+68
View File
@@ -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>
+61
View File
@@ -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>
+103
View File
@@ -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 &amp; 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 &amp; 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>
+172
View File
@@ -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 &amp; 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>
+16
View File
@@ -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;
}
+7 -6
View File
@@ -1,4 +1,4 @@
import type { RequestEvent } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { testDb as db } from '$lib/server/db/test'; import { testDb as db } from '$lib/server/db/test';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId)); await db.delete(table.session).where(eq(table.session.id, sessionId));
} }
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, { cookies.set(sessionCookieName, token, {
expires: expiresAt, expires: expiresAt,
path: '/' path: '/'
}); });
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
event.cookies.delete(sessionCookieName, { cookies.delete(sessionCookieName, {
path: '/' path: '/'
}); });
} }
@@ -102,7 +102,8 @@ export async function createUser(anonymousId: string, email: string, passwordHas
googleId: null, googleId: null,
firstName: firstName || null, firstName: firstName || null,
lastName: lastName || null, lastName: lastName || null,
isPrivate: false isPrivate: false,
createdAt: new Date()
}; };
await db.insert(table.user).values(user); await db.insert(table.user).values(user);
return user; return user;
+7 -6
View File
@@ -1,4 +1,4 @@
import type { RequestEvent } from '@sveltejs/kit'; import type { Cookies, RequestEvent } from '@sveltejs/kit';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema'; import * as table from '$lib/server/db/schema';
@@ -64,15 +64,15 @@ export async function invalidateSession(sessionId: string) {
await db.delete(table.session).where(eq(table.session.id, sessionId)); await db.delete(table.session).where(eq(table.session.id, sessionId));
} }
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: Date) { export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: Date) {
event.cookies.set(sessionCookieName, token, { cookies.set(sessionCookieName, token, {
expires: expiresAt, expires: expiresAt,
path: '/' path: '/'
}); });
} }
export function deleteSessionTokenCookie(event: RequestEvent) { export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }) {
event.cookies.delete(sessionCookieName, { cookies.delete(sessionCookieName, {
path: '/' path: '/'
}); });
} }
@@ -102,7 +102,8 @@ export async function createUser(anonymousId: string, email: string, passwordHas
googleId: null, googleId: null,
firstName: firstName || null, firstName: firstName || null,
lastName: lastName || null, lastName: lastName || null,
isPrivate: false isPrivate: false,
createdAt: new Date()
}; };
await db.insert(table.user).values(user); await db.insert(table.user).values(user);
return user; return user;
+19
View File
@@ -31,3 +31,22 @@ export async function fetchRandomVerse(): Promise<ApiVerse> {
verseText verseText
}; };
} }
/**
* Like fetchRandomVerse, but re-rolls until the picked book is not in
* `avoidBookIds` (the no-back-to-back neighbors for lazy gap-fill).
* 66 books with ≤2 excluded gives ~97% success per try; capped at 20 tries
* before accepting whatever was last drawn (spec: "Lazy Gap-Fill").
*/
export async function fetchRandomVerseAvoiding(
avoidBookIds: string[] = []
): Promise<ApiVerse> {
const avoid = new Set(avoidBookIds.filter(Boolean));
let last: ApiVerse | null = null;
for (let i = 0; i < 20; i++) {
const v = await fetchRandomVerse();
last = v;
if (!avoid.has(v.bookId)) return v;
}
return last as ApiVerse;
}
+30 -3
View File
@@ -1,9 +1,16 @@
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema'; import { dailyVerses } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm'; import { eq, sql } from 'drizzle-orm';
import { fetchRandomVerse } from '$lib/server/bible-api'; import { fetchRandomVerse, fetchRandomVerseAvoiding } from '$lib/server/bible-api';
import type { DailyVerse } from '$lib/server/db/schema'; import type { DailyVerse } from '$lib/server/db/schema';
/** Add `n` days to a YYYY-MM-DD string using pure UTC arithmetic. */
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
export async function getVerseForDate(dateStr: string): Promise<DailyVerse> { export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
// Validate date format (YYYY-MM-DD) // Validate date format (YYYY-MM-DD)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
@@ -16,8 +23,28 @@ export async function getVerseForDate(dateStr: string): Promise<DailyVerse> {
return existing[0]; return existing[0];
} }
// Otherwise get a new random verse for this date // Otherwise get a new random verse for this date. Gap-fill is extended to
const apiVerse = await fetchRandomVerse(); // respect the no-back-to-back rule: the random verse's book must differ from
// any committed neighbor (D-1 / D+1). This preserves the global invariant
// that no two consecutive calendar days ever feature the same book
// (spec: "Lazy Gap-Fill").
const [prev] = await db
.select({ bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(eq(dailyVerses.date, addDays(dateStr, -1)))
.limit(1);
const [next] = await db
.select({ bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(eq(dailyVerses.date, addDays(dateStr, 1)))
.limit(1);
const avoid = [prev?.bookId, next?.bookId].filter((b): b is string => !!b);
const apiVerse =
avoid.length > 0
? await fetchRandomVerseAvoiding(avoid)
: await fetchRandomVerse();
const createdAt = sql`${Math.floor(Date.now() / 1000)}`; const createdAt = sql`${Math.floor(Date.now() / 1000)}`;
const newVerse: Omit<DailyVerse, 'createdAt'> = { const newVerse: Omit<DailyVerse, 'createdAt'> = {
+75 -2
View File
@@ -1,4 +1,5 @@
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', { export const user = sqliteTable('user', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -8,7 +9,10 @@ export const user = sqliteTable('user', {
passwordHash: text('password_hash'), passwordHash: text('password_hash'),
appleId: text('apple_id').unique(), appleId: text('apple_id').unique(),
googleId: text('google_id').unique(), googleId: text('google_id').unique(),
isPrivate: integer('is_private', { mode: 'boolean' }).default(false) isPrivate: integer('is_private', { mode: 'boolean' }).default(false),
// Additive: existing rows stay NULL ("unknown signup date"). New rows get a
// timestamp automatically via the client-side default function.
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())
}); });
export const session = sqliteTable('session', { export const session = sqliteTable('session', {
@@ -48,3 +52,72 @@ export const dailyCompletions = sqliteTable('daily_completions', {
]); ]);
export type DailyCompletion = typeof dailyCompletions.$inferSelect; export type DailyCompletion = typeof dailyCompletions.$inferSelect;
export const verseSubmissions = sqliteTable('verse_submissions', {
id: text('id').primaryKey(),
// Nullable so ON DELETE SET NULL can clear attribution when a user is deleted;
// the scheduled verse still plays out (canonical text lives on daily_verses).
userId: text('user_id').references(() => user.id, { onDelete: 'set null' }),
scheduledDate: text('scheduled_date').notNull().unique(),
selectedBookId: text('selected_book_id').notNull(),
selectedChapter: integer('selected_chapter').notNull(),
selectedVerse: integer('selected_verse').notNull(),
// Raw integer of server UTC milliseconds — drives the 7-day rolling cooldown.
submittedAt: integer('submitted_at').notNull(),
}, (table) => [
index('verse_submissions_user_id_idx').on(table.userId),
]);
export type VerseSubmission = typeof verseSubmissions.$inferSelect;
// ── 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;
+165
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/**
* 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;
}
+58
View File
@@ -0,0 +1,58 @@
/** 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);
}
// A verse `date` is the player's *local* calendar date, not a fixed UTC window.
// Globally the same verse date is played from D-1 10:00Z (UTC+14) through
// D+1 12:00Z (UTC-12), so a day's data is only final after D+1 12:00Z.
const HOUR_MS = 60 * 60 * 1000;
const VERSE_WINDOW_MS = 36 * HOUR_MS; // D+1 12:00Z, relative to D 00:00Z
/** Instant after which no further plays for verse `dateStr` are expected. */
export function endOfVerseWindow(dateStr: string): Date {
return new Date(new Date(dateStr + 'T00:00:00Z').getTime() + VERSE_WINDOW_MS);
}
/** True once the global data window for `dateStr` has closed. */
export function isDayClosed(dateStr: string, now: Date = new Date()): boolean {
return now.getTime() >= endOfVerseWindow(dateStr).getTime();
}
/** Most recent verse date whose global data window has closed. */
export function latestClosedDate(now: Date = new Date()): string {
return new Date(now.getTime() - VERSE_WINDOW_MS).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());
}
+4
View File
@@ -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;
+44
View File
@@ -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;
}
}
+70
View File
@@ -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;
}
}
+478
View File
@@ -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 };
+80
View File
@@ -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
};
}
+82
View File
@@ -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));
}
+246
View File
@@ -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;
}
+740
View File
@@ -0,0 +1,740 @@
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, endOfVerseWindow, isDayClosed, 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 = 4;
const RETENTION_WINDOW_DAYS = 180;
const SERIES_DAYS = 90;
const RECENT_KPI_DAYS = 30;
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[];
}
export interface LoadStatsOptions {
/**
* Point-in-time cutoff for time-stamped rows. When omitted, the full data
* window is loaded and rows are grouped purely by their verse `date`.
*/
completedBefore?: Date;
}
/**
* Load all rows needed to build a snapshot as of `cutoff` (inclusive).
*
* Rows are scoped by the verse `date` column, not by a UTC 24-hour window:
* a verse date is a player's *local* day, so a single date legitimately spans
* D-1 10:00Z through D+1 12:00Z. `completedBefore` is only for point-in-time
* builds of a day whose window is still open.
*/
export async function loadStatsData(
cutoff: string,
database: StatsDb = defaultDb,
opts: LoadStatsOptions = {}
): Promise<LoadedData> {
const { completedBefore } = opts;
const completionRows = await database
.select({
anonymousId: dailyCompletions.anonymousId,
date: dailyCompletions.date,
guessCount: dailyCompletions.guessCount,
guesses: dailyCompletions.guesses,
completedAt: dailyCompletions.completedAt
})
.from(dailyCompletions)
.where(
completedBefore
? and(
lte(dailyCompletions.date, cutoff),
lte(dailyCompletions.completedAt, completedBefore)
)
: lte(dailyCompletions.date, cutoff)
);
const verseRows = await database
.select({ date: dailyVerses.date, bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(lte(dailyVerses.date, cutoff));
// Events (and signups) are time-stamped rather than date-scoped. For a final
// build the window end is exact; an open-day build pins to `completedBefore`.
const rowBefore = completedBefore ?? endOfVerseWindow(cutoff);
const eventRows = await database
.select({
name: analyticsEvents.name,
date: analyticsEvents.date,
createdAt: analyticsEvents.createdAt
})
.from(analyticsEvents)
.where(lte(analyticsEvents.createdAt, rowBefore));
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 (eventDate > cutoff) continue;
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') {
shareEvents++;
}
}
const userRows = await database
.select({ id: user.id, createdAt: user.createdAt })
.from(user)
.where(lte(user.createdAt, rowBefore));
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);
// All-time best single day (earliest date wins ties).
const completionsByDate = new Map<string, number>();
for (const c of pointCompletions) {
completionsByDate.set(c.date, (completionsByDate.get(c.date) ?? 0) + 1);
}
let bestDay: { date: string; count: number } | null = null;
for (const [date, count] of completionsByDate) {
if (!bestDay || count > bestDay.count || (count === bestDay.count && date < bestDay.date)) {
bestDay = { date, count };
}
}
const totals = {
completions: pointCompletions.length,
players: firstPlayDates(pointCompletions).size,
yesterdayCompletions: yesterday.length,
yesterdayAvgGuesses: averageGuesses(yesterday),
bestDay
};
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 a snapshot. `replace=false` never overwrites an existing (immutable)
* snapshot; the caller re-reads instead.
*
* A snapshot is only persisted once its global data window has closed: an
* in-progress day is computed as a point-in-time view and never written, so
* partial early-morning data can't become the permanent record.
*/
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 closed = isDayClosed(asOfDate);
const data = await loadStatsData(cutoff, database, {
completedBefore: closed ? undefined : new Date()
});
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 && closed) {
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.
*
* Closed days are persisted and reused (race-safe via `INSERT OR IGNORE`),
* while an in-progress day is always computed on demand and never written.
*/
export async function ensureSnapshot(
date: string,
opts: { matureThrough?: string } = {},
database: StatsDb = defaultDb
): Promise<StoredSnapshot> {
if (isDayClosed(date)) {
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 the final view.
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;
}
// Open day: compute a live point-in-time view without persisting it.
return (
await buildSnapshot(
date,
{ kind: 'lazy', matureThrough: opts.matureThrough, persist: false },
database
)
).snapshot;
}
/** 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;
}
+293
View File
@@ -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
View File
@@ -135,7 +135,7 @@ function getGreekChapter(bookNumber: number, chapterNumber: number): ChapterData
/** /**
* Get the number of verses in a specific chapter * Get the number of verses in a specific chapter
*/ */
function getVerseCount(bookNumber: number, chapterNumber: number): number { export function getVerseCount(bookNumber: number, chapterNumber: number): number {
const chapter = getChapter(bookNumber, chapterNumber); const chapter = getChapter(bookNumber, chapterNumber);
return chapter ? chapter.verse.length : 0; return chapter ? chapter.verse.length : 0;
} }
@@ -151,7 +151,7 @@ function getGreekVerseCount(bookNumber: number, chapterNumber: number): number {
/** /**
* Get the number of chapters in a specific book * Get the number of chapters in a specific book
*/ */
function getChapterCount(bookNumber: number): number { export function getChapterCount(bookNumber: number): number {
const book = getBook(bookNumber); const book = getBook(bookNumber);
return book ? book.chapter.length : 0; return book ? book.chapter.length : 0;
} }
@@ -439,3 +439,127 @@ export function getAllNKJVVerses(): Array<{ text: string; book: string; chapter:
} }
return verses; return verses;
} }
/**
* Resolve a 1-based book-wide verse index to { chapter, verse }.
* The index is contiguous across all chapters of the book.
*/
function resolveBookVerseIndex(
bookNumber: number,
index: number
): { chapter: number; verse: number } | null {
const chapterCount = getChapterCount(bookNumber);
let cumulative = 0;
for (let c = 1; c <= chapterCount; c++) {
const vCount = getVerseCount(bookNumber, c);
if (index <= cumulative + vCount) {
return { chapter: c, verse: index - cumulative };
}
cumulative += vCount;
}
return null;
}
export interface ComposedWindow {
bookId: string;
bookName: string;
verses: string[]; // the 3 verse texts in order
startChapter: number;
startVerse: number;
endChapter: number;
endVerse: number;
selectedVerse: number; // the anchor verse number within its chapter
}
/**
* Compose a 3-verse window anchored on (book, chapter, verse), constrained to
* a single book. Default window is [v-2, v-1, v] in book-wide verse indices;
* if that would run before the book's start (v is 1 or 2), fall forward to
* [v, v+1, v+2]. Cross-chapter windows within the same book are allowed.
*
* Mirrors the spec's "Verse Windowing" section.
*/
export function composeVerseWindow(
bookNumber: number,
chapter: number,
verse: number
): ComposedWindow | null {
const book = getBookByNumber(bookNumber);
if (!book) return null;
const chapterCount = getChapterCount(bookNumber);
if (chapter < 1 || chapter > chapterCount) return null;
const verseCount = getVerseCount(bookNumber, chapter);
if (verse < 1 || verse > verseCount) return null;
// Book-wide 1-based index of the selected verse.
let selectedIndex = 0;
for (let c = 1; c < chapter; c++) selectedIndex += getVerseCount(bookNumber, c);
selectedIndex += verse;
let startIdx: number;
let endIdx: number;
if (selectedIndex - 2 < 1) {
// Fall forward: [v, v+1, v+2]
startIdx = selectedIndex;
endIdx = selectedIndex + 2;
} else {
startIdx = selectedIndex - 2;
endIdx = selectedIndex;
}
const start = resolveBookVerseIndex(bookNumber, startIdx);
const end = resolveBookVerseIndex(bookNumber, endIdx);
if (!start || !end) return null;
// Extract the contiguous window, crossing chapter boundaries within the book as needed.
const verses: string[] = [];
let curChapter = start.chapter;
let curVerse = start.verse;
while (verses.length < 3 && curChapter <= chapterCount) {
const vCount = getVerseCount(bookNumber, curChapter);
while (curVerse <= vCount && verses.length < 3) {
const ex = extractVerses(bookNumber, curChapter, curVerse, 1);
if (ex.length === 1) {
verses.push(ex[0]);
} else {
break;
}
curVerse++;
}
curChapter++;
curVerse = 1;
}
if (verses.length < 3) return null;
return {
bookId: book.id,
bookName: book.name,
verses,
startChapter: start.chapter,
startVerse: start.verse,
endChapter: end.chapter,
endVerse: end.verse,
selectedVerse: verse
};
}
/**
* Format a reference for a window that may span chapters within a book.
* Same-chapter: "Genesis 1:1-3" (hyphen)
* Cross-chapter: "Genesis 1:31–2:1" (en-dash)
*/
export function formatWindowReference(
bookName: string,
startChapter: number,
startVerse: number,
endChapter: number,
endVerse: number
): string {
if (startChapter === endChapter) {
if (startVerse === endVerse) return `${bookName} ${startChapter}:${startVerse}`;
return `${bookName} ${startChapter}:${startVerse}-${endVerse}`;
}
return `${bookName} ${startChapter}:${startVerse}\u2013${endChapter}:${endVerse}`;
}
+3 -23
View File
@@ -1,18 +1,6 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game"; import { evaluateGuess, type Guess } from "$lib/utils/game";
import { getOrCreateAnonymousId } from "$lib/utils/analytics";
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
// Used to attribute stats to a player who hasn't signed in.
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
// Reactive store that keeps in-memory game state in sync with localStorage. // Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity // Accepts getter functions (rather than plain values) so Svelte's reactivity
@@ -53,15 +41,7 @@ export function createGamePersistence(
if ((window as any).umami) { if ((window as any).umami) {
(window as any).umami.identify(anonymousId); (window as any).umami.identify(anonymousId);
} }
if (user) {
const nameParts = [user.firstName, user.lastName].filter(Boolean);
(window as any).rybbit?.identify(user.id, {
...(nameParts.length ? { name: nameParts.join(' ') } : {}),
...(user.email ? { email: user.email } : {}),
});
} else {
(window as any).rybbit?.identify(anonymousId);
}
const date = getDate(); const date = getDate();
const reference = getReference(); const reference = getReference();
+254
View File
@@ -0,0 +1,254 @@
/**
* Shared types for the private statistics dashboard (see statistics-plan.md).
*
* Keep this module dependency-free (no DB / Svelte imports) so it can be used
* from server code, Svelte components, and unit tests alike.
*/
// ── Settings ─────────────────────────────────────────────────────────────────
/**
* Dashboard thresholds. Keys are snake_case to match both the
* `dashboard_settings` table and the `STATS_*` environment variables.
*/
export const SETTINGS_DEFAULTS = {
core_plays_per_week: 4,
churn_days: 30,
resurrect_gap_days: 30,
at_risk_days: 8,
active_window_days: 180,
d1_target: 20,
return7_target: 25,
completion_target: 70,
first_try_min: 15,
first_try_max: 35,
share_target: 25,
anomaly_mad_threshold: 2
} as const;
export type SettingsKey = keyof typeof SETTINGS_DEFAULTS;
export type StatsSettings = Record<SettingsKey, number>;
// ── Plain rows (pure metric inputs) ──────────────────────────────────────────
/** The subset of a `daily_completions` row the metrics need. */
export interface CompletionRow {
anonymousId: string;
date: string;
guessCount: number;
guesses?: string | null;
completedAt: Date | number;
}
export interface VerseRow {
date: string;
bookId: string;
}
export interface UserRow {
id: string;
createdAt: Date | null;
}
// ── Player state & segments ──────────────────────────────────────────────────
/** Compact per-player state frozen as of a snapshot date. */
export interface PlayerState {
id: string;
firstDate: string;
lastDate: string;
totalPlays: number;
plays7: number;
plays30: number;
streak: number;
}
export interface SegmentSets {
new: string[];
core: string[];
returning: string[];
resurrected: string[];
atRisk: string[];
churned: string[];
active: string[];
}
export interface SegmentCounts {
new: number;
core: number;
returning: number;
resurrected: number;
atRisk: number;
churned: number;
active: number;
total: number;
}
// ── Signals ──────────────────────────────────────────────────────────────────
export type SignalSeverity = 'good' | 'info' | 'warn' | 'bad';
export interface Signal {
id: string;
severity: SignalSeverity;
title: string;
detail: string;
action?: string;
metric?: string;
value?: number | null;
target?: number | null;
}
// ── Payload ──────────────────────────────────────────────────────────────────
export interface DailyPoint {
date: string;
completions: number;
newPlayers: number;
started: number;
}
export interface SeriesPoint {
date: string;
value: number | null;
}
export interface CohortRow {
weekStart: string;
size: number;
/** Index 0 = week 0 (the cohort week); index k = k weeks later. */
counts: number[];
rates: (number | null)[];
}
export interface HistogramBin {
label: string;
value: number;
}
export interface BookStat {
bookId: string;
days: number;
plays: number;
firstTryPct: number;
avgGuesses: number;
d1Return: number | null;
}
export interface Activity {
dau: number;
wau: number;
mau: number;
core: number;
returningShare: number | null;
newPlayers: number;
resurrected: number;
churned: number;
netGrowth: number;
/** Core count from the snapshot ~7 days earlier (same thresholds), if available. */
corePrevious: number | null;
/** Same-metric values for the previous day (weekday-aligned comparison). */
previous: {
dau: number;
wau: number;
mau: number;
core: number;
} | null;
}
export interface Kpis {
d1Return: number | null;
return7: number | null;
overallReturnRate: number | null;
completionRate: number | null;
shareRate: number | null;
firstTryRate: number | null;
avgGuesses: number | null;
}
export interface RetentionData {
cohorts: CohortRow[];
curves: {
d1: SeriesPoint[];
d7: SeriesPoint[];
d30: SeriesPoint[];
};
timeToSecondPlay: HistogramBin[];
segmentMix: {
date: string;
new: number;
core: number;
returning: number;
resurrected: number;
atRisk: number;
churned: number;
}[];
}
export interface VerseMixData {
coverage: BookStat[];
testamentMix: { date: string; old: number; new: number }[];
entropy: SeriesPoint[];
repeatIntervals: HistogramBin[];
firstTryByBook: BookStat[];
retentionByBook: BookStat[];
retentionByGuessBucket: { bucket: string; d1: number | null; plays: number }[];
achievability: HistogramBin[];
}
export interface HabitData {
streakSurvival: { length: number; probability: number }[];
streakLengths: HistogramBin[];
calendar: { date: string; count: number }[];
weekdayHour: number[][];
}
export interface SnapshotPayload {
asOfDate: string;
activity: Activity;
dailySeries: DailyPoint[];
kpis: Kpis;
retention: RetentionData;
verseMix: VerseMixData;
habit: HabitData;
growth: {
lifecycleFunnel: { label: string; players: number }[];
signups: { date: string; count: number }[];
};
/** Safe, all-time aggregates for the public community widget. */
totals: {
completions: number;
players: number;
yesterdayCompletions: number;
yesterdayAvgGuesses: number | null;
/** Highest single-day finish count across all time (earliest date wins ties). */
bestDay: { date: string; count: number } | null;
};
signals: Signal[];
/** Metadata about data availability, so the UI can show honest em-dashes. */
meta: {
hasStartedData: boolean;
hasShareData: boolean;
firstEventDate: string | null;
generatedAt?: number;
/** Payload shape version; older snapshots are rebuilt lazily. */
schemaVersion?: number;
};
}
// ── Analytics events ─────────────────────────────────────────────────────────
/** Appendix B — allowlisted event names. */
export const EVENT_NAMES = [
'verse_served',
'share',
'share_copy',
'first_visit',
'donate_click',
'discord_click'
] as const;
export type EventName = (typeof EVENT_NAMES)[number];
export const MAX_METADATA_BYTES = 2048;
+36
View File
@@ -0,0 +1,36 @@
/** Pure first-touch acquisition parsing (no browser/SvelteKit imports). */
export interface Acquisition {
referrer: string | null;
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
}
/**
* Extract first-touch acquisition data from a query string and referrer.
* Same-origin referrers are ignored when `location` is available.
*/
export function parseAcquisition(search: string, referrer: string): Acquisition {
const params = new URLSearchParams(search);
const clean = (v: string | null): string | null => {
if (v === null) return null;
const trimmed = v.trim().slice(0, 256);
return trimmed.length > 0 ? trimmed : null;
};
let ref = referrer || null;
if (ref) {
try {
const refUrl = new URL(ref);
if (typeof location !== 'undefined' && refUrl.origin === location.origin) ref = null;
} catch {
/* keep raw referrer */
}
}
return {
referrer: clean(ref),
utm_source: clean(params.get('utm_source') ?? params.get('ref')),
utm_medium: clean(params.get('utm_medium')),
utm_campaign: clean(params.get('utm_campaign'))
};
}
+65
View File
@@ -0,0 +1,65 @@
import { browser } from '$app/environment';
import { generateUUID } from './game';
import { parseAcquisition, type Acquisition } from './acquisition';
import type { EventName } from '$lib/types/stats';
export { parseAcquisition };
export type { Acquisition };
const ANON_KEY = 'bibdle-anonymous-id';
const FIRST_VISIT_KEY = 'bibdle-first-visit-sent';
/** Stable pseudonymous ID for this browser, created on first use. */
export function getOrCreateAnonymousId(): string {
if (!browser) return '';
let id = localStorage.getItem(ANON_KEY);
if (!id) {
id = generateUUID();
localStorage.setItem(ANON_KEY, id);
}
return id;
}
/**
* Fire-and-forget event. Never throws and never blocks the UI.
* `date` should be the game date for date-scoped events (e.g. verse_served).
*/
export function track(
name: EventName,
metadata?: Record<string, unknown> | null,
date?: string | null
): void {
if (!browser) return;
try {
const body = JSON.stringify({
name,
anonymousId: getOrCreateAnonymousId(),
date: date ?? null,
metadata: metadata ?? null
});
void fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true
}).catch(() => {});
} catch {
/* ignore */
}
}
/**
* Records a single `first_visit` event per browser with referrer / UTM data.
* Call once on mount (root layout).
*/
export function captureFirstVisit(): void {
if (!browser) return;
try {
if (localStorage.getItem(FIRST_VISIT_KEY) === 'true') return;
const acquisition = parseAcquisition(location.search, document.referrer ?? '');
track('first_visit', { ...acquisition });
localStorage.setItem(FIRST_VISIT_KEY, 'true');
} catch {
/* ignore */
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Quantile-bucketed color scale for calendar/heatmap charts.
*
* A naive `count / max` alpha collapses most cells into the same pale shade when
* a single day is an outlier. Bucketing by quantiles of the non-zero values
* (like GitHub's contribution graph) keeps typical variation visible and never
* changes when a new all-time-high day appears.
*/
export interface CalendarBucket {
/** Lowest count in this bucket. */
min: number;
/** Highest count in this bucket. */
max: number;
/** Alpha (0..1) applied to the base color. */
alpha: number;
/** How many non-zero days fall in this bucket. */
days: number;
}
export interface CalendarScale {
min: number;
max: number;
/** Non-empty intensity buckets, low → high. Zero is rendered separately. */
buckets: CalendarBucket[];
/** Alpha for a raw count (0 for a zero/empty day). */
alphaFor: (count: number) => number;
}
/** Linear-interpolated quantile over an already-sorted ascending array. */
export function quantile(sorted: number[], q: number): number {
if (sorted.length === 0) return 0;
if (sorted.length === 1) return sorted[0];
const pos = (sorted.length - 1) * q;
const base = Math.floor(pos);
const rest = pos - base;
const next = sorted[base + 1];
return next === undefined ? sorted[base] : sorted[base] + rest * (next - sorted[base]);
}
/**
* Build a quantile scale from a list of daily counts.
*
* The non-zero counts are split into `steps` intensity buckets at evenly spaced
* quantiles; duplicate-heavy data is tolerated because empty buckets are dropped.
*/
export function buildCalendarScale(counts: number[], steps = 4): CalendarScale {
const nonzero = counts.filter((c) => c > 0).sort((a, b) => a - b);
const min = counts.length ? Math.min(...counts) : 0;
const max = counts.length ? Math.max(...counts) : 0;
if (nonzero.length === 0) {
return { min, max, buckets: [], alphaFor: () => 0 };
}
const alphas =
steps <= 1
? [0.9]
: Array.from({ length: steps }, (_, i) => 0.28 + (0.72 * i) / (steps - 1));
const thresholds: number[] = [];
for (let i = 1; i < steps; i++) thresholds.push(quantile(nonzero, i / steps));
const bucketIndex = (count: number): number => {
if (count <= 0) return -1;
let index = 0;
for (const threshold of thresholds) {
if (count > threshold) index++;
}
return index;
};
const buckets: CalendarBucket[] = alphas.map((alpha) => ({
alpha,
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
days: 0
}));
for (const count of nonzero) {
const bucket = buckets[bucketIndex(count)];
bucket.min = Math.min(bucket.min, count);
bucket.max = Math.max(bucket.max, count);
bucket.days++;
}
return {
min,
max,
buckets: buckets.filter((bucket) => bucket.days > 0),
alphaFor: (count: number) => {
const index = bucketIndex(count);
return index < 0 ? 0 : alphas[index];
}
};
}
+43
View File
@@ -0,0 +1,43 @@
/** Small formatting helpers shared by the stats dashboard components. */
export function numOrDash(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : n.toLocaleString('en-US');
}
export function pctOrDash(n: number | null | undefined): string {
return n === null || n === undefined ? '—' : `${n}%`;
}
export function signed(n: number | null | undefined): string {
if (n === null || n === undefined) return '—';
return `${n >= 0 ? '+' : ''}${n}`;
}
export function formatDateLabel(dateStr: string): string {
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC'
});
}
export function weekdayName(dateStr: string): string {
return new Date(dateStr + 'T00:00:00Z').toLocaleDateString('en-US', {
weekday: 'short',
timeZone: 'UTC'
});
}
/** Compare a value to a previous value, returning signed delta and direction. */
export function delta(
value: number | null | undefined,
previous: number | null | undefined
): { text: string; direction: 'up' | 'down' | 'flat' } {
if (value === null || value === undefined || previous === null || previous === undefined) {
return { text: '—', direction: 'flat' };
}
const diff = value - previous;
if (diff === 0) return { text: 'no change', direction: 'flat' };
return { text: signed(diff), direction: diff > 0 ? 'up' : 'down' };
}
+5 -7
View File
@@ -6,6 +6,7 @@
import favicon from "$lib/assets/favicon.ico"; import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte"; import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte"; import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { captureFirstVisit } from "$lib/utils/analytics";
let isDev = $state(false); let isDev = $state(false);
@@ -14,6 +15,9 @@
window.location.host === "localhost:5173" || window.location.host === "localhost:5173" ||
window.location.host === "test.bibdle.com"; window.location.host === "test.bibdle.com";
// Instrumentation E: capture first-touch referrer / UTM once per browser.
captureFirstVisit();
// Inject analytics script // Inject analytics script
const script = document.createElement("script"); const script = document.createElement("script");
script.defer = true; script.defer = true;
@@ -31,13 +35,7 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<link <meta name="description" content="A Wordle-inspired daily Bible game" />
rel="alternate"
type="application/rss+xml"
title="Bibdle RSS Feed"
href="/feed.xml"
/>
<meta name="description" content="A daily Bible game" />
</svelte:head> </svelte:head>
<div <div
+13
View File
@@ -18,6 +18,18 @@ export const actions: Actions = {
const anonymousId = formData.get('anonymousId') as string; const anonymousId = formData.get('anonymousId') as string;
const date = formData.get('date') as string; const date = formData.get('date') as string;
const guessCount = parseInt(formData.get('guessCount') as string, 10); const guessCount = parseInt(formData.get('guessCount') as string, 10);
// Optional JSON array of book IDs — mirrors the JSON API so all guest
// completions persist guess sequences (instrumentation A).
const guessesRaw = formData.get('guesses') as string | null;
let guesses: string[] | null = null;
if (guessesRaw) {
try {
const parsed = JSON.parse(guessesRaw);
if (Array.isArray(parsed)) guesses = parsed.map(String);
} catch {
guesses = null;
}
}
// Validation // Validation
if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) { if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) {
@@ -33,6 +45,7 @@ export const actions: Actions = {
anonymousId, anonymousId,
date, date,
guessCount, guessCount,
guesses: guesses && guesses.length > 0 ? JSON.stringify(guesses) : null,
completedAt, completedAt,
}); });
} catch (err: any) { } catch (err: any) {
+1 -24
View File
@@ -146,7 +146,6 @@
(window as any).umami (window as any).umami
) { ) {
(window as any).umami.track("First guess"); (window as any).umami.track("First guess");
(window as any).rybbit?.event("First guess");
localStorage.setItem(key, "true"); localStorage.setItem(key, "true");
} }
} }
@@ -287,9 +286,6 @@
(window as any).umami.track("Guessed correctly", { (window as any).umami.track("Guessed correctly", {
totalGuesses: persistence.guesses.length, totalGuesses: persistence.guesses.length,
}); });
(window as any).rybbit?.event("Guessed correctly", {
totalGuesses: persistence.guesses.length,
});
} }
}); });
@@ -436,6 +432,7 @@
{streakPercentile} {streakPercentile}
isLoggedIn={!!user} isLoggedIn={!!user}
anonymousId={persistence.anonymousId} anonymousId={persistence.anonymousId}
localDate={new Date().toLocaleDateString("en-CA")}
/> />
</div> </div>
{/if} {/if}
@@ -452,26 +449,6 @@
<hr <hr
class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600" class="animate-fade-in-up animate-delay-800 border-gray-300 dark:border-gray-600"
/> />
<div class="animate-fade-in-up animate-delay-800">
<a
href="https://discord.gg/yWQXbGK8SD"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-2 w-full px-5 py-2.5 bg-[#5865F2] hover:bg-[#4752C4] text-white font-semibold rounded-lg shadow-md transition-colors duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 127.14 96.36"
class="w-5 h-5 fill-white"
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>
Join the BIBDLE Discord!
</a>
</div>
<div class="animate-fade-in-up animate-delay-800"> <div class="animate-fade-in-up animate-delay-800">
<Credits /> <Credits />
</div> </div>
+3 -1
View File
@@ -1,15 +1,17 @@
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
import { getOrCreateAnonymousId } from '$lib/utils/analytics';
// Disable SSR so the load function runs on the client with the correct local date // Disable SSR so the load function runs on the client with the correct local date
export const ssr = false; export const ssr = false;
export const load: PageLoad = async ({ fetch, data }) => { export const load: PageLoad = async ({ fetch, data }) => {
const localDate = new Date().toLocaleDateString("en-CA"); const localDate = new Date().toLocaleDateString("en-CA");
const anonymousId = getOrCreateAnonymousId();
const res = await fetch('/api/daily-verse', { const res = await fetch('/api/daily-verse', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }), body: JSON.stringify({ date: localDate, anonymousId }),
}); });
const result = await res.json(); const result = await res.json();
+89
View File
@@ -0,0 +1,89 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { isAdmin } from '$lib/server/admin';
import { addDays, isValidDate } from '$lib/server/stats/dates';
import {
buildSnapshot,
ensureSnapshot,
isSnapshotStale
} from '$lib/server/stats/snapshot';
import { deriveSegments, countSegments } from '$lib/server/stats/segments';
import { getSettings, updateSettings, hashSettings } from '$lib/server/stats/settings';
import { sortSignals, verdictLine } from '$lib/server/stats/signals';
import { SETTINGS_DEFAULTS, type SettingsKey, type StatsSettings } from '$lib/types/stats';
function todayEst(): string {
return new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (!isAdmin(locals.user?.email)) {
throw error(404, 'Not found');
}
const today = todayEst();
const yesterday = addDays(today, -1);
const dateParam = url.searchParams.get('date');
let date = dateParam && isValidDate(dateParam) ? dateParam : yesterday;
if (date > today) date = today;
const matured = url.searchParams.get('mature') === '1';
const settings = await getSettings();
// Normal loads read (or lazily build) the immutable snapshot. The mature
// toggle is the only path allowed to compute on demand from raw data.
const snapshot = matured
? (
await buildSnapshot(date, {
kind: 'lazy',
matureThrough: today,
persist: false
})
).snapshot
: await ensureSnapshot(date);
const segments = deriveSegments(snapshot.playerState, date, settings);
const stale =
!matured && snapshot.configVersion !== hashSettings(settings);
const topSignal = sortSignals(snapshot.payload.signals)[0] ?? null;
return {
date,
today,
generatedAt: snapshot.generatedAt,
buildKind: snapshot.buildKind,
payload: snapshot.payload,
settings,
segmentCounts: countSegments(segments),
verdict: verdictLine(snapshot.payload.signals),
topSignal,
stale,
matured,
userEmail: locals.user?.email ?? null
};
};
export const actions: Actions = {
updateSettings: async ({ request, url, locals }) => {
if (!isAdmin(locals.user?.email)) {
return fail(403, { error: 'Forbidden' });
}
const form = await request.formData();
const partial: Partial<StatsSettings> = {};
for (const key of Object.keys(SETTINGS_DEFAULTS) as SettingsKey[]) {
const raw = form.get(key);
if (typeof raw === 'string' && raw !== '') {
const value = Number(raw);
if (Number.isFinite(value)) partial[key] = value;
}
}
try {
await updateSettings(partial);
} catch (err) {
return fail(500, { error: String(err) });
}
// Segments are re-derived at render time, so no rebuild is needed.
const date = url.searchParams.get('date');
throw redirect(303, `/admin/stats${date ? `?date=${date}` : ''}`);
}
};
+75
View File
@@ -0,0 +1,75 @@
<script lang="ts">
import type { PageData } from './$types';
import ConsoleChrome from '$lib/components/stats/ConsoleChrome.svelte';
import Pulse from '$lib/components/stats/Pulse.svelte';
import GrowthLoop from '$lib/components/stats/GrowthLoop.svelte';
import RetentionLab from '$lib/components/stats/RetentionLab.svelte';
import VerseMixLab from '$lib/components/stats/VerseMixLab.svelte';
import HabitLab from '$lib/components/stats/HabitLab.svelte';
import SignalsFeed from '$lib/components/stats/SignalsFeed.svelte';
import FeatureImpact from '$lib/components/stats/FeatureImpact.svelte';
import Glossary from '$lib/components/stats/Glossary.svelte';
import Container from '$lib/components/Container.svelte';
import { numOrDash } from '$lib/utils/stats-format';
let { data }: { data: PageData } = $props();
const segments = $derived(data.segmentCounts);
</script>
<svelte:head>
<title>Growth Console | Bibdle</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div class="min-h-screen bg-gray-950 p-4 md:p-6">
<div class="mx-auto flex max-w-6xl flex-col gap-8 pb-16">
<ConsoleChrome
date={data.date}
today={data.today}
generatedAt={data.generatedAt}
buildKind={data.buildKind}
matured={data.matured}
stale={data.stale}
settings={data.settings}
/>
<Pulse
payload={data.payload}
settings={data.settings}
verdict={data.verdict}
topSignal={data.topSignal}
/>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">Segment mix (as of this date)</h2>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{#each [
{ label: 'New (7d)', value: segments.new },
{ label: 'Core', value: segments.core },
{ label: 'Returning', value: segments.returning },
{ label: 'Resurrected', value: segments.resurrected },
{ label: 'At risk', value: segments.atRisk },
{ label: 'Churned', value: segments.churned }
] as item (item.label)}
<Container class="p-3 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">{item.label}</p>
<p class="text-xl font-bold text-gray-100">{numOrDash(item.value)}</p>
</Container>
{/each}
</div>
<p class="text-xs text-gray-500">
Segments are re-derived from stored per-player state, so changing thresholds in Settings
updates every date instantly. Total tracked players: {numOrDash(segments.total)}.
</p>
</section>
<GrowthLoop payload={data.payload} />
<RetentionLab payload={data.payload} settings={data.settings} />
<VerseMixLab payload={data.payload} />
<HabitLab payload={data.payload} />
<SignalsFeed signals={data.payload.signals} />
<FeatureImpact />
<Glossary />
</div>
</div>
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { isAdmin } from '$lib/server/admin';
import { isValidDate } from '$lib/server/stats/dates';
import { buildSnapshot } from '$lib/server/stats/snapshot';
/** Admin-only manual snapshot (re)build, e.g. after a settings change. */
export const POST: RequestHandler = async ({ request, locals }) => {
if (!isAdmin(locals.user?.email)) {
return json({ error: 'Forbidden' }, { status: 403 });
}
let body: { date?: string; mature?: boolean } = {};
try {
body = await request.json();
} catch {
/* fall through to validation */
}
const date = body.date;
if (!date || !isValidDate(date)) {
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
}
const { snapshot } = await buildSnapshot(date, {
kind: 'reconstructed',
replace: true
});
return json({ ok: true, date, buildKind: snapshot.buildKind, generatedAt: snapshot.generatedAt });
};
+26
View File
@@ -0,0 +1,26 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { bibleBooks } from '$lib/server/bible';
import { getChapterCount, getVerseCount } from '$lib/server/xml-bible';
// Static Bible structure for the cascading Book → Chapter → Verse selectors.
// ~1189 chapter entries total; safe to cache indefinitely.
let cached: { bookId: string; chapters: number[] }[] | null = null;
function build(): { bookId: string; chapters: number[] }[] {
if (cached) return cached;
const out = bibleBooks.map((book) => {
const chapterCount = getChapterCount(book.order);
const chapters: number[] = new Array(chapterCount);
for (let c = 1; c <= chapterCount; c++) {
chapters[c - 1] = getVerseCount(book.order, c);
}
return { bookId: book.id, chapters };
});
cached = out;
return out;
}
export const GET: RequestHandler = async () => {
return json(build());
};
@@ -0,0 +1,30 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { latestClosedDate } from '$lib/server/stats/dates';
import { buildSnapshot } from '$lib/server/stats/snapshot';
import { sendDigest } from '$lib/server/stats/digest';
/**
* Daily snapshot build trigger. Protected by `Authorization: Bearer $CRON_SECRET`
* (same pattern as `/api/send-daily-verse`). Builds the most recent date whose
* global data window has closed, then sends the digest.
*/
export const POST: RequestHandler = async ({ request }) => {
const cronSecret = Bun.env.CRON_SECRET;
const authHeader = request.headers.get('Authorization');
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const date = latestClosedDate();
const { snapshot } = await buildSnapshot(date, {
kind: 'scheduled',
replace: true
});
// Digest failure must never fail the build.
await sendDigest(snapshot.payload);
return json({ ok: true, date, buildKind: 'scheduled' });
};
+15 -2
View File
@@ -2,10 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { getVerseForDate } from '$lib/server/daily-verse'; import { getVerseForDate } from '$lib/server/daily-verse';
import { getBookById } from '$lib/server/bible'; import { getBookById } from '$lib/server/bible';
import { recordEvent } from '$lib/server/stats/events';
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json(); const body = await request.json();
const { date } = body; const { date, anonymousId } = body;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 }); return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
@@ -16,6 +17,18 @@ export const POST: RequestHandler = async ({ request }) => {
const dailyVerse = await getVerseForDate(dateStr); const dailyVerse = await getVerseForDate(dateStr);
const correctBook = getBookById(dailyVerse.bookId) ?? null; const correctBook = getBookById(dailyVerse.bookId) ?? null;
// Instrumentation B: a player "started" the puzzle for this date. The unique
// partial index dedupes reloads, so this is safe to call on every load.
// Never let analytics failures affect the response.
// Prefer the signed-in user's ID so the started→finished funnel matches
// completions (which are keyed to the user ID for logged-in players).
const playerId =
locals.user?.id ??
(typeof anonymousId === 'string' && anonymousId.length > 0 ? anonymousId : null);
if (playerId) {
void recordEvent({ name: 'verse_served', anonymousId: playerId, date: dateStr });
}
return json({ return json({
dailyVerse, dailyVerse,
correctBookId: dailyVerse.bookId, correctBookId: dailyVerse.bookId,
@@ -0,0 +1,189 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses, verseSubmissions, user } from '$lib/server/db/schema';
import { sql } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import { bibleBooks } from '$lib/types/bible';
import {
getChapterCount,
getVerseCount,
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
const DEV_HOSTS = ['localhost:5173', 'test.bibdle.com'];
const MAX_SCAN_RETRIES = 5;
// Dev-only: insert a verse_submissions row (from a random existing account) +
// its corresponding daily_verses row, scheduled on the earliest valid future
// date per the spec's scheduling rules. Lets you populate /scheduled-verses to
// verify the admin view without going through the (Step 5+) submit API.
export const POST: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
// Pick a random existing user.
const users = await db.select().from(user);
if (users.length === 0) {
return json(
{ error: 'No users exist yet. Sign up at least one account first.' },
{ status: 400 }
);
}
const submitter = users[Math.floor(Math.random() * users.length)];
// Pick a random valid (book, chapter, verse).
const book = bibleBooks[Math.floor(Math.random() * bibleBooks.length)];
const chapterCount = getChapterCount(book.order);
const chapter = Math.floor(Math.random() * chapterCount) + 1;
const verseCount = getVerseCount(book.order, chapter);
const verse = Math.floor(Math.random() * verseCount) + 1;
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
return json({ error: 'Failed to compose verse window' }, { status: 500 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
const verseText = window.verses.join(' ');
const todayUtc = new Date().toISOString().slice(0, 10);
const tomorrowMs = new Date(todayUtc + 'T00:00:00Z').getTime() + 86400000;
function addDays(ms: number, n: number): string {
return new Date(ms + n * 86400000).toISOString().slice(0, 10);
}
// Fetch all future daily_verses into memory (date → bookId, date → reference).
const future = await db.select().from(dailyVerses).where(sql`date > ${todayUtc}`);
const byDate = new Map<string, { bookId: string; reference: string }>();
for (const r of future) byDate.set(r.date, { bookId: r.bookId, reference: r.reference });
// For the 60-day repeat rule, also need a bounded historical lookup per
// candidate date. Build a set of all existing references globally to do a
// cheap pre-check, then a precise range query when we settle on a date.
const allRows = await db.select().from(dailyVerses);
const refDates = new Map<string, string[]>(); // reference → list of dates
for (const r of allRows) {
const arr = refDates.get(r.reference) ?? [];
arr.push(r.date);
refDates.set(r.reference, arr);
}
let scheduledDate: string | null = null;
for (let attempt = 0; attempt < MAX_SCAN_RETRIES && !scheduledDate; attempt++) {
// Walk day-by-day from tomorrow.
for (let offset = 0; offset < 10000; offset++) {
const D = addDays(tomorrowMs, offset);
// Rule 1: empty (no committed daily_verses row).
if (byDate.has(D)) continue;
// Rule 2: no back-to-back with prior neighbor.
const prev = byDate.get(addDays(tomorrowMs, offset - 1));
if (prev && prev.bookId === book.id) continue;
// Rule 3: no back-to-back with next committed neighbor.
const next = byDate.get(addDays(tomorrowMs, offset + 1));
if (next && next.bookId === book.id) continue;
// Rule 4: 60-day repeat distance — no same reference within ±60 days.
const sameRefDates = refDates.get(reference) ?? [];
const dMs = new Date(D + 'T00:00:00Z').getTime();
const tooClose = sameRefDates.some((d) => {
const diff = Math.abs(new Date(d + 'T00:00:00Z').getTime() - dMs);
return diff <= 60 * 86400000;
});
if (tooClose) continue;
scheduledDate = D;
break;
}
if (!scheduledDate) break; // exhausted retries
}
if (!scheduledDate) {
return json({ error: 'Could not find a valid future date' }, { status: 500 });
}
// Insert both rows in a transaction. On a unique conflict (concurrent
// submission grabbed the same date), bubble up so the caller can retry.
const now = Date.now();
const verseId = Bun.randomUUIDv7();
const submissionId = Bun.randomUUIDv7();
try {
db.transaction((tx) => {
tx.insert(dailyVerses)
.values({
id: verseId,
date: scheduledDate!,
bookId: book.id,
verseText,
reference,
createdAt: sql`${Math.floor(now / 1000)}`
})
.run();
tx.insert(verseSubmissions)
.values({
id: submissionId,
userId: submitter.id,
scheduledDate: scheduledDate!,
selectedBookId: book.id,
selectedChapter: chapter,
selectedVerse: verse,
submittedAt: now
})
.run();
});
} catch (err: any) {
if (err?.code === 'SQLITE_CONSTRAINT_UNIQUE' || err?.message?.includes('UNIQUE')) {
return json(
{ error: 'Date collision during seed; retry the request', detail: String(err?.message ?? err) },
{ status: 409 }
);
}
throw err;
}
return json({
ok: true,
submitter: { id: submitter.id, email: submitter.email },
selected: {
bookId: book.id,
bookName: book.name,
chapter,
verse
},
scheduledDate,
reference,
verseText
});
};
// Convenience: GET returns the current count of submissions + future scheduled
// verses, useful as a quick dev sanity check without mutating state.
export const GET: RequestHandler = async ({ request }) => {
const host = request.headers.get('host') ?? '';
if (!DEV_HOSTS.includes(host)) {
return json({ error: 'Not allowed in production' }, { status: 403 });
}
const subs = await db.select().from(verseSubmissions);
const future = await db
.select()
.from(dailyVerses)
.where(sql`date > ${new Date().toISOString().slice(0, 10)}`);
return json({
totalSubmissions: subs.length,
futureScheduledVerses: future.length
});
};
+52
View File
@@ -0,0 +1,52 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { isEventName, recordEvent, encodeMetadata } from '$lib/server/stats/events';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
/**
* Fire-and-forget client analytics sink.
*
* Body: `{ name, anonymousId?, date?, metadata? }`.
* `userId` is resolved server-side from the session — never trusted from the client.
* Always returns 204, even for invalid input, so gameplay is never blocked.
*/
export const POST: RequestHandler = async ({ request, locals }) => {
let body: any;
try {
body = await request.json();
} catch {
return new Response(null, { status: 204 });
}
const name = typeof body?.name === 'string' ? body.name : '';
if (!isEventName(name)) {
return new Response(null, { status: 204 });
}
const anonymousId =
typeof body?.anonymousId === 'string' && body.anonymousId.length > 0
? body.anonymousId.slice(0, 128)
: null;
const date =
typeof body?.date === 'string' && DATE_RE.test(body.date) ? body.date : null;
const metadata =
body?.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
? (body.metadata as Record<string, unknown>)
: null;
// Ensure metadata is always serializable/capped before it reaches the DB.
void encodeMetadata(metadata);
await recordEvent({
name,
anonymousId,
userId: locals.user?.id ?? null,
date,
metadata
});
return new Response(null, { status: 204 });
};
+89
View File
@@ -0,0 +1,89 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyCompletions } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import {
scheduleSubmission,
validateSelection,
getCooldownState
} from '$lib/server/verse-submission';
/**
* POST /api/submit-verse
*
* Accepts a user-chosen verse (bookId/chapter/verse) and reserves a concrete
* future date for it via the scheduling scan. Auth required, solved-today gate,
* 7-day rolling cooldown, and structural validation all enforced here.
*
* Body: { bookId, chapter, verse, localDate }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
// 1. Auth.
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = locals.user.id;
let body: any;
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { bookId, chapter, verse, localDate } = body ?? {};
// 2. Solved-today gate (engagement gate, not security).
if (typeof localDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(localDate)) {
return json({ error: 'A valid localDate (YYYY-MM-DD) is required' }, { status: 400 });
}
const [completion] = await db
.select({ id: dailyCompletions.id })
.from(dailyCompletions)
.where(
and(
eq(dailyCompletions.anonymousId, userId),
eq(dailyCompletions.date, localDate)
)
)
.limit(1);
if (!completion) {
return json({ error: "Solve today's puzzle first" }, { status: 403 });
}
// 3. Cooldown (rolling 7×24h, server UTC).
const cooldown = await getCooldownState(db, userId);
if (cooldown.onCooldown) {
return json(
{ error: 'Cooldown active', cooldownEndsAt: cooldown.cooldownEndsAt },
{ status: 429 }
);
}
// 4. Structural validation.
const validationError = validateSelection({ bookId, chapter, verse });
if (validationError) {
return json({ error: validationError }, { status: 400 });
}
// 5–7. Compute window + scheduling scan + transactional insert (with retry).
try {
const result = await scheduleSubmission(
db,
{ bookId, chapter, verse },
userId
);
return json(
{
scheduledDate: result.scheduledDate,
reference: result.reference,
windowText: result.windowText
},
{ status: 201 }
);
} catch (err) {
console.error('submit-verse failed:', err);
return json({ error: 'Failed to schedule submission' }, { status: 500 });
}
};
@@ -0,0 +1,69 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { verseSubmissions, dailyVerses } from '$lib/server/db/schema';
import { eq, desc, asc } from 'drizzle-orm';
import { getCooldownState, todayUtcStr } from '$lib/server/verse-submission';
/**
* GET /api/submit-verse/status?localDate=YYYY-MM-DD
*
* Returns the state needed to render the win-screen submit button:
* whether the user can submit, the active cooldown (if any), their most
* recent submission, and their not-yet-reached upcoming submissions.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = locals.user.id;
const cooldown = await getCooldownState(db, userId);
// Most recent submission (by submitted_at) — joined to daily_verses for the
// canonical reference/window text that will actually play on its day.
const [lastRow] = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
reference: dailyVerses.reference
})
.from(verseSubmissions)
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.where(eq(verseSubmissions.userId, userId))
.orderBy(desc(verseSubmissions.submittedAt))
.limit(1);
const lastSubmission = lastRow
? {
scheduledDate: lastRow.scheduledDate,
reference: lastRow.reference
}
: null;
// Upcoming = this user's submissions whose scheduled date hasn't been
// reached yet (server UTC today).
const today = todayUtcStr();
const upcomingRows = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
reference: dailyVerses.reference
})
.from(verseSubmissions)
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.where(eq(verseSubmissions.userId, userId))
.orderBy(asc(verseSubmissions.scheduledDate));
const upcoming = upcomingRows
.filter((r) => r.scheduledDate > today)
.map((r) => ({
scheduledDate: r.scheduledDate,
reference: r.reference
}));
return json({
canSubmit: !cooldown.onCooldown,
cooldownEndsAt: cooldown.cooldownEndsAt,
lastSubmission,
upcoming
});
};
+51
View File
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getBookById } from '$lib/server/bible';
import {
composeVerseWindow,
formatWindowReference
} from '$lib/server/xml-bible';
// Live 3-verse preview for the submission selector. Computes the fall-forward
// window anchored on the selected verse (spec: "Verse Windowing").
export const GET: RequestHandler = async ({ url }) => {
const bookId = url.searchParams.get('bookId');
const chapterParam = url.searchParams.get('chapter');
const verseParam = url.searchParams.get('verse');
if (!bookId || !chapterParam || !verseParam) {
return json({ error: 'bookId, chapter, and verse are required' }, { status: 400 });
}
const book = getBookById(bookId);
if (!book) {
return json({ error: 'Unknown bookId' }, { status: 400 });
}
const chapter = Number(chapterParam);
const verse = Number(verseParam);
if (!Number.isInteger(chapter) || !Number.isInteger(verse) || chapter < 1 || verse < 1) {
return json({ error: 'chapter and verse must be positive integers' }, { status: 400 });
}
const window = composeVerseWindow(book.order, chapter, verse);
if (!window) {
// chapter/verse out of range — caught by composeVerseWindow's bounds checks
return json({ error: 'chapter or verse out of range for this book' }, { status: 400 });
}
const reference = formatWindowReference(
window.bookName,
window.startChapter,
window.startVerse,
window.endChapter,
window.endVerse
);
return json({
windowVerses: window.verses,
reference,
bookId: window.bookId,
selectedVerse: window.selectedVerse
});
};
+1 -1
View File
@@ -131,7 +131,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
// Create session // Create session
const sessionToken = auth.generateSessionToken(); const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId); const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt); auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
redirect(302, '/'); redirect(302, '/');
}; };
+1 -1
View File
@@ -125,7 +125,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
// Create session // Create session
const sessionToken = auth.generateSessionToken(); const sessionToken = auth.generateSessionToken();
const session = await auth.createSession(sessionToken, userId); const session = await auth.createSession(sessionToken, userId);
auth.setSessionTokenCookie({ cookies } as any, sessionToken, session.expiresAt); auth.setSessionTokenCookie({ cookies }, sessionToken, session.expiresAt);
redirect(302, '/'); redirect(302, '/');
}; };
+69 -102
View File
@@ -1,133 +1,99 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema'; import { dailyVerses } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm'; import { desc, lte } from 'drizzle-orm';
import {
// Helper: Escape XML special characters FEED_ITEM_LIMIT,
function escapeXml(text: string): string { cdata,
return text escapeXml,
.replace(/&/g, '&amp;') feedFingerprint,
.replace(/</g, '&lt;') formatRFC822,
.replace(/>/g, '&gt;') formatReadableDate,
.replace(/"/g, '&quot;') formatVerseText,
.replace(/'/g, '&apos;'); latestModification,
} todayInTimeZone
} from '$lib/server/rss';
// Helper: Format YYYY-MM-DD to RFC 822 date string
function formatRFC822(dateStr: string): string {
// Parse date in America/New_York timezone (EST/EDT)
// Assuming midnight ET
const date = new Date(dateStr + 'T00:00:00-05:00');
return date.toUTCString().replace('GMT', 'EST');
}
// Helper: Format YYYY-MM-DD to readable date
function formatReadableDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
timeZone: 'America/New_York'
});
}
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
function formatVerseText(text: string): string {
let formatted = text;
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
const pairs: [string, string][] = [
['(', ')'],
['[', ']'],
['{', '}'],
['"', '"'],
["'", "'"],
['\u201C', '\u201D'], // " "
['\u2018', '\u2019'] // ' '
];
// Check if text starts with opening punctuation without closing
for (const [open, close] of pairs) {
if (formatted.startsWith(open) && !formatted.includes(close)) {
formatted += '...' + close;
break;
}
}
// Check if text ends with closing punctuation without opening
for (const [open, close] of pairs) {
if (formatted.endsWith(close) && !formatted.includes(open)) {
formatted = open + '...' + formatted;
break;
}
}
// Check if text contains unbalanced opening quotes (not at start) without closing
for (const [open, close] of pairs) {
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
if (openCount > closeCount) {
formatted += close;
break;
}
}
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
// Replace trailing punctuation with ellipsis
// Preserve closing quotes/brackets that may have been added
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
return formatted;
}
export const GET: RequestHandler = async ({ request }) => { export const GET: RequestHandler = async ({ request }) => {
try { try {
// Query last 30 verses, ordered by date descending const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
const FEED_URL = `${SITE_URL}/feed.xml`;
const today = todayInTimeZone();
// Only publish verses whose calendar day has arrived. Pre-scheduled
// future verses (from the submission scheduling scan) and any stray
// future-dated rows must never leak into the feed early.
const verses = await db const verses = await db
.select() .select()
.from(dailyVerses) .from(dailyVerses)
.where(lte(dailyVerses.date, today))
.orderBy(desc(dailyVerses.date)) .orderBy(desc(dailyVerses.date))
.limit(30); .limit(FEED_ITEM_LIMIT);
// Generate ETag based on latest verse date // Content-addressed ETag + Last-Modified let aggregators revalidate
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"'; // cheaply with If-None-Match / If-Modified-Since.
const etag = `"bibdle-feed-${feedFingerprint(verses)}"`;
const lastModifiedDate = latestModification(
verses,
new Date(`${today}T00:00:00Z`)
);
const lastModified = lastModifiedDate.toUTCString();
// Check if client has cached version const notModified =
if (request.headers.get('If-None-Match') === etag) { request.headers.get('If-None-Match') === etag ||
return new Response(null, { status: 304 }); (() => {
const since = request.headers.get('If-Modified-Since');
if (!since) return false;
const sinceMs = Date.parse(since);
return !Number.isNaN(sinceMs) && lastModifiedDate.getTime() <= sinceMs;
})();
if (notModified) {
return new Response(null, {
status: 304,
headers: {
ETag: etag,
'Last-Modified': lastModified,
'Cache-Control': 'public, max-age=3600, s-maxage=3600'
}
});
} }
// Get site URL from environment or use default
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
// Build RSS XML
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString(); const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
const items = verses const items = verses
.map( .map((verse) => {
(verse) => ` const body = formatVerseText(verse.verseText);
const escaped = escapeXml(body);
return `
<item> <item>
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title> <title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
<description>${escapeXml(formatVerseText(verse.verseText))}</description> <description>${escaped}</description>
<content:encoded>${cdata(`<p>${escaped}</p>`)}</content:encoded>
<link>${SITE_URL}</link> <link>${SITE_URL}</link>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid> <guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate> <pubDate>${formatRFC822(verse.date)}</pubDate>
</item>` </item>`;
) })
.join(''); .join('');
const xml = `<?xml version="1.0" encoding="UTF-8" ?> const xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0"> <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel> <channel>
<title>Bibdle</title> <title>Bibdle</title>
<link>${SITE_URL}</link> <link>${SITE_URL}</link>
<description>A daily Bible game</description> <description>A daily Bible verse guessing game</description>
<language>en-us</language> <language>en-us</language>
<lastBuildDate>${lastBuildDate}</lastBuildDate> <lastBuildDate>${lastBuildDate}</lastBuildDate>
<ttl>720</ttl>${items} <pubDate>${lastBuildDate}</pubDate>
<generator>SvelteKit</generator>
<ttl>720</ttl>
<atom:link href="${escapeXml(FEED_URL)}" rel="self" type="application/rss+xml" />
<image>
<url>${SITE_URL}/favicon.ico</url>
<title>Bibdle</title>
<link>${SITE_URL}</link>
</image>${items}
</channel> </channel>
</rss>`; </rss>`;
@@ -135,11 +101,12 @@ export const GET: RequestHandler = async ({ request }) => {
headers: { headers: {
'Content-Type': 'application/rss+xml; charset=utf-8', 'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600', 'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag ETag: etag,
'Last-Modified': lastModified
} }
}); });
} catch (error) { } catch (error) {
console.error('RSS feed generation error:', error); console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 }); return new Response('Internal Server Error', { status: 500 });
} }
}; }
+17 -477
View File
@@ -1,484 +1,24 @@
import { db } from '$lib/server/db';
import { dailyCompletions, user } from '$lib/server/db/schema';
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { ensureSnapshot } from '$lib/server/stats/snapshot';
import { latestClosedDate } from '$lib/server/stats/dates';
function estDateStr(daysAgo = 0): string { /**
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5 * Slim public community widget. Reads the latest completed daily snapshot and
estNow.setUTCDate(estNow.getUTCDate() - daysAgo); * exposes only safe aggregates (no retention, churn, signup or revenue data).
return estNow.toISOString().slice(0, 10); */
}
function prevDay(d: string): string {
const dt = new Date(d + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() - 1);
return dt.toISOString().slice(0, 10);
}
function addDays(d: string, n: number): string {
const dt = new Date(d + 'T00:00:00Z');
dt.setUTCDate(dt.getUTCDate() + n);
return dt.toISOString().slice(0, 10);
}
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
const todayEst = estDateStr(0); const snapshot = await ensureSnapshot(latestClosedDate());
const yesterdayEst = estDateStr(1); const payload = snapshot.payload;
const sevenDaysAgo = estDateStr(7);
// Three weekly windows for first + second derivative calculations
// Week A: last 7 days (indices 0–6)
// Week B: 7–13 days ago (indices 7–13)
// Week C: 14–20 days ago (indices 14–20)
const weekAStart = estDateStr(6);
const weekBEnd = estDateStr(7);
const weekBStart = estDateStr(13);
const weekCEnd = estDateStr(14);
const weekCStart = estDateStr(20);
// ── Scalar stats ──────────────────────────────────────────────────────────
const [{ todayCount }] = await db
.select({ todayCount: count() })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const [{ totalCount }] = await db
.select({ totalCount: count() })
.from(dailyCompletions);
const [{ uniquePlayers }] = await db
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions);
const [{ weeklyPlayers }] = await db
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sevenDaysAgo));
const thirtyDaysAgo = estDateStr(30);
const [{ monthlyPlayers }] = await db
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, thirtyDaysAgo));
const todayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const yesterdayPlayers = await db
.selectDistinct({ id: dailyCompletions.anonymousId })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, yesterdayEst));
const todaySet = new Set(todayPlayers.map((r) => r.id));
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
const [{ avgGuessesRaw }] = await db
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
.from(dailyCompletions)
.where(eq(dailyCompletions.date, todayEst));
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
const [{ registeredUsers }] = await db
.select({ registeredUsers: count() })
.from(user);
const avgCompletionsPerPlayer =
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
// ── 21-day completions per day (covers all three weekly windows) ──────────
const rawPerDay21 = await db
.select({ date: dailyCompletions.date, dayCount: count() })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, weekCStart))
.groupBy(dailyCompletions.date)
.orderBy(asc(dailyCompletions.date));
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
// Build indexed array: index 0 = today, index 20 = 20 days ago
const completionsPerDay: number[] = [];
for (let i = 0; i <= 20; i++) {
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
}
// last14Days for the trend chart (most recent first)
const last14Days: { date: string; count: number }[] = [];
for (let i = 0; i <= 13; i++) {
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
}
// Weekly totals from the indexed array
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
// First derivative: avg daily completions change (week A vs week B)
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
// Second derivative: is velocity itself increasing or decreasing?
const completionsAcceleration =
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
const ninetyDaysAgo = estDateStr(90);
const recentCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, ninetyDaysAgo))
.orderBy(asc(dailyCompletions.date));
// Group dates by user (ascending) and users by date
const userDatesMap = new Map<string, string[]>();
const dateUsersMap = new Map<string, Set<string>>();
for (const row of recentCompletions) {
const arr = userDatesMap.get(row.anonymousId);
if (arr) arr.push(row.date);
else userDatesMap.set(row.anonymousId, [row.date]);
let s = dateUsersMap.get(row.date);
if (!s) { s = new Set(); dateUsersMap.set(row.date, s); }
s.add(row.anonymousId);
}
// ── Streak distribution ───────────────────────────────────────────────────
const streakDistribution = new Map<number, number>();
for (const dates of userDatesMap.values()) {
const desc = dates.slice().reverse();
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
let streak = 1;
let cur = desc[0];
for (let i = 1; i < desc.length; i++) {
if (desc[i] === prevDay(cur)) {
streak++;
cur = desc[i];
} else {
break;
}
}
if (streak >= 2) {
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
}
}
const streakChart = Array.from(streakDistribution.entries())
.sort((a, b) => a[0] - b[0])
.map(([days, userCount]) => ({ days, count: userCount }));
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
const weekAUsers = new Set<string>();
const weekBUsers = new Set<string>();
const weekCUsers = new Set<string>();
for (const [userId, dates] of userDatesMap) {
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
}
// First derivative: weekly unique users change
const userVelocity = weekAUsers.size - weekBUsers.size;
// Second derivative: is user growth speeding up or slowing down?
const userAcceleration =
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
// ── New players + churn ───────────────────────────────────────────────────
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
// Checking against all-time data (not just the 90-day window) ensures accuracy.
const firstDates = await db
.select({
anonymousId: dailyCompletions.anonymousId,
firstDate: min(dailyCompletions.date),
totalCompletions: count()
})
.from(dailyCompletions)
.groupBy(dailyCompletions.anonymousId);
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
// Churned: played in week B but not at all in week A
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
// Net growth = truly new arrivals minus departures
const netGrowth7d = newUsers7d - churned7d;
// ── Session depth funnel ──────────────────────────────────────────────────
// For each depth d, count players with >= d completions.
// returnRate at depth d = (players with >= d+1) / (players with >= d).
const depthCounts = new Map<number, number>();
for (const r of firstDates) {
const n = r.totalCompletions;
for (let d = 1; d <= n; d++) {
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
}
}
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
const atD = depthCounts.get(d) ?? 0;
const atDplus1 = depthCounts.get(d + 1) ?? 0;
return {
depth: d,
players: atD,
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
};
});
// ── Return rate ───────────────────────────────────────────────────────────
// "Return rate": % of all-time unique players who have ever played more than once.
const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length;
const overallReturnRate =
firstDates.length > 0
? Math.round((playersWithReturn / firstDates.length) * 1000) / 10
: null;
// Daily new-player return rate: for each day D, what % of first-time players
// on D ever came back (i.e. totalCompletions >= 2)?
const dailyNewPlayerReturn = new Map<string, { cohort: number; returned: number }>();
for (const r of firstDates) {
if (!r.firstDate) continue;
const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 };
existing.cohort++;
if (r.totalCompletions >= 2) existing.returned++;
dailyNewPlayerReturn.set(r.firstDate, existing);
}
// Build chronological array of daily rates (oldest first, days 60→1 ago)
// Days with fewer than 3 new players get rate=null to exclude from rolling avg
const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = [];
for (let i = 60; i >= 1; i--) {
const dateD = estDateStr(i);
const d = dailyNewPlayerReturn.get(dateD);
dailyReturnRates.push({
date: dateD,
cohort: d?.cohort ?? 0,
rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null
});
}
// 7-day trailing rolling average of the daily rates
// Index 0 = 60 days ago, index 59 = yesterday
const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => {
const window = dailyReturnRates
.slice(Math.max(0, idx - 6), idx + 1)
.filter((d) => d.rate !== null);
const avg =
window.length > 0
? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) /
10
: null;
return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg };
});
// Velocity: avg of last 7 complete days (idx 53–59) vs prior 7 (idx 46–52)
const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null);
const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null);
const current7dReturnAvg =
recentWindow.length > 0
? Math.round(
(recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10
) / 10
: null;
const prior7dReturnAvg =
priorWindow.length > 0
? Math.round(
(priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10
) / 10
: null;
const returnRateChange =
current7dReturnAvg !== null && prior7dReturnAvg !== null
? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10
: null;
// ── Retention over time ───────────────────────────────────────────────────
// For each cohort day D, retention = % of that day's players who played
// again within the next N days. Only compute for days where D+N is in the past.
function retentionSeries(
windowDays: number,
seriesLength: number
): { date: string; rate: number; cohortSize: number }[] {
// Earliest computable cohort day: today - (windowDays + 1)
// We use index windowDays+1 through windowDays+seriesLength
const series: { date: string; rate: number; cohortSize: number }[] = [];
for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) {
const dateD = estDateStr(i);
const cohort = dateUsersMap.get(dateD);
if (!cohort || cohort.size < 3) continue; // skip tiny cohorts
let retained = 0;
for (const userId of cohort) {
if (dateUsersMap.get(addDays(dateD, windowDays))?.has(userId)) {
retained++;
}
}
series.push({
date: dateD,
rate: Math.round((retained / cohort.size) * 1000) / 10,
cohortSize: cohort.size
});
}
return series; // newest first (loop iterates i from smallest = most recent)
}
const retention7dSeries = retentionSeries(7, 30);
const retention30dSeries = retentionSeries(30, 30);
// ── Weekly Active Users history (12 weeks) ────────────────────────────────
const wauWeeks: { weekStart: string; weekEnd: string; wau: number; changePct: number | null }[] = [];
for (let w = 0; w < 12; w++) {
const weekEnd = estDateStr(w * 7);
const weekStart = estDateStr(w * 7 + 6);
const users = new Set<string>();
for (const row of recentCompletions) {
if (row.date >= weekStart && row.date <= weekEnd) {
users.add(row.anonymousId);
}
}
wauWeeks.push({ weekEnd, weekStart, wau: users.size, changePct: null });
}
// Change % vs prior week (index i+1 is the older week)
for (let i = 0; i < wauWeeks.length - 1; i++) {
const prev = wauWeeks[i + 1].wau;
if (prev > 0) {
wauWeeks[i].changePct = Math.round(((wauWeeks[i].wau - prev) / prev) * 1000) / 10;
}
}
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
// ── Monthly Active Users history (6 months) ───────────────────────────────
const sixMonthsAgo = estDateStr(185);
const mauCompletions = await db
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
.from(dailyCompletions)
.where(gte(dailyCompletions.date, sixMonthsAgo));
// Rolling 30-day windows
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
for (let m = 0; m < 6; m++) {
const monthEnd = estDateStr(m * 30);
const monthStart = estDateStr(m * 30 + 29);
const users = new Set<string>();
for (const row of mauCompletions) {
if (row.date >= monthStart && row.date <= monthEnd) {
users.add(row.anonymousId);
}
}
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
}
for (let i = 0; i < mauMonths.length - 1; i++) {
const prev = mauMonths[i + 1].mau;
if (prev > 0) {
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
}
}
// Calendar month windows
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
const calendarMauMonths: {
label: string;
monthStart: string;
monthEnd: string;
mau: number;
daysElapsed: number;
daysInMonth: number;
projectedMau: number | null;
changePct: number | null;
isCurrentMonth: boolean;
}[] = [];
for (let i = 0; i < 6; i++) {
let mo = todayMonth - i;
let yr = todayYear;
if (mo <= 0) { mo += 12; yr--; }
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
const daysInMonth = new Date(yr, mo, 0).getDate();
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
const isCurrentMonth = i === 0;
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
const users = new Set<string>();
for (const row of mauCompletions) {
if (row.date >= monthStart && row.date <= queryEnd) {
users.add(row.anonymousId);
}
}
const projectedMau = isCurrentMonth && daysElapsed > 0
? Math.round(users.size * (daysInMonth / daysElapsed))
: null;
calendarMauMonths.push({
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
monthStart,
monthEnd,
mau: users.size,
daysElapsed,
daysInMonth,
projectedMau,
changePct: null,
isCurrentMonth
});
}
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
const curr = calendarMauMonths[i];
const prev = calendarMauMonths[i + 1];
if (prev.mau > 0) {
const compareVal = curr.projectedMau ?? curr.mau;
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
}
}
return { return {
todayEst, asOfDate: payload.asOfDate,
sessionDepthCards, completions: payload.totals.completions,
stats: { players: payload.totals.players,
todayCount, weeklyPlayers: payload.activity.wau,
totalCount, monthlyPlayers: payload.activity.mau,
uniquePlayers, yesterdayCompletions: payload.totals.yesterdayCompletions,
weeklyPlayers, yesterdayAvgGuesses: payload.totals.yesterdayAvgGuesses,
activeStreaks, bestDayAllTime: payload.totals.bestDay,
avgGuessesToday, calendar: payload.habit.calendar.slice(-90)
registeredUsers,
monthlyPlayers
},
growth: {
completionsVelocity,
completionsAcceleration,
userVelocity,
userAcceleration,
newUsers7d,
churned7d,
netGrowth7d
},
last14Days,
streakChart,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
newPlayerReturnVelocity: {
current7dAvg: current7dReturnAvg,
prior7dAvg: prior7dReturnAvg,
change: returnRateChange
},
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths
}; };
}; };
+110 -602
View File
@@ -1,627 +1,135 @@
<script lang="ts"> <script lang="ts">
import Container from "$lib/components/Container.svelte"; import type { PageData } from './$types';
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte"; import Container from '$lib/components/Container.svelte';
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
interface Stats { import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
todayCount: number;
totalCount: number;
uniquePlayers: number;
weeklyPlayers: number;
activeStreaks: number;
avgGuessesToday: number | null;
registeredUsers: number;
monthlyPlayers: number;
}
interface PageData {
todayEst: string;
stats: Stats;
last14Days: { date: string; count: number }[];
streakChart: { days: number; count: number }[];
growth: {
completionsVelocity: number;
completionsAcceleration: number;
userVelocity: number;
userAcceleration: number;
newUsers7d: number;
churned7d: number;
netGrowth7d: number;
};
retention7dSeries: { date: string; rate: number; cohortSize: number }[];
retention30dSeries: {
date: string;
rate: number;
cohortSize: number;
}[];
overallReturnRate: number | null;
newPlayerReturnSeries: {
date: string;
cohort: number;
rate: number | null;
rollingAvg: number | null;
}[];
newPlayerReturnVelocity: {
current7dAvg: number | null;
prior7dAvg: number | null;
change: number | null;
};
wauWeeks: {
weekStart: string;
weekEnd: string;
wau: number;
changePct: number | null;
}[];
avgWau: number;
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
calendarMauMonths: {
label: string;
monthStart: string;
monthEnd: string;
mau: number;
daysElapsed: number;
daysInMonth: number;
projectedMau: number | null;
changePct: number | null;
isCurrentMonth: boolean;
}[];
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
}
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { const calendar = $derived(data.calendar);
stats, const active = $derived(calendar.filter((d) => d.count > 0));
last14Days,
todayEst,
streakChart,
growth,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries,
newPlayerReturnVelocity,
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths,
sessionDepthCards,
} = $derived(data);
let mauMode = $state<'rolling' | 'calendar'>('rolling'); const total = $derived(calendar.reduce((sum, d) => sum + d.count, 0));
const avgPerDay = $derived(calendar.length ? total / calendar.length : 0);
function signed(n: number, unit = ""): string { const bestDay = $derived(
if (n > 0) return `+${n}${unit}`; active.reduce<{ date: string; count: number } | null>(
if (n < 0) return `${n}${unit}`; (best, d) => (best === null || d.count > best.count ? d : best),
return `0${unit}`; null
)
);
const worstDay = $derived(
active.reduce<{ date: string; count: number } | null>(
(worst, d) => (worst === null || d.count < worst.count ? d : worst),
null
)
);
const medianDay = $derived.by(() => {
if (active.length === 0) return null;
const sorted = [...active].sort((a, b) => a.count - b.count);
return sorted[Math.floor((sorted.length - 1) / 2)];
});
/** Compact enough to sit inside a day square. */
function compactCount(n: number): string {
if (n >= 10000) return `${Math.round(n / 1000)}k`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
} }
function trendColor(n: number): string { const annotations = $derived.by(() => {
if (n > 0) return "text-green-400"; const out: Record<string, string> = {};
if (n < 0) return "text-red-400"; for (const day of [bestDay, medianDay, worstDay]) {
return "text-gray-400"; if (day) out[day.date] = compactCount(day.count);
} }
return out;
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count))); });
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
const statCards = $derived([
{ label: "Completions Today", value: String(stats.todayCount) },
{ label: "All-Time Completions", value: String(stats.totalCount) },
{ label: "Unique Players", value: String(stats.uniquePlayers) },
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
{ label: "Active Streaks", value: String(stats.activeStreaks) },
{ label: "Registered Users", value: String(stats.registeredUsers) },
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
{
label: "Overall Return Rate",
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
},
]);
const mauModes = [
{ value: 'rolling', label: 'Rolling 30d' },
{ value: 'calendar', label: 'Calendar' },
];
</script> </script>
<svelte:head> <svelte:head>
<title>Global Stats | Bibdle</title> <title>Community Stats | Bibdle</title>
<meta name="description" content="Bibdle community statistics, as of the latest completed day." />
</svelte:head> </svelte:head>
<div <div class="mx-auto max-w-3xl px-4 py-8">
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100" <div class="mb-6 text-center">
> <h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Community Stats</h1>
<div class="max-w-6xl mx-auto px-4 py-8"> <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">As of {formatDateLabel(data.asOfDate)}</p>
<a </div>
href="/"
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
>
← Back to Game
</a>
<header class="mb-8"> <div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1> <Container class="p-4 w-full items-start text-left">
<p class="text-gray-400 text-sm mt-1"> <p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time plays</p>
EST reference date: {todayEst} <p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time players</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.players)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Players this week</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.weeklyPlayers)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Players this month</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Latest day's plays</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p>
</Container>
<Container class="p-4 w-full items-start text-left">
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses that day</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p>
</Container>
</div>
<Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily plays (last {calendar.length} days)</p>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start">
<div class="flex flex-col gap-1">
<CalendarHeatmap data={calendar} cellSize={22} annotations={annotations} />
<p class="text-[10px] text-gray-500 dark:text-gray-400">
Numbers mark the best, median, and least popular days.
</p> </p>
</header>
<section
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
>
{#each statCards as card (card.label)}
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>{card.label}</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100"
>{card.value}</span
>
</Container>
{/each}
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Traffic &amp; Growth <span
class="text-xs font-normal text-gray-400"
>(7-day windows)</span
>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Completions Velocity</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.completionsVelocity,
)}">{signed(growth.completionsVelocity, "/day")}</span
>
<span class="text-xs text-gray-500 text-center"
>vs prior 7 days</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Completions Accel.</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.completionsAcceleration,
)}"
>{signed(growth.completionsAcceleration, "/day")}</span
>
<span class="text-xs text-gray-500 text-center"
>rate of change of velocity</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>User Velocity</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.userVelocity,
)}">{signed(growth.userVelocity)}</span
>
<span class="text-xs text-gray-500 text-center"
>unique players, wk/wk</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>User Acceleration</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.userAcceleration,
)}">{signed(growth.userAcceleration)}</span
>
<span class="text-xs text-gray-500 text-center"
>rate of change of user velocity</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>New Players (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.newUsers7d,
)}">{String(growth.newUsers7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>first-time players</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Churned (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
0,
)}">{String(growth.churned7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>played wk prior, not this wk</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Net Growth (7d)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {trendColor(
growth.netGrowth7d,
)}">{signed(growth.netGrowth7d)}</span
>
<span class="text-xs text-gray-500 text-center"
>new minus churned</span
>
</Container>
</div>
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
<p class="text-gray-400 text-sm mb-4">
Of players who completed N sessions, what % came back for N+1?
</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{#each sessionDepthCards as card (card.depth)}
<Container class="w-full p-5 gap-2">
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
>After {card.depth} plays</span
>
<span class="text-2xl md:text-3xl font-bold text-gray-100">
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>{card.players} players</span
>
</Container>
{/each}
</div>
</section>
<section class="mb-10">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
New Player Return Rate <span
class="text-xs font-normal text-gray-400"
>(7-day rolling avg)</span
>
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Return Rate (7d avg)</span
>
<span
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
>
{newPlayerReturnVelocity.current7dAvg != null
? `${newPlayerReturnVelocity.current7dAvg}%`
: "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>new players who came back</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Return Rate Change</span
>
<span
class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change !=
null
? trendColor(newPlayerReturnVelocity.change)
: 'text-gray-400'}"
>
{newPlayerReturnVelocity.change != null
? signed(newPlayerReturnVelocity.change, "pp")
: "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>vs prior 7 days</span
>
</Container>
<Container class="w-full p-5 gap-2">
<span
class="text-gray-400 text-xs uppercase tracking-wide text-center"
>Prior 7d Avg</span
>
<span
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
>
{newPlayerReturnVelocity.prior7dAvg != null
? `${newPlayerReturnVelocity.prior7dAvg}%`
: "N/A"}
</span>
<span class="text-xs text-gray-500 text-center"
>days 8–14 ago</span
>
</Container>
</div> </div>
<CollapsibleTable <div class="flex min-w-0 flex-1 flex-col gap-4">
rows={newPlayerReturnSeries} <dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
headers={[
{ label: 'Date' },
{ label: 'New Players', align: 'right' },
{ label: 'Return Rate', align: 'right' },
{ label: '7d Avg', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
<td class="px-4 py-3 text-right text-gray-400">
{item.rate != null ? `${item.rate}%` : '—'}
</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
{#if item.rollingAvg != null}
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
{/if}
</div>
</td>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">
Weekly Active Users
</h2>
<p class="text-gray-400 text-sm mb-4">
Unique players per 7-day window. Most recent week first. Avg
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
</p>
<CollapsibleTable
rows={wauWeeks}
headers={[
{ label: 'Week' },
{ label: 'Active Users', align: 'right' },
{ label: 'Wk/Wk Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.wau / maxWau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} – {item.weekEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.changePct, '%') : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
<p class="text-gray-400 text-sm mb-4">
{mauMode === 'rolling'
? 'Unique players per 30-day window. Most recent first.'
: 'Unique players per calendar month. Current month projected to end of month.'}
</p>
{#if mauMode === 'rolling'}
<CollapsibleTable
rows={mauMonths}
headers={[
{ label: 'Period' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const barPct = Math.round((item.mau / maxMau) * 100)}
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} – {item.monthEnd}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{item.changePct != null ? signed(item.changePct, '%') : '—'}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
{:else}
<CollapsibleTable
rows={calendarMauMonths}
headers={[
{ label: 'Month' },
{ label: 'Active Users', align: 'right' },
{ label: 'Mo/Mo Change', align: 'right' },
{ label: '', width: 'w-48' },
]}
modes={mauModes}
bind:mode={mauMode}
>
{#snippet row(item)}
{@const displayMau = item.projectedMau ?? item.mau}
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
<td class="px-4 py-3 text-gray-300">
{item.label}
{#if item.isCurrentMonth}
<span class="text-xs text-gray-500 ml-1">(projected)</span>
{/if}
</td>
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
{#if item.isCurrentMonth}
<span class="text-gray-500 text-xs">{item.mau} → </span>{item.projectedMau ?? item.mau}
{:else}
{item.mau}
{/if}
</td>
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
: 'text-gray-500'}">
{#if item.changePct != null}
{item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
{:else}
—
{/if}
</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
{/if}
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Last 14 Days — Completions
</h2>
<CollapsibleTable
rows={last14Days}
headers={[
{ label: 'Date' },
{ label: 'Completions', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-4">
Active Streak Distribution
</h2>
<CollapsibleTable
rows={streakChart}
headers={[
{ label: 'Days' },
{ label: 'Players', align: 'right' },
{ label: '', width: 'w-48' },
]}
>
{#snippet row(item)}
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
<td class="px-4 py-3 text-gray-300">{item.days}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
<td class="px-4 py-3">
<div class="w-full min-w-24">
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
</div>
</td>
{/snippet}
{#snippet empty()}
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
{/snippet}
</CollapsibleTable>
</section>
<section class="mt-8">
<h2 class="text-lg font-semibold text-gray-100 mb-1">
Retention Over Time
</h2>
<p class="text-gray-400 text-sm mb-6">
% of each day's players who played again exactly 7 or 30 days later (regardless of activity in between). Cohorts with fewer than 3 players are excluded.
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 7-day retention -->
<div> <div>
<h3 class="text-base font-semibold text-gray-200 mb-3"> <dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">{calendar.length}-day plays</dt>
7-Day Retention <dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">{numOrDash(total)}</dd>
</h3>
<CollapsibleTable
rows={retention7dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
</div> </div>
</td>
{/snippet}
</CollapsibleTable>
</div>
<!-- 30-day retention -->
<div> <div>
<h3 class="text-base font-semibold text-gray-200 mb-3"> <dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Daily average</dt>
30-Day Retention <dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
</h3> {avgPerDay.toLocaleString('en-US', { maximumFractionDigits: 1 })}
<CollapsibleTable </dd>
rows={retention30dSeries}
headers={[
{ label: 'Cohort Date' },
{ label: 'n', align: 'right' },
{ label: 'Ret. %', align: 'right' },
{ label: '', width: 'w-32' },
]}
>
{#snippet row(item)}
<td class="px-4 py-3 text-gray-300">{item.date}</td>
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
<td class="px-4 py-3">
<div class="w-full min-w-20">
<div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
</div> </div>
</td> {#if bestDay}
{/snippet} <div>
</CollapsibleTable> <dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Best day ({calendar.length}d)</dt>
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{numOrDash(bestDay.count)}
<span class="font-normal text-gray-500 dark:text-gray-400">on {formatDateLabel(bestDay.date)}</span>
</dd>
</div>
{/if}
{#if data.bestDayAllTime}
<div>
<dt class="text-[11px] uppercase tracking-wider text-gray-500 dark:text-gray-400">Best day · all-time</dt>
<dd class="font-semibold tabular-nums text-gray-900 dark:text-gray-100">
{numOrDash(data.bestDayAllTime.count)}
<span class="font-normal text-gray-500 dark:text-gray-400">on {formatDateLabel(data.bestDayAllTime.date)}</span>
</dd>
</div>
{/if}
</dl>
</div> </div>
</div> </div>
</section> </Container>
</div>
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
Updated once a day from completed games.<br /> <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
</p>
</div> </div>
@@ -0,0 +1,97 @@
import { db } from '$lib/server/db';
import { verseSubmissions, dailyVerses, user } from '$lib/server/db/schema';
import { eq, asc } from 'drizzle-orm';
import { getBookById } from '$lib/server/bible';
import { isAdmin } from '$lib/server/admin';
import type { PageServerLoad } from './$types';
export type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number; // server UTC millis
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
export const load: PageServerLoad = async ({ locals }) => {
// Not authenticated → signal the client to show the sign-in flow
// (same pattern as /progress, /stats).
if (!locals.user) {
return {
rows: [] as ScheduledVerseRow[],
requiresAuth: true,
authorized: false,
user: null,
session: null,
};
}
// Authenticated but wrong account → plain access-denied state. Don't leak
// the existence of privileged data; just render "not authorized".
if (!isAdmin(locals.user.email)) {
return {
rows: [] as ScheduledVerseRow[],
requiresAuth: false,
authorized: false,
user: locals.user,
session: locals.session,
};
}
const joined = await db
.select({
scheduledDate: verseSubmissions.scheduledDate,
selectedBookId: verseSubmissions.selectedBookId,
selectedChapter: verseSubmissions.selectedChapter,
selectedVerse: verseSubmissions.selectedVerse,
submittedAt: verseSubmissions.submittedAt,
dailyDate: dailyVerses.date,
bookId: dailyVerses.bookId,
reference: dailyVerses.reference,
verseText: dailyVerses.verseText,
userId: verseSubmissions.userId,
userEmail: user.email,
userFirstName: user.firstName,
userLastName: user.lastName,
})
.from(verseSubmissions)
// LEFT JOINs so deleted accounts (ON DELETE SET NULL) still render —
// the scheduled verse plays out regardless.
.leftJoin(dailyVerses, eq(verseSubmissions.scheduledDate, dailyVerses.date))
.leftJoin(user, eq(verseSubmissions.userId, user.id))
.orderBy(asc(verseSubmissions.scheduledDate));
const rows: ScheduledVerseRow[] = joined.map((r) => {
const book = r.bookId ? getBookById(r.bookId) : undefined;
return {
scheduledDate: r.scheduledDate,
reference: r.reference,
verseText: r.verseText,
bookId: r.bookId,
bookName: book?.name ?? null,
selectedBookId: r.selectedBookId,
selectedChapter: r.selectedChapter,
selectedVerse: r.selectedVerse,
submittedAt: r.submittedAt,
submitterEmail: r.userEmail,
submitterName: [r.userFirstName, r.userLastName].filter(Boolean).join(' ') || null,
// userId is null when the account was deleted (ON DELETE SET NULL)
submitterDeleted: r.userId === null,
};
});
return {
rows,
requiresAuth: false,
authorized: true,
user: locals.user,
session: locals.session,
};
};
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import { invalidateAll } from '$app/navigation';
import AuthModal from '$lib/components/AuthModal.svelte';
import Container from '$lib/components/Container.svelte';
import { bibleBooks } from '$lib/types/bible';
type ScheduledVerseRow = {
scheduledDate: string;
reference: string | null;
verseText: string | null;
bookId: string | null;
bookName: string | null;
selectedBookId: string;
selectedChapter: number;
selectedVerse: number;
submittedAt: number;
submitterEmail: string | null;
submitterName: string | null;
submitterDeleted: boolean;
};
interface PageData {
rows: ScheduledVerseRow[];
requiresAuth: boolean;
authorized: boolean;
user?: any;
session?: any;
}
let { data }: { data: PageData } = $props();
let authModalOpen = $state(false);
let anonymousId = $state('');
let filter = $state<'all' | 'upcoming' | 'past'>('all');
const filters = ['all', 'upcoming', 'past'] as const;
// Dev-only seed button (calls POST /api/dev/seed-submission, which is
// host-gated to localhost:5173 / test.bibdle.com). Hidden in prod.
const isDevHost = $derived(
browser &&
['localhost:5173', 'test.bibdle.com'].includes(window.location.host)
);
let seeding = $state(false);
let seedMessage = $state<{ ok: boolean; text: string } | null>(null);
async function seedSubmission() {
seeding = true;
seedMessage = null;
try {
const res = await fetch('/api/dev/seed-submission', { method: 'POST' });
const body = await res.json().catch(() => ({}));
if (!res.ok) {
seedMessage = {
ok: false,
text: body?.error ?? `Failed (${res.status})`
};
} else {
seedMessage = {
ok: true,
text: `Seeded ${body?.scheduledDate ?? ''} — ${body?.reference ?? ''}`
};
// Reload server load data so the new row appears in the table.
await invalidateAll();
}
} catch (err) {
seedMessage = { ok: false, text: String(err) };
} finally {
seeding = false;
}
}
function getOrCreateAnonymousId(): string {
if (!browser) return '';
const key = 'bibdle-anonymous-id';
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
}
onMount(() => {
anonymousId = getOrCreateAnonymousId();
});
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00Z');
return d.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
timeZone: 'UTC',
});
}
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
});
}
const todayUtc = $derived(new Date().toISOString().slice(0, 10));
const filteredRows = $derived.by(() => {
if (filter === 'all') return data.rows;
if (filter === 'upcoming') return data.rows.filter((r) => r.scheduledDate >= todayUtc);
return data.rows.filter((r) => r.scheduledDate < todayUtc);
});
function selectedBookName(bookId: string): string {
return bibleBooks.find((b) => b.id === bookId)?.name ?? bookId;
}
</script>
<svelte:head>
<title>Scheduled Verses | Bibdle</title>
<meta name="robots" content="noindex" />
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 p-4 md:p-8"
>
<div class="max-w-5xl mx-auto">
<div class="text-center mb-6 md:mb-8">
<h1 class="text-3xl md:text-4xl font-bold text-gray-100 mb-4">
Scheduled Verses
</h1>
<a href="/" class="p-2 px-20 w-full items-center text-gray-300">
&larr; Back to Game
</a>
</div>
{#if data.requiresAuth}
<div class="text-center py-12">
<div
class="bg-blue-950/50 border border-blue-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-blue-200 mb-4">
Authentication Required
</h2>
<p class="text-blue-300 mb-6">
You must be signed in to view this page.
</p>
<div class="flex flex-col gap-3">
<button
onclick={() => (authModalOpen = true)}
class="inline-flex items-center justify-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Sign In
</button>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
</div>
{:else if !data.authorized}
<div class="text-center py-12">
<div
class="bg-red-950/50 border border-red-800/50 rounded-lg p-8 max-w-md mx-auto backdrop-blur-sm"
>
<h2 class="text-2xl font-bold text-red-200 mb-4">Not Authorized</h2>
<p class="text-red-300 mb-6">You do not have access to this page.</p>
<a
href="/"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors font-medium"
>
&larr; Back to Game
</a>
</div>
</div>
{:else}
<!-- Filter toggle -->
<div class="flex items-center justify-between gap-2 mb-4 flex-wrap">
<div class="flex items-center gap-2 flex-wrap">
<div class="flex gap-1 bg-white/5 rounded-lg p-1 border border-white/10">
{#each filters as f}
<button
onclick={() => (filter = f)}
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors {filter ===
f
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-white/5'}"
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
{/each}
</div>
{#if isDevHost}
<button
onclick={seedSubmission}
disabled={seeding}
class="px-3 py-1.5 rounded-md text-xs font-medium border border-white/10 bg-white/5 text-gray-200 hover:bg-white/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{seeding ? 'Seeding…' : '+ Seed test submission'}
</button>
{/if}
</div>
<div class="flex items-center gap-3">
{#if seedMessage}
<span
class="text-xs {seedMessage.ok
? 'text-emerald-400'
: 'text-red-400'}"
>
{seedMessage.text}
</span>
{/if}
<span class="text-xs text-gray-500">
{filteredRows.length} {filteredRows.length === 1 ? 'row' : 'rows'}
</span>
</div>
</div>
{#if filteredRows.length === 0}
<Container class="p-8 w-full text-center">
<p class="text-gray-400">No submissions yet.</p>
</Container>
{:else}
<Container class="p-2 md:p-4 w-full overflow-x-auto">
<table class="w-full text-sm text-left text-gray-200">
<thead
class="text-xs uppercase text-gray-400 border-b border-white/10"
>
<tr>
<th class="px-2 md:px-3 py-2">Scheduled</th>
<th class="px-2 md:px-3 py-2">Reference</th>
<th class="px-2 md:px-3 py-2 hidden md:table-cell">
Window text
</th>
<th class="px-2 md:px-3 py-2">Submitted by</th>
<th class="px-2 md:px-3 py-2 hidden sm:table-cell">
Submitted at
</th>
<th class="px-2 md:px-3 py-2 hidden lg:table-cell">
Selected
</th>
</tr>
</thead>
<tbody>
{#each filteredRows as row (row.scheduledDate)}
<tr
class="border-b border-white/5 hover:bg-white/5 align-top"
>
<td class="px-2 md:px-3 py-2 whitespace-nowrap">
<div class="font-medium text-gray-100">
{formatDate(row.scheduledDate)}
</div>
{#if row.scheduledDate < todayUtc}
<span class="text-[10px] text-gray-500"
>played</span
>
{:else if row.scheduledDate === todayUtc}
<span
class="text-[10px] text-emerald-400"
>today</span
>
{/if}
</td>
<td class="px-2 md:px-3 py-2">
{#if row.reference}
<div class="font-medium">
{row.reference}
</div>
<div class="text-[10px] text-gray-500">
{row.bookName ?? row.bookId}
</div>
{:else}
<span class="text-gray-500"
>— no daily_verse row —</span
>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden md:table-cell max-w-xs"
>
<span class="text-xs text-gray-400 line-clamp-4"
>{row.verseText ?? ''}</span
>
</td>
<td class="px-2 md:px-3 py-2">
{#if row.submitterDeleted}
<span class="text-gray-500 italic"
>(deleted user)</span
>
{:else}
{#if row.submitterName}
<div>{row.submitterName}</div>
{/if}
<div class="text-[11px] text-gray-400">
{row.submitterEmail ?? '—'}
</div>
{/if}
</td>
<td
class="px-2 md:px-3 py-2 hidden sm:table-cell whitespace-nowrap text-xs text-gray-400"
>
{formatTimestamp(row.submittedAt)}
</td>
<td
class="px-2 md:px-3 py-2 hidden lg:table-cell whitespace-nowrap text-xs text-gray-400"
>
{selectedBookName(row.selectedBookId)}
{row.selectedChapter}:{row.selectedVerse}
</td>
</tr>
{/each}
</tbody>
</table>
</Container>
{/if}
{/if}
</div>
</div>
<AuthModal bind:isOpen={authModalOpen} {anonymousId} />
+369
View File
@@ -0,0 +1,369 @@
# Bibdle Statistics Dashboard — Implementation Checklist
**For:** an AI coding agent (or human) implementing the plan in `statistics-plan.md`.
**Repository:** Bibdle — SvelteKit 2 · Svelte 5 (runes) · Tailwind 4 · Bun · SQLite + Drizzle ORM.
**Rule:** read `statistics-plan.md` and `README.md` fully before starting. This checklist is ordered and dependency-aware; do tasks top to bottom unless a task says otherwise.
---
## 0. How to use this document
- Each task has an **ID**, **Goal**, **Files**, **Steps**, **Acceptance**, and **Tests**.
- Do **not** skip the Phase 0 foundations — every later phase depends on the schema and event log.
- Prefer pure, testable functions in `src/lib/server/stats/*`; keep Svelte components presentational.
- When a task is ambiguous, prefer the smallest reversible change and leave a `// TODO(stats):` comment rather than inventing behavior.
---
## 1. Guardrails (non-negotiable)
1. **Do not add Umami or Rybbit integration.** The dashboard is self-contained and durable. Remove/ignore any Rybbit references you touch.
2. **Do not query the DB on `/admin/stats` page load** except to (a) build a missing snapshot lazily, or (b) serve a `?mature=1` view. Normal loads read the stored snapshot only.
3. **Point-in-time correctness:** snapshot computation for date `D` may only use rows with `date <= D` (and `completed_at` before end of `D`).
4. **Plain language only.** No "velocity/acceleration/derivative" jargon in UI labels or captions. Every metric gets a one-sentence caption and a "what to do."
5. **Privacy:** `/admin/stats` is gated to `ADMIN_EMAIL`. Never render emails or raw anonymous IDs. The public widget must expose no retention/churn/signup/revenue data.
6. **Do not modify existing public routes** (`/`, `/progress`, `/stats`, `/imposter`, etc.) beyond the specific instrumentation hooks in Phase 0. Do not change the public `/global` until Phase 4.
7. **No new runtime dependency except `layerchart`.** (Email digest would require `nodemailer`; it is out of scope unless the owner approves — default to the Discord webhook.)
8. **Follow existing code style:** Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`), Tailwind 4 utility classes, Drizzle query builder, `bun test` with `describe/test/expect`.
9. **Migrations:** never hand-edit generated SQL. Use `bun run db:generate` and review the diff. Be aware the `drizzle/` journal references `0001_loose_kree` but that `.sql` file is absent on disk — do not "fix" this; just add new migrations on top.
10. **Commit early and often**, one phase per logical commit set, message prefix `stats:`.
---
## 2. Environment & commands
```bash
bun install
bun run dev # dev server
bun run check # svelte-check / TS
bun test # all tests
bun test tests/<file>.test.ts
bun run db:generate # generate migration from schema.ts
bun run db:migrate # apply migrations to DATABASE_URL
```
- `DATABASE_URL` (e.g. `dev.db`) drives the app DB.
- `TEST_DATABASE_URL` drives `drizzle.test.config.ts`.
- Existing env: `CRON_SECRET`, `DISCORD_DAILY_WEBHOOK`, `ADMIN_EMAIL` (hardcoded in `src/lib/server/admin.ts`).
- **Add** `DISCORD_ADMIN_WEBHOOK` (digest target) to `.env.example` and `.env`.
**Verification gate after every phase:** `bun run check` clean, `bun test` green, `bun run build` succeeds.
---
## Phase 0 — Foundations & instrumentation
### T0.1 — Add tables and column to the Drizzle schema
- **Goal:** add `stats_snapshot`, `analytics_events`, `dashboard_settings`, and `user.created_at`.
- **Files:** `src/lib/server/db/schema.ts`
- **Steps:**
1. Add `user.createdAt = integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())`. (`$defaultFn` means new rows get a timestamp automatically; existing rows stay `NULL` = "unknown".)
2. Add `statsSnapshot` (`date` text PK; `generatedAt` integer timestamp; `buildKind` text; `configVersion` text; `payload` text; `playerState` text; `config` text).
3. Add `analyticsEvents` (`id` text PK; `name` text; `anonymousId` text; `userId` text nullable FK → user ON DELETE SET NULL; `date` text nullable; `createdAt` integer timestamp; `metadata` text). Indexes on `(name, date)` and `(anonymousId, date)`; unique index `analytics_events_serve_unique` on `(name, anonymousId, date)`.
4. Add `dashboardSettings` (`key` text PK; `value` text; `updatedAt` integer timestamp).
5. Export inferred types.
- **Steps:** run `bun run db:generate`, inspect SQL, then `bun run db:migrate`.
- **Acceptance:** fresh migration applies cleanly to `dev.db`; `sqlite3 dev.db ".schema user"` shows `created_at`; all three tables exist.
- **Tests:** none (schema only) — but run full suite.
### T0.2 — Settings module (thresholds with env defaults)
- **Goal:** a single source of truth for configurable thresholds.
- **Files:** new `src/lib/server/stats/settings.ts`; new `src/lib/types/stats.ts` (types).
- **Steps:**
1. Define keys with defaults: `core_plays_per_week` (4), `churn_days` (30), `resurrect_gap_days` (30), `at_risk_days` (8), `d1_target` (20), `return7_target` (25), `completion_target` (70), `first_try_min` (15), `first_try_max` (35), `share_target` (25), `active_window_days` (180), `anomaly_mad_threshold` (2).
2. `getSettings()` reads `dashboard_settings` and overlays env-var defaults (e.g. `STATS_D1_TARGET`). `updateSettings(partial)` upserts rows.
3. `configVersion()` = short stable hash of the resolved settings.
- **Acceptance:** `getSettings()` returns defaults with an empty table; `updateSettings` persists and is reflected on re-read.
- **Tests:** `tests/stats-settings.test.ts` — defaults, override, upsert, config hash stability.
### T0.3 — Event recording module + client helper
- **Goal:** a single, safe sink for analytics events (B/D/E/F).
- **Files:** new `src/lib/server/stats/events.ts`; new `src/routes/api/events/+server.ts`; new `src/lib/utils/analytics.ts`.
- **Steps:**
1. `recordEvent({ name, anonymousId, userId, date, metadata })` inserts into `analytics_events`. For `name === 'verse_served'`, use `INSERT OR IGNORE` (dedupe on unique index).
2. `POST /api/events` validates `name` against an allowlist (`verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`), caps metadata size, resolves `userId` from `locals.user` if present, and records. Return 204. Never throw to the client callers (fire-and-forget).
3. `src/lib/utils/analytics.ts` exports `track(name, metadata?)` that POSTs to `/api/events` with the lib's anonymous ID (read/create `bibdle-anonymous-id`), ignoring errors. Also export `getOrCreateAnonymousId()` and refactor `src/lib/stores/game-persistence.svelte.ts` to import it (avoid duplicated logic).
- **Acceptance:** calling `track('donate_click')` writes one row; repeated `verse_served` for the same `(anon, date)` writes only once.
- **Tests:** `tests/stats-events.test.ts` — allowlist rejection, metadata cap, verse_served dedupe, user attribution.
### T0.4 — Instrumentation A: guarantee guess sequences for everyone
- **Goal:** all completions persist `guesses`.
- **Files:** `src/routes/api/submit-completion/+server.ts`, `src/routes/+page.svelte` (verify), `src/routes/+page.server.ts` (legacy form action).
- **Steps:** confirm the client always sends `guesses`; ensure the API stores `JSON.stringify(guesses)` whenever the array is non-empty. Update the legacy `submitCompletion` form action to accept and store `guesses` too (or delete the dead action if unused). Do not backfill historical rows.
- **Acceptance:** a guest completion POSTed with `guesses` stores the JSON; `SELECT COUNT(*) FROM daily_completions WHERE guesses IS NOT NULL` grows for guest IDs in dev.
- **Tests:** extend `tests/stats.test.ts` or add `tests/stats-completion.test.ts`.
### T0.5 — Instrumentation B: log "verse served" (game started)
- **Goal:** measure started → completed.
- **Files:** `src/routes/+page.ts`, `src/routes/api/daily-verse/+server.ts`, `src/lib/utils/analytics.ts`.
- **Steps:** in `+page.ts`, read/create the anonymous ID before the fetch and include it in the `POST /api/daily-verse` body. In the endpoint, record `verse_served` (unique per anon+date). Keep the endpoint's response shape unchanged.
- **Acceptance:** loading `/` records exactly one `verse_served` per browser per day, even after reloads.
- **Tests:** `tests/stats-serve.test.ts` (endpoint logic with a test DB) + unit test for dedupe.
### T0.6 — Instrumentation D: share / copy events
- **Goal:** measure virality.
- **Files:** `src/lib/components/WinScreen.svelte`, `src/lib/utils/share.ts` (or call site).
- **Steps:** fire `track('share', { snippet })` when the native-share path is used and `track('share_copy', { snippet })` on clipboard copy. Do not double-count the copy that happens inside `shareResult` fallback. Keep existing Umami `data-umami-*` attributes untouched.
- **Acceptance:** clicking Share writes one `share` row; Copy writes one `share_copy` row; snippet flag correct.
- **Tests:** manual + a small unit test on the helper if logic is extracted.
### T0.7 — Instrumentation E: first-touch referrer / UTM
- **Goal:** acquisition source.
- **Files:** `src/lib/utils/analytics.ts`, root layout or `+page.ts`.
- **Steps:** on first visit per browser (guard with localStorage key), capture `document.referrer` and `?ref=` / `?utm_source=` / `?utm_medium` / `?utm_campaign`, and record a one-time `first_visit` event with that metadata.
- **Acceptance:** first visit with `?utm_source=bluesky` records one `first_visit`; subsequent visits do not.
- **Tests:** unit test on the parsing/guard logic.
### T0.8 — Instrumentation F: monetization clicks
- **Goal:** patron funnel.
- **Files:** `src/lib/components/WinScreen.svelte` (Donate, Discord), `src/lib/components/SocialLinks.svelte` if desired.
- **Steps:** fire `track('donate_click')` and `track('discord_click')` on click (keep the existing Umami attributes).
- **Acceptance:** clicks record events with the right names.
### T0.9 — Add `DISCORD_ADMIN_WEBHOOK` env
- **Files:** `.env.example`, `.env` (local only), `README.md` env list.
- **Acceptance:** documented; not required at boot (digest degrades gracefully when unset).
**Phase 0 exit criteria:** migrations applied; events flowing in dev; `bun run check` + `bun test` green.
---
## Phase 1 — Core console (snapshot + Pulse + Signals skeleton)
### T1.1 — Pure metric functions
- **Goal:** tested, deterministic metrics independent of Svelte/DB reads.
- **Files:** new `src/lib/server/stats/metrics.ts`, types in `src/lib/types/stats.ts`.
- **Steps:** implement, each taking plain rows and a cutoff date:
- `activePlayers(completions, end, windowDays)`
- `newVsReturning(completions, date)` (returning = first-ever completion `< date`)
- `netGrowth(completions, end, windowDays, settings)`
- `d1Return(completions, cohortDate)` and `returnWithin(completions, cohortDate, n)`
- `cohortTriangle(completions, { start, weeks })`
- `timeToSecondPlayHistogram(completions, buckets)`
- `guessHistogram(completions, maxBucket)` + `firstTryRate`
- `streakSurvival(completions)`
- `bookCoverage(verses, completions, start, end)`
- `retentionByBook(completions, verses, minPlays)` and `retentionByGuessBucket`
- `entropy(bookCounts)`
- `weekdayHourHeatmap(completions)`
- **Acceptance:** pure functions; no DB imports; correct on hand-built fixtures.
- **Tests:** `tests/stats-metrics.test.ts` — one test per function incl. edge cases (empty, single player, day boundaries in UTC).
### T1.2 — Segment derivation from stored player state
- **Goal:** render-time configurable segments.
- **Files:** new `src/lib/server/stats/segments.ts`.
- **Steps:** define `PlayerState = { id, firstDate, lastDate, totalPlays, plays7, plays30, streak }`. `deriveSegments(playerState[], asOfDate, settings)` returns `{ new, returning, core, atRisk, churned, resurrected }` counts/sets. Keep it pure.
- **Acceptance:** changing `core_plays_per_week` changes segmentation without recomputation from raw completions.
- **Tests:** `tests/stats-segments.test.ts`.
### T1.3 — Snapshot builder
- **Goal:** compute and persist one snapshot for a date.
- **Files:** new `src/lib/server/stats/snapshot.ts`.
- **Steps:**
1. `buildSnapshot(asOfDate, { kind })`: query completions/verses/users with `date <= asOfDate`, compute all Phase-1 (and later-phase) aggregates, build `playerState` for active players in the trailing `active_window_days`, assemble `payload` JSON + `config` + `configVersion`, and `INSERT OR REPLACE` into `stats_snapshot`.
2. `getSnapshot(date)` reads a row. `ensureSnapshot(date)` builds with `kind: 'lazy'` if missing (guard against races: `INSERT OR IGNORE` then re-read).
3. `isSnapshotStale(date)` compares stored `configVersion` to current `configVersion()` (used to warn in UI; do **not** auto-rebuild unless triggered).
- **Acceptance:** building 2026-09-14 twice is idempotent; the payload only reflects data ≤ 2026-09-14.
- **Tests:** `tests/stats-snapshot.test.ts` — cutoff enforcement, idempotency, lazy race handling.
### T1.4 — Snapshot endpoints
- **Goal:** protected build trigger + admin read.
- **Files:** new `src/routes/api/cron/build-stats/+server.ts`; optional `src/routes/api/admin/rebuild-stats/+server.ts`.
- **Steps:** `POST /api/cron/build-stats` checks `Authorization: Bearer ${CRON_SECRET}`, builds **yesterday** (server `America/New_York`, matching `send-daily-verse`), then triggers the digest (Phase 4; no-op if not built yet). The admin rebuild accepts `{ date }` and requires `isAdmin`.
- **Acceptance:** 401 without secret; builds yesterday with secret.
- **Tests:** `tests/stats-endpoints.test.ts` — auth + date selection.
### T1.5 — Admin route shell + auth gate + date navigator
- **Goal:** `/admin/stats` renders a snapshot and can navigate time.
- **Files:** new `src/routes/admin/stats/+page.server.ts`, `+page.svelte`, `src/lib/components/stats/DateNavigator.svelte`, `src/lib/components/stats/ConsoleChrome.svelte`.
- **Steps:**
1. `+page.server.ts`: if `!isAdmin(locals.user?.email)` → 404 (mirror `/scheduled-verses` behavior). Resolve `date` from `?date=`; default = yesterday. `ensureSnapshot(date)`. Load settings + snapshot + (if `?mature=1`) a matured computation. Return `{ date, generatedAt, buildKind, payload, segments, settings, matured }`.
2. Layout: sticky header with `DateNavigator` (prev/next/date input), "Mature this view" toggle (link with `?mature=1`), link to Settings, and an as-of banner: *"Showing {date} · snapshot built {generatedAt}"*.
- **Acceptance:** non-admin gets 404; admin sees the banner and can move between dates; days with no snapshot get lazily built (flagged `lazy`).
- **Tests:** endpoint/load unit test where feasible.
### T1.6 — Pulse lab
- **Goal:** the 10-second read.
- **Files:** `src/lib/components/stats/Pulse.svelte`, `src/lib/components/charts/Sparkline.svelte`, `src/lib/components/charts/StatusPill.svelte`, `src/lib/components/charts/Gauge.svelte` (LayerChart).
- **Steps:** render KPI cards (Players today, WAU, MAU, Core, Returning share, D1 return, Net growth) with same-weekday deltas and sparklines; a verdict banner from `signals.ts`.
- **Acceptance:** all cards render from the snapshot payload with no live DB query; numbers match a manual query for a test date.
### T1.7 — Signals engine skeleton
- **Goal:** plain-language verdicts + actions.
- **Files:** new `src/lib/server/stats/signals.ts`, `src/lib/components/stats/SignalsFeed.svelte`.
- **Steps:** implement `evaluateSignals(payload, settings)` returning `{ id, severity, title, detail, action }[]`. Implement the six target rules from the plan plus a generic anomaly detector (trailing-28-day median + MAD, weekday-aware). Thresholds from `settings`.
- **Acceptance:** synthesizing payloads produces expected severities; no jargon in strings.
- **Tests:** `tests/stats-signals.test.ts`.
### T1.8 — Settings menu
- **Goal:** edit thresholds; re-segment instantly.
- **Files:** `src/lib/components/stats/SettingsMenu.svelte`, `src/routes/admin/stats/settings/+page.server.ts` (form action), or an inline panel.
- **Steps:** form posts to `updateSettings`; on save, reload the same date (segments are re-derived from `player_state`, so no rebuild needed). Show a note: *"Changing these re-reads history; snapshots themselves are unchanged."* If `configVersion` differs from a snapshot's stored version, show an "outdated snapshot definitions" notice with a rebuild link.
- **Acceptance:** changing `core_plays_per_week` updates Core counts across multiple past dates immediately.
- **Tests:** settings module already covered; add a segment re-derivation test at two thresholds.
### T1.9 — Metric glossary panel
- **Goal:** plain-language definitions on the page.
- **Files:** `src/lib/components/stats/Glossary.svelte`.
- **Steps:** render the glossary table from the plan, driven by a constant map so labels stay consistent.
- **Acceptance:** every metric label used in charts has a glossary entry.
**Phase 1 exit criteria:** `/admin/stats` shows Pulse + Signals for any date, snapshot-only loads, settings work, LayerChart installed.
---
## Phase 2 — Retention Lab & Growth Loop
### T2.1 — Add LayerChart
- **Files:** `package.json` (`bun add layerchart`), lockfile.
- **Acceptance:** `bun run build` succeeds; a trivial `<LineChart>` renders.
### T2.2 — Retention Lab
- **Files:** `src/lib/components/stats/RetentionLab.svelte`, charts: `CohortHeatmap.svelte`, `Histogram.svelte`, `StackedArea.svelte`, `BoxPlot.svelte`.
- **Steps:** cohort triangle heatmap (weekly cohorts, toggle daily), D1/D7/D30 curves as small multiples, time-to-second-play histogram, segment-mix stacked area, retention-by-segment box/grouped bars. Captions per plan; segments from `player_state`.
- **Acceptance:** heatmap cells match a manual cohort calculation; captions plain-language.
### T2.3 — Growth Loop
- **Files:** `src/lib/components/stats/GrowthLoop.svelte`, charts: `AnnotatedArea.svelte`, `Waterfall.svelte`, `Funnel.svelte`.
- **Steps:** new-players/day area with 7-day MA; if manual annotations are added later, support marker pins (design the prop now, render none if absent); waterfall (new + resurrected − churned); lifecycle funnel (players reaching 1/2/5/10/25/50 plays); signup funnel using `user.created_at` + `verse_served`/completions.
- **Acceptance:** waterfall components sum to net growth; funnel is monotonic non-increasing.
### T2.4 — Mature view
- **Files:** `src/lib/server/stats/snapshot.ts` (add `buildMatured(date)`), `+page.server.ts`.
- **Steps:** implement on-demand matured computation (uses all data after `date` for cohort resolution) and clearly label it. Do not store matured results as snapshots.
- **Acceptance:** `?mature=1` resolves old D7/D30 where possible; banner says "Matured — uses data after this date."
**Phase 2 exit criteria:** Retention & Growth labs render from snapshots; mature toggle works.
---
## Phase 3 — Verse Mix Lab & Habit Lab
### T3.1 — Verse Mix Lab
- **Files:** `src/lib/components/stats/VerseMixLab.svelte`, charts reuse `StackedBar`, `BubbleScatter.svelte`, `CoverageHeatmap.svelte`, `StepLine.svelte`.
- **Steps:** mix balance (book/section/testament stack over time), 66-book coverage heatmap, freshness (repeat intervals + entropy line), retention-by-book bubble scatter, retention-by-guess-bucket, achievability band + step curve, regression alarm table, before/after curated-mix annotated comparison. Include the "giveaway detector" table (high first-try books/windows).
- **Acceptance:** Revelation/Genesis flags as easy outliers and Micah as hard on the current data; captions explain each.
### T3.2 — Habit Lab
- **Files:** `src/lib/components/stats/HabitLab.svelte`, charts: `SurvivalLine.svelte`, `Ridgeline.svelte`, `CalendarHeatmap.svelte`, `WeekdayHourHeatmap.svelte`.
- **Steps:** streak survival curve, streak length distribution, calendar heatmap, weekday×hour heatmap (note server-time caveat in the caption).
- **Acceptance:** survival curve is monotonic; calendar matches completion counts.
**Phase 3 exit criteria:** all five labs complete; page passes `bun run check`.
---
## Phase 4 — Delivery
### T4.1 — Daily digest
- **Files:** new `src/lib/server/stats/digest.ts`; call from `src/routes/api/cron/build-stats/+server.ts`.
- **Steps:** after a successful build, compose ~6 plain lines (as-of date; DAU/WAU/MAU; new vs returning; D1; top signal; link) and POST to `DISCORD_ADMIN_WEBHOOK`. No-op with a log if unset. Never fail the build on digest failure.
- **Acceptance:** running the cron locally posts the digest (or logs a skip); build still succeeds if the webhook errors.
- **Tests:** unit test message composition.
### T4.2 — Slim public community widget
- **Files:** rewrite `src/routes/global/+page.server.ts` and `+page.svelte` (or create a `CommunityStats.svelte` embedded on `/about`).
- **Steps:** read the **latest snapshot** and render only: all-time completions, players this week/month, yesterday's completions + average guesses, 30-day calendar heatmap. Label "as of {date}." No sensitive metrics. Mobile-friendly.
- **Acceptance:** public page contains no retention/churn/signup/revenue figures; loads without heavy queries (reads snapshot).
### T4.3 — Remove/replace any lingering `/global` references
- **Steps:** update README route table and any nav links. Keep old `+page.server.ts` logic out of the new console.
- **Acceptance:** README matches reality.
### T4.4 — Docs & env
- **Steps:** update `README.md` (routes, API, schema, env: `DISCORD_ADMIN_WEBHOOK`), and `todo.md` if relevant.
- **Acceptance:** a new contributor can find `/admin/stats` and the cron endpoint from the README.
**Phase 4 exit criteria:** digest + public widget live; docs current.
---
## Phase 5 — Deferred / roadmap (do not build unless approved)
- **C — feature-usage events:** emit `mode_enter` (imposter/practice), `hint_reveal`, `progress_view`, `submit_verse_*`; add the Feature Impact panel.
- **Manual marketing annotations:** small table + UI pins on the Growth Loop timeline.
- **CSV/JSON export** of snapshot payloads.
- **Email digest** (requires `nodemailer`).
---
## Cross-cutting requirements
### C1 — Testing
- All `src/lib/server/stats/*.ts` pure logic must have unit tests with fixtures (no real DB) where possible.
- DB-touching functions tested against `TEST_DATABASE_URL`.
- Maintain the phase verification gate: `bun run check && bun test && bun run build`.
### C2 — Accessibility & mobile
- Charts keyboard-navigable where interactive; provide text/table fallbacks for color-only heatmaps.
- Responsive: KPI grid 2-col on mobile, charts fluid width; tables horizontally scrollable.
- Respect the existing dark theme (`bg-white/10 dark:bg-white/5`, etc.) and the `Container`/`CollapsibleTable` components.
### C3 — Performance
- Snapshot build is a batch job; acceptable to scan tables. Page loads must not scan raw tables.
- Keep `player_state` bounded (~180-day active window); JSON may be gzipped if it grows.
### C4 — Copy & tone
- Every chart caption ≤ 2 short sentences.
- No metric named "rate of change," "velocity," or "acceleration."
- Always include a comparison and a "what to do."
---
## Definition of Done (overall)
1. `/admin/stats` is admin-only and serves a once-daily, as-of-yesterday snapshot without live DB reads.
2. Any past date can be viewed (point-in-time), with `—` where data didn't exist.
3. "Mature this view" works and is clearly labeled.
4. All five labs + Pulse + Signals + Glossary render from snapshots using LayerChart; layouts are mobile-friendly.
5. Segment thresholds are editable in-app and re-derive all history instantly.
6. Instrumentation A/B/D/E/F + `user.created_at` actively record data.
7. Daily digest posts; slim public widget shows only safe aggregates.
8. `bun run check`, `bun test`, `bun run build` all pass; README/env docs updated.
---
## Appendix A — Snapshot `payload` shape (extend per phase)
```ts
interface SnapshotPayload {
asOfDate: string;
activity: { dau: number; wau: number; mau: number; core: number; returningShare: number; newPlayers: number; resurrected: number; churned: number; netGrowth: number };
dailySeries: { date: string; completions: number; newPlayers: number; started: number }[]; // last ~90d
kpis: { d1Return: number | null; return7: number | null; overallReturnRate: number; completionRate: number | null; shareRate: number | null; firstTryRate: number; avgGuesses: number };
retention: { cohorts: { weekStart: string; sizes: number[]; rates: (number|null)[] }[]; curves: { d1: Series; d7: Series; d30: Series }; timeToSecondPlay: Bin[]; segmentMix: { date: string; new: number; returning: number; resurrected: number; atRisk: number; churned: number }[] };
verseMix: { coverage: { bookId: string; days: number }[]; sectionMix: Series[]; entropy: Series; repeatIntervals: number[]; firstTryByBook: { bookId: string; plays: number; firstTryPct: number; avgGuesses: number }[]; retentionByBook: { bookId: string; d1: number; plays: number }[]; retentionByGuessBucket: { bucket: string; d1: number }[] };
habit: { streakSurvival: { length: number; p: number }[]; streakLengths: Bin[]; calendar: { date: string; count: number }[]; weekdayHour: number[][] };
signals: Signal[];
}
```
## Appendix B — Event names (allowlisted)
`verse_served` · `share` · `share_copy` · `first_visit` · `donate_click` · `discord_click`
Metadata examples: share `{ snippet: boolean }`; first_visit `{ referrer, utm_source, utm_medium, utm_campaign }`.
## Appendix C — Settings keys (defaults)
| Key | Default | Meaning |
|---|---|---|
| `core_plays_per_week` | 4 | Core player threshold |
| `churn_days` | 30 | Days inactive → churned |
| `resurrect_gap_days` | 30 | Gap that makes a return "resurrected" |
| `at_risk_days` | 8 | Days inactive → at-risk |
| `active_window_days` | 180 | Player-state horizon stored per snapshot |
| `d1_target` | 20 | D1 return target (%) |
| `return7_target` | 25 | 7-day return target (%) |
| `completion_target` | 70 | Started→completed target (%) |
| `first_try_min` / `first_try_max` | 15 / 35 | Achievability band (%) |
| `share_target` | 25 | Share rate target (%) |
| `anomaly_mad_threshold` | 2 | Robust z-score cutoff |
## Appendix D — Signal rule template
```ts
{ id: 'd1_return_low', severity: 'warn',
title: 'New players are not coming back',
detail: `D1 return is ${v}% (target ${t}%).`,
action: 'Test a "come back tomorrow" prompt on the win screen.' }
```
+340
View File
@@ -0,0 +1,340 @@
# Bibdle Statistics Dashboard — Plan
**Status:** design agreed with the project owner · **Supersedes:** the public `/global` dashboard
**Deliverable:** a private, once-daily, point-in-time **Growth Console** at `/admin/stats`, plus a slim public community-stats widget.
> **Implementing this plan?** Use the agent-ready task list in [`statistics-implementation-checklist.md`](./statistics-implementation-checklist.md). It is ordered, dependency-aware, and includes acceptance criteria and tests per task.
---
## 1. Why we're replacing `/global`
`/global` was a good start but it is now a wall of aggregates and second derivatives ("completions velocity", "user acceleration") with **no verdict, no feature data, and no content data**. It answers *"did a number move?"* but not *"why, and what should I change?"*
Three structural problems:
1. **No "so what."** Every chart is descriptive. Nothing tells you what to improve, change, or add.
2. **No product surface.** There is no read on features (modes, hints, sharing), funnels (started → completed → shared), or content (which books/verses help or hurt retention).
3. **Wrong delivery.** It queries the DB on every load, shows partial "today" data, and offers no time machine.
**Goal of the new console:** a product review you can skim in 60 seconds, ordered around the questions that matter, that ends every section with a plain-language verdict and recommended action — and that can be scrolled back to any past day.
### Live baseline (pulled from bibdle.com/global, as of 2026-09-15)
| Metric | Value |
|---|---|
| All-time completions | 31,775 |
| Unique players (all-time) | 6,589 |
| Registered users | 98 |
| DAU / WAU / MAU | 102 / 363 / 852 |
| Net player growth (7d) | ~flat (−1: 175 new vs 176 churned) |
| Overall return rate (ever played 2×) | 22.8% |
| 7-day new-player return rate | 12.2% |
| Survival after 2 plays → 3rd | 67.2% |
| 7-day / 30-day retention | ~61% / ~51% |
| Difficulty | mean 4.13 guesses, median 3, **33.7% first-try** |
| Easiest / hardest books | Revelation 1.34 avg / 83% first-try · Micah 7.84 avg / 12% first-try |
**The core story:** acquisition is replacing churn but not growing. ~77% of all-time players played exactly once; the ~23% who return are very sticky. The leak is the **first session** (and possibly verse mix/achievability), not the endgame.
---
## 2. Decisions locked with the owner
| # | Decision | Choice |
|---|---|---|
| 1 | Audience | **Private founder dashboard**, replacing `/global` internally. Reuse `ADMIN_EMAIL` gate (as `/scheduled-verses` does). |
| 2 | Public presence | Keep a **slim public community-stats widget** (no sensitive/business data). |
| 3 | Freshness | **Once-daily snapshot, "as of yesterday."** Page load never queries the DB. |
| 4 | History | **Point-in-time by default, with a "Mature this view" toggle.** |
| 5 | Snapshot storage | **Store one immutable snapshot per day** (fidelity + speed + no-data fallbacks). |
| 6 | Config | Segment thresholds configurable at **render time** via a settings menu (env defaults). Requires storing compact per-player state in snapshots. |
| 7 | Data sources | **Existing SQLite only.** No Umami, no Rybbit, self-contained and durable. |
| 8 | North star | Lead with **DAU + MAU**, with **Core players (4+/7d)** as the durability signal. |
| 9 | Signals | **Both:** explicit targets for ~6 core metrics + anomaly detection everywhere else. |
| 10 | Charts | **LayerChart** (Svelte 5 native; verified `layerchart@2.5.0` peers `svelte ^5`). |
| 11 | Tone | **Plain language.** Short factual sentences. No jargon, no paragraphs next to charts. |
| 12 | Mobile | Must be **mobile-friendly**. |
| 13 | Digest | **Scheduled daily digest** of the snapshot (Discord admin webhook default; email optional). |
| 14 | Instrumentation approved | **A, B, D, E, F** + `user.created_at` (see §7). **C deferred** (roadmap). |
**Design principles (apply to every chart):**
- Every visual has a **plain-English caption** and, where useful, a **one-line "what to do."**
- Never show a number without a **comparison** (previous period, same weekday, or baseline).
- **Weekday-align** daily comparisons so weekends don't masquerade as growth.
- Prefer **shape** over derivatives: show "players per day" and a trend line, not "acceleration."
---
## 3. Information architecture
```
/admin/stats
├─ Global chrome: date navigator · "Mature this view" toggle · Settings · as-of banner
├─ 1. Pulse (10-second read + verdict)
├─ 2. Growth Loop (new · active · returning · churned · funnel)
├─ 3. Retention Lab (cohorts, curves, time-to-return, segments)
├─ 4. Verse Mix Lab (coverage, freshness, retention by book & difficulty)
├─ 5. Habit Lab (streaks, calendar, timing)
├─ 6. Signals & Actions (rule-based verdicts + recommended actions)
├─ 7. Feature Impact (stub until C is instrumented)
└─ 8. Notes & Glossary (plain-language metric definitions)
```
The header is a **time machine**: a date picker plus prev/next arrows, always labeled e.g. *"Showing 2026-09-14 (snapshot built 2026-09-15)."* Days before a metric existed show **"—"**.
---
## 4. The labs (what you see, why, and the chart type)
Chart types in **bold**. Every section ends in a one-sentence verdict from the Signals engine.
### 4.1 Pulse
| Element | Plain-English caption | Chart |
|---|---|---|
| Players today | "People who finished yesterday's verse." + same-weekday change | **KPI card + sparkline** |
| Players this week / month | "Distinct players in the last 7 / 30 days." | **KPI card + delta** |
| Core players | "Players who finished 4+ times this week — your habit-holders." | **KPI card + trend** |
| Returning share | "Of yesterday's players, how many had played before." | **KPI + mini stacked bar** |
| D1 return rate | "Of people who played for the first time, how many came back the next day." | **KPI + gauge vs target** |
| Net player growth (7d) | "New and returning players minus those who stopped." | **KPI + waterfall delta** |
| Verdict banner | e.g. "Stable. Biggest leak: 88% of new players never return." | **status pill** |
*Decision it informs:* which lab to open; whether the week was a win.
### 4.2 Growth Loop
| Element | Plain-English caption | Chart |
|---|---|---|
| New players per day | "First-time players each day." Annotated with HN/Bluesky/Discord posts. | **Annotated area + marker pins** |
| Acquisition vs churn | "New + resurrected − churned = net." | **Waterfall** |
| Lifecycle funnel | "Of everyone who ever played: how many returned 2×, 5×, 10×, 25×, 50×." | **Funnel** |
| Retention curves | D1 / D7 / D30 over time. | **Small-multiple line** |
| Signup funnel | "Of first-time players, how many created an account." (needs `user.created_at` + starts) | **Funnel / line** |
*Decision it informs:* is the problem acquisition or activation; did a marketing post actually move retention, not just traffic.
### 4.3 Retention Lab
| Element | Plain-English caption | Chart |
|---|---|---|
| Cohort retention triangle | "Each row is a starting week; each column is weeks later. Darker = more came back." | **Heatmap** |
| Time-to-second-play | "How many days until a first-timer plays again." | **Histogram** |
| Segment mix over time | New · Returning · Resurrected · At-risk · Churned. | **Stacked area** |
| Retention by segment | "Do account-holders / core players stick longer?" | **Box plot or grouped bars** |
| Overall return rate | "Share of all players who ever played more than once." | **KPI + target** |
*Decision it informs:* whether onboarding/notification changes bend retention; which segment is healthiest.
### 4.4 Verse Mix Lab
This lab treats **verse mix and appealability** (not raw difficulty) as the retention lever. Hard *and* easy days can both drive churn — one by frustration, one by boredom.
| Element | Plain-English caption | Chart |
|---|---|---|
| Mix balance over time | "Are we spreading across all 66 books / 9 sections / 2 testaments?" | **Stacked bars / treemap** |
| Coverage heatmap | 66 books × times featured. | **Heatmap** |
| Freshness | "How often the same book or window repeats." (repeat interval + entropy) | **Histogram + line** |
| Retention by book | For each book, the next-day return rate of players who got it. | **Bubble scatter** (x=plays, y=D1 return, size=first-try%) |
| Retention by difficulty | "Do easy or hard days make people leave?" | **Line/grouped bars by guess bucket** |
| Achievability band | First-try % and solve-by-N against a target band. | **Histogram + step line** |
| Regression alarms | Books/windows whose cohort churns above average. | **Table with inline bars** |
| Before/after the curated mix system | Same metrics pre- vs post-launch. | **Annotated line** |
*Decision it informs:* whether the new curated verse-mix system works; which books/windows to retire or feature more; submissions policy.
### 4.5 Habit Lab
| Element | Plain-English caption | Chart |
|---|---|---|
| Streak survival | "If you've played N days in a row, how likely are you to play day N+1." | **Survival line** |
| Streak lengths | Distribution of current streaks. | **Ridgeline / histogram** |
| Calendar heatmap | Daily completions, GitHub-style. | **Calendar heatmap** |
| Weekday × hour | When people play. (Note: `completedAt` is server time, approximate.) | **2-D heatmap** |
*Decision it informs:* whether to invest in streaks, reminders, and Discord/content timing.
### 4.6 Signals & Actions
Plain-language cards, each: **metric · current vs target · trend · interpretation · suggested action.**
> Example: *"D1 return is 12% (target 20%). Most first-timers never see day two. → Test a 'come back tomorrow' prompt on the win screen."*
> Example: *"Revelation averages 1.3 guesses (83% first-try). Likely self-revealing text. → Exclude or down-weight for the curated mix."*
### 4.7 Feature Impact (stub)
Since feature-usage events (C) are deferred, this ships as a **"needs data"** panel listing exactly what would be shown once C is added (Imposter entries, hint reveals, progress-page visits, submissions).
---
## 5. Plain-language metric glossary (shown in the page)
Each metric has a friendly name, a one-sentence definition, and a "what to do."
| Metric | Plain definition | What to do if it's off |
|---|---|---|
| Players today | Finished the puzzle yesterday | Investigate content or traffic |
| WAU / MAU | Played at least once in 7 / 30 days | Reach vs habit |
| Core players | 4+ finishes this week | If falling, retention problem |
| Returning share | Played before vs first time | Low = acquisition-heavy, leaky |
| D1 return | First-timers who came back next day | The #1 activation lever |
| Return rate (any) | Played again within N days | Onboarding quality |
| Overall return rate | Ever played more than once | Overall stickiness |
| Net growth | New + resurrected − churned | Is the base expanding? |
| Completion rate | Started → finished | Friction/confusion (needs starts) |
| Signup rate | Players who made an account | Account value |
| First-try rate | Solved with no wrong guesses | Too high = giveaway; too low = brutal |
| Share rate | Shares ÷ finishes | Virality (needs D) |
| Streak survival | Chance a streak continues | Streak feature ROI |
---
## 6. Data model & architecture
### 6.1 New tables (all additive)
**`stats_snapshot`** — one immutable row per as-of day.
| Column | Type | Notes |
|---|---|---|
| `date` | text PK | as-of date `YYYY-MM-DD` |
| `generated_at` | integer | build time |
| `build_kind` | text | `scheduled` \| `lazy` \| `reconstructed` |
| `config_version` | text | hash of thresholds used at build |
| `payload` | text (JSON) | all aggregate series/lab data |
| `player_state` | text (JSON, gzip) | compact per-player state as of that day, for render-time segmentation |
| `config` | text (JSON) | copy of thresholds used |
**`analytics_events`** — generic, extensible event log (unlocks B/D/E/F now, C later).
| Column | Type | Notes |
|---|---|---|
| `id` | text PK | |
| `name` | text | `verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`, … |
| `anonymous_id` | text | pseudonymous |
| `user_id` | text, nullable | |
| `date` | text, nullable | game date when relevant |
| `created_at` | integer | server timestamp |
| `metadata` | text (JSON) | referrer/utm, snippet flag, channel… |
Indexes: `(name, date)`, `(anonymous_id, date)`. **Unique** on `(name, anonymous_id, date)` for `verse_served` (dedupes reloads → "players who started").
**`dashboard_settings`** — key/value (e.g. `core_plays_per_week`, `churn_days`, `resurrect_gap_days`, `d1_target`, `first_try_band`, `share_target`). Env vars provide defaults; the settings menu writes here.
**`user.created_at`** — new timestamp column (see §7).
### 6.2 Snapshot build
- **Trigger:** daily cron hits a protected `POST /api/cron/build-stats` (bearer `CRON_SECRET`, same pattern as `/api/send-daily-verse`). It builds the most recent date whose global data window has closed and sends the digest.
- **Lazy fallback:** `/admin/stats` load builds a missing snapshot on demand (guarded against races, marked `lazy`).
- **Backfill:** an admin action/script can reconstruct a date range from raw data (marked `reconstructed`).
- **Computation cutoff:** data is grouped by the verse `date` (a player's local day), not a UTC 24-hour window. A date is final after `D+1 12:00Z` (UTC-12 has finished the day); until then a build is a non-persisted point-in-time view. Snapshots are only persisted once the window has closed.
- **Player state (for render-time config):** per player active in the trailing ~180 days, a compact tuple `[anonId, firstDate, lastDate, totalPlays, plays7, plays30, currentStreak]`. Thresholds applied at render time, so changing them re-segments **all history instantly** while the state stays frozen as of that day.
- **Retention/pruning:** keep all snapshots (small); optionally prune `player_state` older than a configurable horizon later.
### 6.3 "Mature this view"
A toggle (off by default). When on, the selected date's metrics are recomputed on demand using data *after* that date (so old cohorts have resolved D7/D30), clearly labeled **"Matured — uses data after this date."** This is the only mode allowed to query the DB directly.
### 6.4 Auth & privacy
- `/admin/stats` gated to `ADMIN_EMAIL` via existing `src/lib/server/admin.ts`.
- Aggregate-only rendering; never display emails or raw IDs. Anonymous IDs are pseudonymous.
- `/api/stats` name already exists (per-user stats) — the new snapshot endpoints will use distinct names (`/api/admin/stats/...`) to avoid collision.
### 6.5 Public community widget
Redesigned slim `/global` (or an embeddable component) showing only: all-time completions, players this week/month, yesterday's completions and average guesses, and a 30-day calendar heatmap. **No** retention, churn, signup, or revenue data. Labeled "as of yesterday."
---
## 7. Instrumentation plan (approved)
| ID | What | Where it wires in | Cost |
|---|---|---|---|
| **A** | Store guess sequences for **all** players | `src/routes/api/submit-completion/+server.ts` already persists `guesses` when provided; confirm the client always sends them and remove any conditional nulling. Historical rows stay null (correct point-in-time). | Tiny |
| **B** | Log "verse served" (game started) | Client generates/reads `bibdle-anonymous-id` in `src/routes/+page.ts` and sends it in the `POST /api/daily-verse` body; endpoint records a `verse_served` event, unique per `(anonymous_id, date)`. Gives start→completion funnel and bounce. | Small |
| **D** | Share / virality events | In `src/lib/utils/share.ts` / `WinScreen.svelte`, record `share` and `share_copy` events (with `snippet` flag) via a tiny client helper. Enables share rate + viral proxy. | Small |
| **E** | Referrer / UTM capture | On first visit (client), read `document.referrer` and `?ref=`/`?utm_source=`; store a `first_visit` event keyed to the anonymous ID. Enables acquisition source. | Small |
| **F** | Monetization clicks | Record `donate_click` and `discord_click` events from the win-screen/footer links. Enables the patron funnel. | Tiny |
| **G** | `user.created_at` | Add column + set on signup in `src/lib/server/auth.ts` (and OAuth paths). Existing users show "unknown." Enables signup-per-day, time-to-signup, and the visitor→signup funnel. | Small |
**Deferred (roadmap):** **C — feature-usage event log** (Imposter/hints/progress/submissions). The `analytics_events` table makes this a trivial follow-on; only the emit points need adding.
---
## 8. Signals engine (targets + anomaly detection)
**Explicit targets (defaults; editable in Settings):**
| Metric | Target | Red |
|---|---|---|
| D1 return rate | ≥ 20% | < 12% |
| 7-day return (any) | ≥ 25% | < 15% |
| Completion rate (started→finished) | ≥ 70% | < 55% |
| First-try rate (achievability band) | 15–35% | > 45% or < 8% |
| Share rate | ≥ 25% | < 15% |
| Core players (4+/7d) | non-declining WoW | down ≥ 2 weeks |
*Completion-rate and share-rate targets are placeholders until 2 weeks of new data exist.*
**Anomaly detection (everything else):** compare each metric to its own trailing 28-day median (weekday-aware), flag moves beyond a robust threshold (e.g. > 2× MAD). Output is a plain sentence, not a chart annotation.
Each rule = `{ metric, comparator, target, severity, interpretation, recommended_action }`. Stored as code (not user-editable), with thresholds from settings.
---
## 9. Chart inventory & LayerChart mapping
| Chart | Used in | LayerChart approach |
|---|---|---|
| Sparkline KPI | Pulse | minimal `<LineChart>` |
| Annotated area | Growth Loop | `<AreaChart>` + marker overlay |
| Waterfall | Growth Loop | floating `<Bar>` segments |
| Funnel | Growth Loop, signups | stacked `<Rect>`/`<Bar>` |
| Cohort heatmap | Retention | `<Chart>` + grid of `<Rect>` |
| Calendar heatmap | Habit, public widget | `<Chart>` + weekday×week `<Rect>` grid |
| 2-D heatmap | Habit | `<Chart>` + `<Rect>` grid |
| Bubble scatter | Verse Mix | `<ScatterChart>` with radius scale |
| Histogram | Retention, Verse Mix, Habit | `<BarChart>` from binned data |
| Step / survival line | Verse Mix, Habit | `<LineChart>` with step interpolation |
| Ridgeline | Habit | stacked `<AreaChart>`/density |
| Box plot | Retention | custom `<Rect>`+`<Line>` |
| Stacked area / bars | Retention, Verse Mix | `<AreaChart>`/`<BarChart>` stacked |
| Gauge / status pill | Pulse | custom `<Chart>` arc + badge |
| Table with inline bars | Verse Mix, Signals | existing `CollapsibleTable` |
**Rendering model:** the page fetches the day's snapshot once (already computed), then renders charts client-side. No live DB reads in the normal path → matches the "once per day" requirement.
---
## 10. Delivery: daily digest
- Runs right after the snapshot build (same cron endpoint), once per day.
- **Default channel:** a dedicated Discord admin webhook (`DISCORD_ADMIN_WEBHOOK`), reusing the existing webhook pattern. Optionally email later (note: `nodemailer` is **not** currently a dependency; adding email = adding a dependency).
- **Content (plain, ~6 lines):** as-of date · DAU/WAU/MAU · new vs returning · D1 · the single top signal · link to `/admin/stats`.
---
## 11. Phasing
| Phase | Scope |
|---|---|
| **0 — Plumbing** | Add `stats_snapshot`, `analytics_events`, `dashboard_settings`, `user.created_at`; wire instrumentation A/B/D/E/F; client event helper. |
| **1 — Core** | Snapshot builder + cron/lazy; date navigator; Pulse; Signals skeleton; Settings menu; plain-language glossary. |
| **2 — Retention & Growth** | Retention Lab (cohort triangle, curves, time-to-return, segments) + Growth Loop (waterfall, funnel, annotated area, signup funnel). |
| **3 — Content & Habit** | Verse Mix Lab (coverage, freshness, retention-by-book/difficulty, before/after) + Habit Lab (streaks, calendar, timing). |
| **4 — Delivery** | Daily digest; slim public community widget; "Mature this view". |
| **5 — Roadmap** | Feature-usage events (C) + Feature Impact panel; manual marketing-event annotations; CSV/JSON export; optional email digest. |
---
## 12. Open questions / to finalize during build
1. **Digest channel:** Discord admin webhook (default) vs. adding nodemailer for email.
2. **Target values** in §8 — confirm after ~2 weeks of instrumented data.
3. **Snapshot retention:** keep `player_state` forever vs. prune beyond a horizon.
4. **Public widget placement:** standalone `/global` vs. a section on `/about` vs. both.
5. **Manual marketing annotations** (nice-to-have, not in approved list): a tiny table to pin HN/Bluesky/Discord posts on the Growth Loop timeline.
6. **C (feature-usage events):** decide when to add — the table is ready, only emit points remain.
---
## 13. Success criteria for this dashboard
- You can answer, in under 60 seconds and without jargon: *are we growing, who's returning, and what's the one thing to fix?*
- The console tells you whether a **verse-mix** change helped retention, not just whether guesses changed.
- You can scroll to any past day and see what was true then, with honest "—" where data didn't exist.
- Adding a future metric (e.g. feature events) requires only emitting events — no rebuild of the dashboard architecture.
+94
View File
@@ -0,0 +1,94 @@
import { describe, test, expect } from 'bun:test';
import {
cdata,
escapeXml,
feedFingerprint,
formatRFC822,
formatReadableDate,
formatVerseText,
latestModification,
todayInTimeZone
} from '$lib/server/rss';
describe('RSS date formatting', () => {
test('formatReadableDate uses the calendar day, independent of server TZ', () => {
expect(formatReadableDate('2026-09-18')).toBe('September 18, 2026');
expect(formatReadableDate('2026-01-01')).toBe('January 1, 2026');
});
test('formatRFC822 emits a valid GMT timestamp', () => {
expect(formatRFC822('2026-01-15')).toBe('Thu, 15 Jan 2026 05:00:00 GMT');
expect(formatRFC822('2026-09-18')).toBe('Fri, 18 Sep 2026 04:00:00 GMT');
});
test('formatRFC822 handles daylight saving (EST -05:00 vs EDT -04:00)', () => {
// Winter: midnight New York is 05:00 UTC.
expect(formatRFC822('2026-01-15')).toContain('05:00:00 GMT');
// Summer: midnight New York is 04:00 UTC.
expect(formatRFC822('2026-07-15')).toContain('04:00:00 GMT');
// Never a bare, ambiguous abbreviation like EST/EDT.
expect(formatRFC822('2026-07-15')).not.toMatch(/\bE[SD]T\b/);
});
test('todayInTimeZone respects the reference timezone', () => {
// 02:00 UTC on the 23rd is still the evening of the 22nd in New York.
expect(
todayInTimeZone('America/New_York', new Date('2026-09-23T02:00:00Z'))
).toBe('2026-09-22');
// 04:00 UTC on the 23rd is midnight in New York.
expect(
todayInTimeZone('America/New_York', new Date('2026-09-23T04:00:00Z'))
).toBe('2026-09-23');
});
});
describe('RSS content fingerprint', () => {
const row = { date: '2026-09-18', reference: 'Leviticus 19:9-11', verseText: 'a' };
test('is stable for identical content', () => {
expect(feedFingerprint([row])).toBe(feedFingerprint([{ ...row }]));
});
test('changes when verse text changes', () => {
expect(feedFingerprint([row])).not.toBe(feedFingerprint([{ ...row, verseText: 'b' }]));
});
test('changes when the set of days changes', () => {
expect(feedFingerprint([row])).not.toBe(
feedFingerprint([row, { ...row, date: '2026-09-17' }])
);
});
});
describe('latestModification', () => {
const fallback = new Date('2026-09-18T00:00:00Z');
test('returns the newest createdAt', () => {
const older = new Date('2026-09-16T10:00:00Z');
const newer = new Date('2026-09-17T10:00:00Z');
expect(
latestModification([{ createdAt: older }, { createdAt: newer }], fallback)
).toEqual(newer);
});
test('falls back when rows have no timestamp', () => {
expect(latestModification([{ createdAt: null }], fallback)).toEqual(fallback);
expect(latestModification([], fallback)).toEqual(fallback);
});
});
describe('XML serialization helpers', () => {
test('escapeXml escapes all predefined entities', () => {
expect(escapeXml(`<a href="x">&'</a>`)).toBe(
'&lt;a href=&quot;x&quot;&gt;&amp;&apos;&lt;/a&gt;'
);
});
test('cdata splits embedded CDATA terminators', () => {
expect(cdata('a]]>b')).toBe('<![CDATA[a]]]]><![CDATA[>b]]>');
});
test('formatVerseText capitalizes and replaces trailing punctuation', () => {
expect(formatVerseText('for God so loved the world,')).toBe('For God so loved the world...');
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, test, expect } from 'bun:test';
import { parseAcquisition } from '$lib/utils/acquisition';
describe('parseAcquisition', () => {
test('extracts utm parameters and ref', () => {
const a = parseAcquisition('?utm_source=bluesky&utm_medium=social&utm_campaign=launch', '');
expect(a.utm_source).toBe('bluesky');
expect(a.utm_medium).toBe('social');
expect(a.utm_campaign).toBe('launch');
});
test('falls back to ref for the source', () => {
expect(parseAcquisition('?ref=hn', '').utm_source).toBe('hn');
});
test('keeps an external referrer', () => {
const a = parseAcquisition('', 'https://news.ycombinator.com/item?id=1');
expect(a.referrer).toBe('https://news.ycombinator.com/item?id=1');
});
test('blank and whitespace values become null', () => {
const a = parseAcquisition('?utm_source=%20%20', '');
expect(a.utm_source).toBeNull();
expect(a.referrer).toBeNull();
});
test('caps absurdly long values', () => {
const a = parseAcquisition(`?utm_source=${'x'.repeat(500)}`, '');
expect(a.utm_source!.length).toBe(256);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { describe, test, expect } from 'bun:test';
import { composeDigest } from '$lib/server/stats/digest';
import type { SnapshotPayload } from '$lib/types/stats';
const payload: SnapshotPayload = {
asOfDate: '2026-09-14',
activity: {
dau: 102,
wau: 363,
mau: 852,
core: 61,
returningShare: 38,
newPlayers: 20,
resurrected: 4,
churned: 25,
netGrowth: -1,
corePrevious: 60,
previous: null
},
dailySeries: [],
kpis: {
d1Return: 12.2,
return7: 18,
overallReturnRate: 22.8,
completionRate: null,
shareRate: null,
firstTryRate: 33.7,
avgGuesses: 4.1
},
retention: { cohorts: [], curves: { d1: [], d7: [], d30: [] }, timeToSecondPlay: [], segmentMix: [] },
verseMix: {
coverage: [],
testamentMix: [],
entropy: [],
repeatIntervals: [],
firstTryByBook: [],
retentionByBook: [],
retentionByGuessBucket: [],
achievability: []
},
habit: { streakSurvival: [], streakLengths: [], calendar: [], weekdayHour: [] },
growth: { lifecycleFunnel: [], signups: [] },
totals: { completions: 31775, players: 6589, yesterdayCompletions: 102, yesterdayAvgGuesses: 4.1, bestDay: null },
signals: [
{
id: 'd1_return',
severity: 'bad',
title: 'D1 return',
detail: 'D1 return is 12.2%, below the 20% target.',
action: 'Test a "come back tomorrow" prompt.'
}
],
meta: { hasStartedData: false, hasShareData: false, firstEventDate: null }
};
describe('composeDigest', () => {
test('includes the core numbers in plain language', () => {
const message = composeDigest(payload);
expect(message).toContain('2026-09-14');
expect(message).toContain('363');
expect(message).toContain('12.2');
expect(message).toContain('Top signal: D1 return');
expect(message).toContain('/admin/stats?date=2026-09-14');
});
test('is at most a handful of lines', () => {
expect(composeDigest(payload).split('\n').length).toBeLessThanOrEqual(6);
});
test('shows an em-dash for missing metrics', () => {
const message = composeDigest({
...payload,
kpis: { ...payload.kpis, d1Return: null, return7: null }
});
expect(message).toContain('D1 return —%');
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { eq, and } from 'drizzle-orm';
import { testDb } from '$lib/server/db/test';
import { analyticsEvents } from '$lib/server/db/schema';
import {
recordEvent,
isEventName,
encodeMetadata
} from '$lib/server/stats/events';
import { MAX_METADATA_BYTES } from '$lib/types/stats';
async function clear() {
await testDb.delete(analyticsEvents);
}
describe('analytics events', () => {
beforeEach(clear);
afterEach(clear);
test('allowlist accepts known event names and rejects others', () => {
expect(isEventName('verse_served')).toBe(true);
expect(isEventName('donate_click')).toBe(true);
expect(isEventName('not_a_real_event')).toBe(false);
expect(isEventName('')).toBe(false);
});
test('metadata is capped', () => {
const small = encodeMetadata({ utm_source: 'bluesky' });
expect(small).toBe('{"utm_source":"bluesky"}');
const big = encodeMetadata({ blob: 'x'.repeat(MAX_METADATA_BYTES + 100) });
expect(big).toBe('{"truncated":true}');
expect(encodeMetadata(null)).toBeNull();
});
test('records a normal event', async () => {
const inserted = await recordEvent(
{ name: 'donate_click', anonymousId: 'anon-1' },
testDb
);
expect(inserted).toBe(true);
const rows = await testDb.select().from(analyticsEvents);
expect(rows).toHaveLength(1);
expect(rows[0].name).toBe('donate_click');
});
test('verse_served is deduped per player + date', async () => {
const first = await recordEvent(
{ name: 'verse_served', anonymousId: 'anon-1', date: '2026-09-01' },
testDb
);
const second = await recordEvent(
{ name: 'verse_served', anonymousId: 'anon-1', date: '2026-09-01' },
testDb
);
expect(first).toBe(true);
expect(second).toBe(false);
const rows = await testDb
.select()
.from(analyticsEvents)
.where(
and(
eq(analyticsEvents.name, 'verse_served'),
eq(analyticsEvents.anonymousId, 'anon-1')
)
);
expect(rows).toHaveLength(1);
});
test('other event names are not deduped', async () => {
await recordEvent({ name: 'share', anonymousId: 'anon-1', date: '2026-09-01' }, testDb);
await recordEvent({ name: 'share', anonymousId: 'anon-1', date: '2026-09-01' }, testDb);
const rows = await testDb.select().from(analyticsEvents);
expect(rows).toHaveLength(2);
});
test('user attribution is stored', async () => {
await recordEvent(
{ name: 'share_copy', anonymousId: 'anon-1', userId: 'user-9', metadata: { snippet: true } },
testDb
);
const [row] = await testDb.select().from(analyticsEvents);
expect(row.userId).toBe('user-9');
expect(JSON.parse(row.metadata!)).toEqual({ snippet: true });
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, test, expect } from 'bun:test';
import {
activePlayers,
newVsReturning,
netGrowth,
d1Return,
returnWithin,
overallReturnRate,
streakSurvival,
timeToSecondPlayHistogram,
guessHistogram,
firstTryRate,
averageGuesses,
entropy,
repeatIntervals,
bookCoverage,
retentionByGuessBucket,
weekdayHourHeatmap,
cohortTriangle,
dailyCompletionsSeries
} from '$lib/server/stats/metrics';
import type { CompletionRow } from '$lib/types/stats';
function c(anonymousId: string, date: string, guessCount = 3, hour = 12): CompletionRow {
return {
anonymousId,
date,
guessCount,
guesses: null,
completedAt: new Date(`${date}T${String(hour).padStart(2, '0')}:00:00Z`)
};
}
// A: 3-day streak. B: plays day 1 and 3. C: one play. D: one play later.
const fixture: CompletionRow[] = [
c('A', '2026-09-01'),
c('A', '2026-09-02'),
c('A', '2026-09-03'),
c('B', '2026-09-01'),
c('B', '2026-09-03'),
c('C', '2026-09-02', 1),
c('D', '2026-09-05', 6)
];
describe('activePlayers', () => {
test('window of 1 is just that day', () => {
expect(activePlayers(fixture, '2026-09-03', 1)).toBe(2); // A, B
});
test('window includes the trailing days', () => {
expect(activePlayers(fixture, '2026-09-03', 3)).toBe(3); // A, B, C
});
test('empty window returns 0', () => {
expect(activePlayers(fixture, '2026-08-01', 1)).toBe(0);
});
});
describe('newVsReturning', () => {
test('day one is all new', () => {
expect(newVsReturning(fixture, '2026-09-01')).toEqual({
newPlayers: 2,
returning: 0,
returningShare: 0
});
});
test('later days are all returning', () => {
expect(newVsReturning(fixture, '2026-09-03')).toEqual({
newPlayers: 0,
returning: 2,
returningShare: 100
});
});
});
describe('return rates', () => {
test('d1Return pools the cohort of first-timers', () => {
// A first on 09-01 and returned 09-02; B first on 09-01 and did not.
expect(d1Return(fixture, '2026-09-01')).toBe(50);
});
test('returnWithin counts any return in the window', () => {
expect(returnWithin(fixture, '2026-09-01', 2)).toBe(100);
});
test('small cohorts return null when minSize is set', () => {
expect(returnWithin(fixture, '2026-09-05', 1, 3)).toBeNull();
});
test('overall return rate = players with 2+ plays', () => {
expect(overallReturnRate(fixture)).toBe(50); // A & B of A,B,C,D
});
});
describe('netGrowth', () => {
test('new + resurrected - churned over the window', () => {
// Window 09-04..09-05: D is new; A,B,C played the preceding window and stopped.
const g = netGrowth(fixture, '2026-09-05', 2, 30);
expect(g.newPlayers).toBe(1);
expect(g.churned).toBe(3);
expect(g.netGrowth).toBe(-2);
});
test('a long gap counts as resurrected', () => {
const data = [...fixture, c('E', '2026-06-01'), c('E', '2026-09-05')];
const g = netGrowth(data, '2026-09-05', 2, 30);
expect(g.resurrected).toBe(1);
});
});
describe('streaks', () => {
test('survival probability per streak length', () => {
const survival = streakSurvival(fixture);
const byLength = new Map(survival.map((s) => [s.length, s.probability]));
expect(byLength.get(1)).toBe(20); // only A continued from day 1
expect(byLength.get(2)).toBe(100); // A continued to day 3
expect(byLength.get(3)).toBe(0); // A stopped after day 3
});
});
describe('histograms', () => {
test('time to second play buckets gaps', () => {
const bins = timeToSecondPlayHistogram(fixture);
expect(bins.find((b) => b.label === '1 day')?.value).toBe(1); // A
expect(bins.find((b) => b.label === '2 days')?.value).toBe(1); // B
});
test('guess histogram collapses the tail', () => {
const bins = guessHistogram(fixture, 5);
const total = bins.reduce((a, b) => a + b.value, 0);
expect(total).toBe(fixture.length);
expect(bins[bins.length - 1].label).toBe('6+');
});
test('first try rate and average guesses', () => {
expect(firstTryRate(fixture)).toBeCloseTo(14.3, 1);
expect(typeof averageGuesses(fixture)).toBe('number');
});
});
describe('content metrics', () => {
test('entropy of a uniform distribution is log2(n)', () => {
expect(entropy([1, 1, 1, 1])).toBe(2);
expect(entropy([])).toBe(0);
});
test('repeat intervals count gaps per book', () => {
const bins = repeatIntervals([
{ date: '2026-09-01', bookId: 'GEN' },
{ date: '2026-09-03', bookId: 'GEN' },
{ date: '2026-10-01', bookId: 'EXO' },
{ date: '2026-10-05', bookId: 'EXO' }
]);
expect(bins.find((b) => b.label === '1–7d')?.value).toBe(2);
});
test('bookCoverage returns a stat for every book', () => {
const stats = bookCoverage(
[
{ date: '2026-09-01', bookId: 'GEN' },
{ date: '2026-09-02', bookId: 'GEN' }
],
fixture,
{ start: '2026-09-01', end: '2026-09-03' }
);
expect(stats).toHaveLength(66);
const gen = stats.find((s) => s.bookId === 'GEN')!;
expect(gen.days).toBe(2);
expect(gen.plays).toBe(4); // A,B on 09-01 + A,C on 09-02
});
test('retention by guess bucket is grouped', () => {
const buckets = retentionByGuessBucket(fixture);
expect(buckets).toHaveLength(5);
});
test('weekday/hour heatmap has a 7x24 grid', () => {
const grid = weekdayHourHeatmap(fixture);
expect(grid).toHaveLength(7);
expect(grid[0]).toHaveLength(24);
});
});
describe('cohortTriangle', () => {
test('week 0 is the full cohort and later weeks are subsets', () => {
const rows = cohortTriangle(fixture, {
start: '2026-08-30', // Sunday
weeks: 3,
asOf: '2026-09-15',
minSize: 1
});
expect(rows).toHaveLength(3);
expect(rows[0].size).toBeGreaterThan(0);
expect(rows[0].counts[0]).toBe(rows[0].size);
});
});
describe('dailyCompletionsSeries', () => {
test('fills gaps with zeroes and counts new players', () => {
const series = dailyCompletionsSeries(fixture, '2026-09-01', '2026-09-03');
expect(series).toHaveLength(3);
expect(series[0]).toEqual({ date: '2026-09-01', completions: 2, newPlayers: 2 });
expect(series[2]).toEqual({ date: '2026-09-03', completions: 2, newPlayers: 0 });
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, test, expect } from 'bun:test';
import { deriveSegments, countSegments } from '$lib/server/stats/segments';
import { SETTINGS_DEFAULTS, type PlayerState, type StatsSettings } from '$lib/types/stats';
import { addDays } from '$lib/server/stats/dates';
const AS_OF = '2026-09-15';
function settings(overrides: Partial<StatsSettings> = {}): StatsSettings {
return { ...SETTINGS_DEFAULTS, ...overrides };
}
function player(id: string, overrides: Partial<PlayerState>): PlayerState {
return {
id,
firstDate: addDays(AS_OF, -100),
lastDate: AS_OF,
totalPlays: 10,
plays7: 2,
plays30: 10,
streak: 2,
...overrides
};
}
const players: PlayerState[] = [
player('new', { firstDate: addDays(AS_OF, -1), lastDate: AS_OF, plays7: 1 }),
player('core', { lastDate: AS_OF, plays7: 5 }),
player('resurrected', { lastDate: AS_OF, plays7: 2, plays30: 2 }),
player('returning', { lastDate: AS_OF, plays7: 2, plays30: 10 }),
player('atRisk', { lastDate: addDays(AS_OF, -10) }),
player('churned', { lastDate: addDays(AS_OF, -40) })
];
describe('deriveSegments', () => {
test('classifies each lifecycle segment', () => {
const sets = deriveSegments(players, AS_OF, settings());
expect(sets.new).toEqual(['new']);
expect(sets.core).toEqual(['core']);
expect(sets.resurrected).toEqual(['resurrected']);
expect(sets.returning).toEqual(['returning']);
expect(sets.atRisk).toEqual(['atRisk']);
expect(sets.churned).toEqual(['churned']);
expect(sets.active).toEqual(['new', 'core', 'resurrected', 'returning']);
});
test('changing the core threshold re-segments without new data', () => {
const strict = deriveSegments(players, AS_OF, settings({ core_plays_per_week: 6 }));
expect(strict.core).toEqual([]);
expect(strict.returning).toContain('core');
const lenient = deriveSegments(players, AS_OF, settings({ core_plays_per_week: 2 }));
expect(lenient.core).toContain('core');
expect(lenient.core).toContain('returning');
});
test('counts include every player exactly once across mutually exclusive sets', () => {
const counts = countSegments(deriveSegments(players, AS_OF, settings()));
const activeTotal = counts.new + counts.core + counts.returning + counts.resurrected;
expect(activeTotal).toBe(counts.active);
expect(counts.total).toBe(players.length);
});
test('at-risk and churned boundaries follow settings', () => {
const sets = deriveSegments(players, AS_OF, settings({ at_risk_days: 5, churn_days: 20 }));
expect(sets.atRisk).toContain('atRisk'); // inactive 10 >= 5, < 20
expect(sets.churned).toContain('churned'); // inactive 40 >= 20
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { testDb } from '$lib/server/db/test';
import { dashboardSettings } from '$lib/server/db/schema';
import {
getSettings,
updateSettings,
hashSettings,
envSettings,
settingsEnvVar
} from '$lib/server/stats/settings';
import { SETTINGS_DEFAULTS } from '$lib/types/stats';
async function clear() {
await testDb.delete(dashboardSettings);
}
describe('stats settings', () => {
beforeEach(clear);
afterEach(clear);
test('env var names map from keys', () => {
expect(settingsEnvVar('d1_target')).toBe('STATS_D1_TARGET');
});
test('empty table returns built-in defaults', async () => {
const settings = await getSettings(testDb);
expect(settings).toEqual({ ...SETTINGS_DEFAULTS });
});
test('updateSettings upserts and is reflected on re-read', async () => {
await updateSettings({ core_plays_per_week: 7 }, testDb);
const settings = await getSettings(testDb);
expect(settings.core_plays_per_week).toBe(7);
// Updating again overwrites rather than duplicating.
await updateSettings({ core_plays_per_week: 2 }, testDb);
expect((await getSettings(testDb)).core_plays_per_week).toBe(2);
});
test('config hash is stable and changes with settings', () => {
const a = hashSettings({ ...SETTINGS_DEFAULTS });
const b = hashSettings({ ...SETTINGS_DEFAULTS });
const c = hashSettings({ ...SETTINGS_DEFAULTS, d1_target: 99 });
expect(a).toBe(b);
expect(a).not.toBe(c);
expect(a).toHaveLength(12);
});
test('envSettings falls back to defaults with no env overrides', () => {
expect(envSettings()).toEqual({ ...SETTINGS_DEFAULTS });
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, test, expect } from 'bun:test';
import { evaluateSignals, detectAnomaly, sortSignals } from '$lib/server/stats/signals';
import { SETTINGS_DEFAULTS, type SnapshotPayload } from '$lib/types/stats';
function makePayload(overrides: {
kpis?: Partial<SnapshotPayload['kpis']>;
activity?: Partial<SnapshotPayload['activity']>;
dailySeries?: SnapshotPayload['dailySeries'];
} = {}): SnapshotPayload {
return {
asOfDate: '2026-09-14',
activity: {
dau: 100,
wau: 300,
mau: 800,
core: 50,
returningShare: 40,
newPlayers: 20,
resurrected: 5,
churned: 18,
netGrowth: 7,
corePrevious: 45,
previous: { dau: 95, wau: 290, mau: 790, core: 45 },
...overrides.activity
},
dailySeries: overrides.dailySeries ?? [],
kpis: {
d1Return: 25,
return7: 30,
overallReturnRate: 22,
completionRate: 80,
shareRate: 30,
firstTryRate: 25,
avgGuesses: 4.1,
...overrides.kpis
},
retention: {
cohorts: [],
curves: { d1: [], d7: [], d30: [] },
timeToSecondPlay: [],
segmentMix: []
},
growth: { lifecycleFunnel: [], signups: [] },
totals: { completions: 0, players: 0, yesterdayCompletions: 0, yesterdayAvgGuesses: null, bestDay: null },
verseMix: {
coverage: [],
testamentMix: [],
entropy: [],
repeatIntervals: [],
firstTryByBook: [],
retentionByBook: [],
retentionByGuessBucket: [],
achievability: []
},
habit: { streakSurvival: [], streakLengths: [], calendar: [], weekdayHour: [] },
signals: [],
meta: { hasStartedData: true, hasShareData: true, firstEventDate: null }
};
}
const settings = { ...SETTINGS_DEFAULTS };
describe('evaluateSignals', () => {
test('all-good payload yields no warn/bad signals', () => {
const signals = evaluateSignals(makePayload(), settings);
expect(signals.some((s) => s.severity === 'bad' || s.severity === 'warn')).toBe(false);
});
test('low D1 return is bad and recommends an action', () => {
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 9 } }), settings);
const d1 = signals.find((s) => s.id === 'd1_return')!;
expect(d1.severity).toBe('bad');
expect(d1.action).toBeTruthy();
});
test('missed-but-not-red targets are warnings', () => {
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 16 } }), settings);
expect(signals.find((s) => s.id === 'd1_return')!.severity).toBe('warn');
});
test('first-try rate outside the band is flagged', () => {
const tooEasy = evaluateSignals(makePayload({ kpis: { firstTryRate: 60 } }), settings);
expect(tooEasy.find((s) => s.id === 'first_try')!.severity).toBe('bad');
const tooHard = evaluateSignals(makePayload({ kpis: { firstTryRate: 5 } }), settings);
expect(tooHard.find((s) => s.id === 'first_try')!.severity).toBe('bad');
});
test('missing metrics surface as info, not failure', () => {
const signals = evaluateSignals(
makePayload({ kpis: { completionRate: null, shareRate: null } }),
settings
);
expect(signals.find((s) => s.id === 'completion')!.severity).toBe('info');
expect(signals.find((s) => s.id === 'share_rate')!.severity).toBe('info');
});
test('declining core players is a warning', () => {
const signals = evaluateSignals(
makePayload({ activity: { core: 30, corePrevious: 50 } }),
settings
);
expect(signals.find((s) => s.id === 'core_players')!.severity).toBe('warn');
});
test('sortSignals puts bad first', () => {
const signals = sortSignals(evaluateSignals(makePayload({ kpis: { d1Return: 5 } }), settings));
expect(signals[0].severity).toBe('bad');
});
});
describe('detectAnomaly', () => {
function series(values: number[], startWeekdayOffset = 0) {
// Build consecutive days starting 2026-08-01 so weekdays repeat every 7.
const out = values.map((value, i) => {
const d = new Date('2026-08-01T00:00:00Z');
d.setUTCDate(d.getUTCDate() + i);
return { date: d.toISOString().slice(0, 10), value };
});
void startWeekdayOffset;
return out;
}
test('flags a large weekday-aware spike', () => {
// 5 weeks of varying same-weekday values, then a spike.
const base: number[] = [];
for (let i = 0; i < 35; i++) base.push(100 + ((i * 13) % 17));
const spike = [...base, 500];
const anomaly = detectAnomaly(series(spike), 2);
expect(anomaly).not.toBeNull();
expect(anomaly!.value).toBe(500);
});
test('returns null for a flat series', () => {
expect(detectAnomaly(series(Array(35).fill(100)), 2)).toBeNull();
});
test('returns null without enough history', () => {
expect(detectAnomaly(series([1, 2, 3]), 2)).toBeNull();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { testDb } from '$lib/server/db/test';
import {
analyticsEvents,
dailyCompletions,
dailyVerses,
statsSnapshot
} from '$lib/server/db/schema';
import {
buildSnapshot,
computePayload,
computePlayerState,
ensureSnapshot,
loadStatsData,
PAYLOAD_SCHEMA_VERSION,
type LoadedData
} from '$lib/server/stats/snapshot';
import { endOfVerseWindow, isDayClosed, latestClosedDate } from '$lib/server/stats/dates';
import { SETTINGS_DEFAULTS, type CompletionRow } from '$lib/types/stats';
import crypto from 'node:crypto';
const settings = { ...SETTINGS_DEFAULTS };
function c(anonymousId: string, date: string): CompletionRow {
return { anonymousId, date, guessCount: 3, guesses: null, completedAt: new Date(date) };
}
describe('computePlayerState', () => {
test('builds streak and rolling play counts as of the date', () => {
const completions: CompletionRow[] = [
c('A', '2026-09-08'),
c('A', '2026-09-09'),
c('A', '2026-09-10'),
c('A', '2026-08-01'),
c('B', '2026-09-10')
];
const state = computePlayerState(completions, '2026-09-10', 180);
const a = state.find((p) => p.id === 'A')!;
expect(a.firstDate).toBe('2026-08-01');
expect(a.lastDate).toBe('2026-09-10');
expect(a.totalPlays).toBe(4);
expect(a.plays7).toBe(3);
expect(a.streak).toBe(3);
});
test('drops players outside the active window', () => {
const state = computePlayerState([c('old', '2026-01-01')], '2026-09-10', 30);
expect(state).toHaveLength(0);
});
});
describe('computePayload point-in-time cutoff', () => {
const data: LoadedData = {
completions: [
c('A', '2026-09-10'),
c('A', '2026-09-11'), // future relative to asOfDate
c('B', '2026-09-11') // future
],
verses: [{ date: '2026-09-10', bookId: 'GEN' }],
startedByDate: new Map([['2026-09-10', 2]]),
shareEvents: 0,
firstEventDate: '2026-09-10',
signupDates: []
};
test('activity only reflects data up to the as-of date', () => {
const payload = computePayload('2026-09-10', settings, data);
expect(payload.activity.dau).toBe(1); // only A, not B (future)
});
test('completion rate uses verse_served events', () => {
const payload = computePayload('2026-09-10', settings, data);
expect(payload.meta.hasStartedData).toBe(true);
expect(payload.kpis.completionRate).not.toBeNull();
});
test('all-time best day picks the highest count and ignores future days', () => {
const payload = computePayload('2026-09-10', settings, {
...data,
completions: [
c('A', '2026-09-01'),
c('A', '2026-09-02'),
c('B', '2026-09-02'),
c('C', '2026-09-02'),
c('D', '2026-09-11') // future relative to asOfDate
]
});
expect(payload.totals.bestDay).toEqual({ date: '2026-09-02', count: 3 });
});
test('payload has all lab sections', () => {
const payload = computePayload('2026-09-10', settings, data);
expect(payload.retention.cohorts.length).toBeGreaterThan(0);
expect(payload.verseMix.coverage).toHaveLength(66);
expect(payload.habit.calendar.length).toBe(120);
});
});
describe('snapshot persistence', () => {
beforeEach(async () => {
await testDb.delete(statsSnapshot);
await testDb.delete(analyticsEvents);
await testDb.delete(dailyCompletions);
await testDb.delete(dailyVerses);
});
afterEach(async () => {
await testDb.delete(statsSnapshot);
await testDb.delete(analyticsEvents);
await testDb.delete(dailyCompletions);
await testDb.delete(dailyVerses);
});
async function seed() {
await testDb.insert(dailyCompletions).values([
{ id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-10', guessCount: 2, completedAt: new Date('2026-01-10T12:00:00Z') },
{ id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-11', guessCount: 3, completedAt: new Date('2026-01-11T12:00:00Z') },
{ id: crypto.randomUUID(), anonymousId: 'B', date: '2026-01-11', guessCount: 1, completedAt: new Date('2026-01-11T13:00:00Z') }
]);
await testDb.insert(dailyVerses).values([
{ id: crypto.randomUUID(), date: '2026-01-10', bookId: 'GEN', verseText: 'x', reference: 'Genesis 1:1' },
{ id: crypto.randomUUID(), date: '2026-01-11', bookId: 'EXO', verseText: 'y', reference: 'Exodus 1:1' }
]);
}
test('building the same date twice is idempotent', async () => {
await seed();
const first = await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
const rows = await testDb.select().from(statsSnapshot);
expect(rows).toHaveLength(1);
expect(first.snapshot.payload.asOfDate).toBe('2026-01-10');
expect(first.snapshot.payload.activity.dau).toBe(1);
});
test('ensureSnapshot builds when missing and reads when present', async () => {
await seed();
const a = await ensureSnapshot('2026-01-10', {}, testDb);
const b = await ensureSnapshot('2026-01-10', {}, testDb);
expect(a.generatedAt.getTime()).toBe(b.generatedAt.getTime());
expect(a.buildKind).toBe('lazy');
});
test('rebuilds snapshots written with an outdated payload shape', async () => {
await seed();
await testDb.insert(statsSnapshot).values({
date: '2026-01-10',
generatedAt: new Date(),
buildKind: 'lazy',
configVersion: 'outdated',
payload: '{}',
playerState: '[]',
config: '{}'
});
const snap = await ensureSnapshot('2026-01-10', {}, testDb);
expect(snap.payload.meta?.schemaVersion).toBe(PAYLOAD_SCHEMA_VERSION);
});
test('groups completions by verse date, not a UTC 24h window', async () => {
// A player in a UTC-negative timezone finishing "Jan 10" late at night
// lands on Jan 11 04:00Z. The old UTC end-of-day cutoff dropped them.
await testDb.insert(dailyCompletions).values({
id: crypto.randomUUID(),
anonymousId: 'late',
date: '2026-01-10',
guessCount: 4,
completedAt: new Date('2026-01-11T04:00:00Z')
});
const { snapshot } = await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
expect(snapshot.payload.activity.dau).toBe(1);
});
test('isDayClosed gates persistence for in-progress days', async () => {
await seed();
const open = new Date().toISOString().slice(0, 10);
await buildSnapshot(open, { kind: 'lazy' }, testDb);
await buildSnapshot(open, { kind: 'lazy', replace: true }, testDb);
const live = await ensureSnapshot(open, {}, testDb);
expect(live.date).toBe(open);
// None of the open-day builds may leave a persisted row behind.
expect(await testDb.select().from(statsSnapshot)).toHaveLength(0);
});
});
describe('verse day window', () => {
test('closes at D+1 12:00Z (UTC-12 has finished the local day)', () => {
expect(endOfVerseWindow('2026-01-10').toISOString()).toBe('2026-01-11T12:00:00.000Z');
expect(isDayClosed('2026-01-10', new Date('2026-01-11T11:59:59Z'))).toBe(false);
expect(isDayClosed('2026-01-10', new Date('2026-01-11T12:00:00Z'))).toBe(true);
});
test('latestClosedDate reflects the window lag', () => {
expect(latestClosedDate(new Date('2026-01-11T11:00:00Z'))).toBe('2026-01-09');
expect(latestClosedDate(new Date('2026-01-11T13:00:00Z'))).toBe('2026-01-10');
});
test('completedBefore pins a point-in-time build', async () => {
await testDb.delete(analyticsEvents);
await testDb.delete(dailyCompletions);
await testDb.insert(dailyCompletions).values([
{ id: crypto.randomUUID(), anonymousId: 'early', date: '2026-01-10', guessCount: 1, completedAt: new Date('2026-01-10T05:00:00Z') },
{ id: crypto.randomUUID(), anonymousId: 'late', date: '2026-01-10', guessCount: 2, completedAt: new Date('2026-01-11T04:00:00Z') }
]);
const pinned = await loadStatsData('2026-01-10', testDb, {
completedBefore: new Date('2026-01-10T12:00:00Z')
});
expect(pinned.completions.map((c) => c.anonymousId)).toEqual(['early']);
const full = await loadStatsData('2026-01-10', testDb);
expect(full.completions).toHaveLength(2);
});
});
+513
View File
@@ -0,0 +1,513 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import { eq } from "drizzle-orm";
import { testDb as db } from "../src/lib/server/db/test";
import { dailyVerses, verseSubmissions, user } from "../src/lib/server/db/schema";
import {
addDays,
dayDiff,
getCooldownState,
isUniqueConstraintError,
scheduleSubmission,
validateSelection,
COOLDOWN_MS,
type Db
} from "../src/lib/server/verse-submission";
import { bookIdToNumber } from "../src/lib/server/bible";
// ---- helpers --------------------------------------------------------------
const TODAY = "2026-07-01"; // frozen "today" for deterministic scheduling tests
const DAY_MS = 1000 * 60 * 60 * 24;
function uuid() {
return Bun.randomUUIDv7();
}
/** Insert a committed daily_verses row (simulating an existing scheduled verse). */
async function seedDailyVerse(
date: string,
bookId: string,
reference: string,
verseText = "verse text"
) {
await db
.insert(dailyVerses)
.values({
id: uuid(),
date,
bookId,
verseText,
reference,
createdAt: new Date(0)
})
.run();
}
async function seedUser(id: string) {
await db
.insert(user)
.values({
id,
firstName: "Test",
email: `${id}@example.com`,
isPrivate: false
})
.run();
}
async function seedSubmission(
userId: string,
scheduledDate: string,
submittedAt: number,
bookId = "GEN",
chapter = 1,
verse = 3
) {
await db
.insert(verseSubmissions)
.values({
id: uuid(),
userId,
scheduledDate,
selectedBookId: bookId,
selectedChapter: chapter,
selectedVerse: verse,
submittedAt
})
.run();
}
async function clearAll() {
await db.delete(verseSubmissions).run();
await db.delete(dailyVerses).run();
await db.delete(user).run();
}
// ===========================================================================
// Pure date arithmetic
// ===========================================================================
describe("addDays / dayDiff (UTC arithmetic)", () => {
test("addDays moves forward across month/year boundaries", () => {
expect(addDays("2026-01-31", 1)).toBe("2026-02-01");
expect(addDays("2026-12-31", 1)).toBe("2027-01-01");
expect(addDays("2026-02-28", 7)).toBe("2026-03-07");
});
test("addDays is negative-safe", () => {
expect(addDays("2026-03-01", -1)).toBe("2026-02-28");
expect(addDays("2026-01-01", -1)).toBe("2025-12-31");
});
test("addDays is its own inverse with dayDiff", () => {
const start = "2026-07-01";
for (const n of [0, 1, 7, 30, 365, -1, -60]) {
const shifted = addDays(start, n);
expect(dayDiff(start, shifted)).toBe(n);
}
});
test("dayDiff handles DST-free UTC whole days exactly", () => {
expect(dayDiff("2026-07-01", "2026-07-02")).toBe(1);
expect(dayDiff("2026-07-01", "2026-06-30")).toBe(-1);
expect(dayDiff("2026-01-01", "2026-12-31")).toBe(364);
});
});
// ===========================================================================
// validateSelection (structural validation, no DB)
// ===========================================================================
describe("validateSelection", () => {
test("accepts a valid book/chapter/verse", () => {
expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 1 })).toBeNull();
expect(validateSelection({ bookId: "JHN", chapter: 3, verse: 16 })).toBeNull();
});
test("rejects unknown bookId", () => {
expect(validateSelection({ bookId: "ZZZ", chapter: 1, verse: 1 })).toBe(
"Unknown bookId"
);
});
test("rejects chapter out of range", () => {
const gen = bookIdToNumber["GEN"];
expect(validateSelection({ bookId: "GEN", chapter: 999, verse: 1 })).toContain(
"chapter out of range"
);
});
test("rejects verse out of range", () => {
expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 9999 })).toContain(
"verse out of range"
);
});
test("rejects non-positive / non-integer chapter & verse", () => {
expect(validateSelection({ bookId: "GEN", chapter: 0, verse: 1 })).toContain(
"positive integer"
);
expect(validateSelection({ bookId: "GEN", chapter: 1, verse: 0 })).toContain(
"positive integer"
);
expect(
validateSelection({ bookId: "GEN", chapter: 1.5, verse: 1 })
).toContain("positive integer");
});
});
// ===========================================================================
// isUniqueConstraintError
// ===========================================================================
describe("isUniqueConstraintError", () => {
test("matches SQLITE_CONSTRAINT_UNIQUE code", () => {
expect(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT_UNIQUE" })).toBe(true);
});
test("matches SQLITE_CONSTRAINT code", () => {
expect(isUniqueConstraintError({ code: "SQLITE_CONSTRAINT" })).toBe(true);
});
test("matches by message containing UNIQUE", () => {
expect(
isUniqueConstraintError(
new Error("SQLITE_CONSTRAINT: UNIQUE constraint failed: daily_verses.date")
)
).toBe(true);
});
test("returns false for unrelated errors", () => {
expect(isUniqueConstraintError(new Error("something else"))).toBe(false);
expect(isUniqueConstraintError(null)).toBe(false);
expect(isUniqueConstraintError(undefined)).toBe(false);
});
});
// ===========================================================================
// Cooldown arithmetic (DB)
// ===========================================================================
describe("getCooldownState", () => {
const userId = "user-cooldown";
beforeEach(async () => {
await clearAll();
await seedUser(userId);
});
afterEach(async () => {
await clearAll();
});
test("no submissions → not on cooldown, null end", async () => {
const state = await getCooldownState(db, userId);
expect(state.onCooldown).toBe(false);
expect(state.cooldownEndsAt).toBeNull();
});
test("recent submission → on cooldown, ends at submittedAt + 7d", async () => {
const now = Date.now();
const submittedAt = now - 1000; // 1s ago
await seedSubmission(userId, "2026-08-15", submittedAt);
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(true);
expect(state.cooldownEndsAt).toBe(submittedAt + COOLDOWN_MS);
});
test("submission exactly 7 days ago → cooldown just expired (boundary)", async () => {
const now = 1_700_000_000_000;
const submittedAt = now - COOLDOWN_MS; // exactly 7d ago
await seedSubmission(userId, "2026-08-15", submittedAt);
// now == cooldownEndsAt → no longer on cooldown
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(false);
});
test("submission 6d23h ago → still on cooldown", async () => {
const now = 1_700_000_000_000;
const submittedAt = now - (COOLDOWN_MS - 1000);
await seedSubmission(userId, "2026-08-15", submittedAt);
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(true);
expect(state.cooldownEndsAt).toBe(submittedAt + COOLDOWN_MS);
});
test("uses the most recent of multiple submissions", async () => {
const now = Date.now();
await seedSubmission(userId, "2026-08-15", now - 20 * DAY_MS); // older
await seedSubmission(userId, "2026-08-22", now - 2 * DAY_MS); // recent
const state = await getCooldownState(db, userId, now);
expect(state.onCooldown).toBe(true);
// The recent one (2d ago) drives the cooldown, not the 20d-old one.
expect(state.cooldownEndsAt).toBe(now - 2 * DAY_MS + COOLDOWN_MS);
});
});
// ===========================================================================
// Scheduling validity (DB)
// ===========================================================================
describe("scheduleSubmission — validity rules", () => {
const userId = "user-sched";
const now = Date.now();
beforeEach(async () => {
await clearAll();
await seedUser(userId);
});
afterEach(async () => {
await clearAll();
});
test("empty calendar → schedules tomorrow (server UTC)", async () => {
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
expect(result.scheduledDate).toBe(addDays(TODAY, 1));
expect(result.bookId).toBe("GEN");
expect(result.reference).toBe("Genesis 1:1-3");
// Both rows written.
const [dv] = await db
.select()
.from(dailyVerses)
.where(eq(dailyVerses.date, result.scheduledDate));
expect(dv).toBeDefined();
expect(dv.bookId).toBe("GEN");
const [vs] = await db
.select()
.from(verseSubmissions)
.where(eq(verseSubmissions.scheduledDate, result.scheduledDate));
expect(vs).toBeDefined();
expect(vs.userId).toBe(userId);
expect(vs.selectedBookId).toBe("GEN");
expect(vs.selectedChapter).toBe(1);
expect(vs.selectedVerse).toBe(3);
expect(vs.submittedAt).toBe(now);
});
test("skips a date whose D-1 neighbor is the same book (no back-to-back)", async () => {
// Committed row at tomorrow with GEN → tomorrow is back-to-blocked for GEN
// (its D-1 = today, but today is empty so no conflict; instead block via
// seeding GEN at day+2, which makes day+3's D-1 a GEN).
const d2 = addDays(TODAY, 2);
await seedDailyVerse(d2, "GEN", "Genesis 1:10-12");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
// tomorrow (d1) is empty, d-1=today empty, d+1=d2=GEN → back-to-back → skip.
// d2 is occupied. d3's d-1=d2=GEN → back-to-back → skip. d4 is earliest valid
// (different window ref, so the 60-day repeat rule does not apply).
expect(result.scheduledDate).toBe(addDays(TODAY, 4));
});
test("skips a date whose D+1 neighbor is the same book", async () => {
// Seed GEN at day+3. Then day+2 (empty) has D+1 = GEN → back-to-back.
// day+1: D+1 = day+2 empty, ok; D-1=today empty → valid → schedules day+1.
// To force the D+1 rule, seed GEN at day+1 so day+1 is occupied and
// the next empty candidate (day+2) has D+1=day+3 ... need day+3 to be GEN.
const d1 = addDays(TODAY, 1);
const d3 = addDays(TODAY, 3);
await seedDailyVerse(d1, "EXO", "Exodus 1:1-3"); // occupy day+1 (different book)
await seedDailyVerse(d3, "GEN", "Genesis 1:10-12"); // day+3 = GEN
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
// day+2 is empty but D+1 = day+3 = GEN → back-to-back → skip.
// day+3 occupied. day+4: D-1=day+3=GEN → back-to-back → skip.
// day+5: D-1=day+4 empty, D+1=day+6 empty → valid (different window ref).
expect(result.scheduledDate).toBe(addDays(TODAY, 5));
});
test("back-to-back with a different book is allowed", async () => {
// Seed EXO at tomorrow. A GEN submission at day+2 has D-1=day+1=EXO (diff) → ok.
await seedDailyVerse(addDays(TODAY, 1), "EXO", "Exodus 1:1-3");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
userId,
now,
{ today: TODAY }
);
expect(result.scheduledDate).toBe(addDays(TODAY, 2));
});
test("60-day repeat: identical window within ±60d pushes the date out", async () => {
// Seed an identical GEN 1:1-3 window at day+10.
const d10 = addDays(TODAY, 10);
await seedDailyVerse(d10, "GEN", "Genesis 1:1-3");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 }, // same window: Genesis 1:1-3
userId,
now,
{ today: TODAY }
);
// The earliest empty, non-back-to-back candidate whose distance from d10
// is > 60 days. day+1..day+9: within 60d of d10 (and day+9 D+1=d10=GEN
// back-to-back anyway). day+11: D-1=d10=GEN → back-to-back + within 60.
// ... all dates within [d10-60, d10+60] are repeat-blocked. The first
// valid date is d10+61.
expect(result.scheduledDate).toBe(addDays(d10, 61));
// Sanity: distance is just over 60 days.
expect(Math.abs(dayDiff(d10, result.scheduledDate))).toBeGreaterThan(60);
});
test("different-window repeat at the same book is NOT blocked by rule 4", async () => {
// Seed GEN 1:1-3 at day+1. A GEN 1:4-6 submission shares the book but
// not the window identity, so rule 4 (60-day repeat) does not apply —
// only back-to-back matters.
const d1 = addDays(TODAY, 1);
await seedDailyVerse(d1, "GEN", "Genesis 1:1-3");
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 6 }, // window Genesis 1:4-6
userId,
now,
{ today: TODAY }
);
// day+1 occupied. day+2: D-1=day+1=GEN → back-to-back → skip.
// day+3: D-1=day+2 empty → valid (window differs, so no 60-day block).
expect(result.scheduledDate).toBe(addDays(TODAY, 3));
expect(result.reference).toBe("Genesis 1:4-6");
});
test("fall-forward window (Gen 1:1) schedules and stores Gen 1:1-3", async () => {
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 1 },
userId,
now,
{ today: TODAY }
);
expect(result.reference).toBe("Genesis 1:1-3");
});
});
// ===========================================================================
// Concurrency (DB)
// ===========================================================================
describe("scheduleSubmission — concurrency", () => {
const now = Date.now();
beforeEach(async () => {
await clearAll();
});
afterEach(async () => {
await clearAll();
});
test("two concurrent submissions with the same book land on distinct dates", async () => {
const u1 = "user-conc-1";
const u2 = "user-conc-2";
await seedUser(u1);
await seedUser(u2);
const [r1, r2] = await Promise.all([
scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, {
today: TODAY
}),
scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 4 }, u2, now, {
today: TODAY
})
]);
// Both must succeed and never share a scheduled date.
expect(r1.scheduledDate).not.toBe(r2.scheduledDate);
// And they must not be back-to-back (same book).
const diff = Math.abs(dayDiff(r1.scheduledDate, r2.scheduledDate));
expect(diff).toBeGreaterThan(1);
// Both rows exist in verse_submissions with their own user.
const all = await db.select().from(verseSubmissions).all();
expect(all).toHaveLength(2);
const userIds = all.map((r) => r.userId).sort();
expect(userIds).toEqual([u1, u2].sort());
// And two distinct daily_verses rows.
const dv = await db.select().from(dailyVerses).all();
expect(dv).toHaveLength(2);
expect(new Set(dv.map((r) => r.date)).size).toBe(2);
});
test("retry recovers when a concurrent insert claims the candidate first", async () => {
// Simulate a "lost race" by pre-occupying tomorrow (the first candidate)
// right before the call — the scan already ran against a stale calendar
// only if we insert between scan and insert. Instead, verify the simpler
// guarantee: an existing row on the candidate date is detected on retry
// because loadCalendar is re-read each attempt.
const u1 = "user-conc-race";
await seedUser(u1);
// Seed GEN at every day from tomorrow..tomorrow+3 so the first valid
// empty slot for GEN (respecting back-to-back) is pushed well out. This
// exercises the scan walking past occupied + back-to-back dates.
for (let i = 1; i <= 3; i++) {
await seedDailyVerse(addDays(TODAY, i), "GEN", `Genesis 1:10-12`);
}
const result = await scheduleSubmission(
db,
{ bookId: "GEN", chapter: 1, verse: 3 },
u1,
now,
{ today: TODAY }
);
// Days 1..3 occupied with GEN (different window ref, so rule 4 is silent).
// day+4: D-1=day+3=GEN → back-to-back → skip.
// day+5: D-1=day+4 empty, D+1=day+6 empty → valid.
expect(result.scheduledDate).toBe(addDays(TODAY, 5));
});
test("two concurrent submissions for different books can be adjacent", async () => {
const u1 = "user-conc-diff-1";
const u2 = "user-conc-diff-2";
await seedUser(u1);
await seedUser(u2);
const [r1, r2] = await Promise.all([
scheduleSubmission(db, { bookId: "GEN", chapter: 1, verse: 3 }, u1, now, {
today: TODAY
}),
scheduleSubmission(db, { bookId: "EXO", chapter: 1, verse: 3 }, u2, now, {
today: TODAY
})
]);
expect(r1.bookId).toBe("GEN");
expect(r2.bookId).toBe("EXO");
expect(r1.scheduledDate).not.toBe(r2.scheduledDate);
// Different books may legitimately be adjacent (diff == 1) — just assert
// both are distinct future dates.
const diff = Math.abs(dayDiff(r1.scheduledDate, r2.scheduledDate));
expect(diff).toBeGreaterThanOrEqual(1);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { describe, test, expect } from "bun:test";
import {
composeVerseWindow,
formatWindowReference,
getChapterCount,
getVerseCount,
extractVerses,
} from "$lib/server/xml-bible";
import { bookIdToNumber, getBookById } from "$lib/server/bible";
describe("getChapterCount / getVerseCount", () => {
test("Genesis has 50 chapters", () => {
expect(getChapterCount(bookIdToNumber["GEN"])).toBe(50);
});
test("Jude has 1 chapter", () => {
expect(getChapterCount(bookIdToNumber["JUD"])).toBe(1);
});
test("Genesis 1 has 31 verses", () => {
expect(getVerseCount(bookIdToNumber["GEN"], 1)).toBe(31);
});
});
describe("composeVerseWindow — default [v-2, v-1, v]", () => {
test("Genesis 1:3 → window is 1:1-3 (same chapter, hyphen)", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 3);
expect(w).not.toBeNull();
expect(w!.startChapter).toBe(1);
expect(w!.startVerse).toBe(1);
expect(w!.endChapter).toBe(1);
expect(w!.endVerse).toBe(3);
expect(w!.verses).toHaveLength(3);
expect(formatWindowReference(w!.bookName, w!.startChapter, w!.startVerse, w!.endChapter, w!.endVerse))
.toBe("Genesis 1:1-3");
});
test("selected verse is the last in the default window", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 10);
expect(w!.selectedVerse).toBe(10);
// verses[2] should equal Genesis 1:10's text
const direct = extractVerses(bookIdToNumber["GEN"], 1, 10, 1);
expect(w!.verses[2]).toBe(direct[0]);
});
});
describe("composeVerseWindow — fall-forward at book start", () => {
test("Genesis 1:1 → fall forward to [1,2,3]", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 1);
expect(w!.startChapter).toBe(1);
expect(w!.startVerse).toBe(1);
expect(w!.endChapter).toBe(1);
expect(w!.endVerse).toBe(3);
expect(w!.verses).toHaveLength(3);
expect(formatWindowReference(w!.bookName, w!.startChapter, w!.startVerse, w!.endChapter, w!.endVerse))
.toBe("Genesis 1:1-3");
});
test("Genesis 1:2 → fall forward to [2,3,4] (v-2 < 1)", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 1, 2);
expect(w!.startVerse).toBe(2);
expect(w!.endVerse).toBe(4);
expect(w!.verses).toHaveLength(3);
});
test("John 1:1 → fall forward", () => {
const w = composeVerseWindow(bookIdToNumber["JHN"], 1, 1);
expect(w!.bookId).toBe("JHN");
expect(w!.startVerse).toBe(1);
expect(w!.endVerse).toBe(3);
});
});
describe("composeVerseWindow — cross-chapter within a book", () => {
test("Genesis 2:1 → window spans 1:30-2:1 (en-dash, two chapters)", () => {
const w = composeVerseWindow(bookIdToNumber["GEN"], 2, 1);
expect(w!.startChapter).toBe(1);
expect(w!.endChapter).toBe(2);
// Genesis 1 has 31 verses; selected index for 2:1 = 31 + 1 = 32; window = [30,31,32]
expect(w!.startVerse).toBe(30);
expect(w!.startChapter === 1 && w!.endChapter === 2).toBe(true);
expect(w!.verses).toHaveLength(3);
const ref = formatWindowReference(w!.bookName, w!.startChapter, w!.startVerse, w!.endChapter, w!.endVerse);
expect(ref).toBe("Genesis 1:30\u20132:1");
});
test("window never crosses a book boundary", () => {
// Every book has >= 3 verses, and windows are constrained in-book by construction.
// Spot-check the last verse of Malachi 4 (end of OT) still resolves in-book.
const mal = bookIdToNumber["MAL"];
const lastChapter = getChapterCount(mal);
const lastVerse = getVerseCount(mal, lastChapter);
const w = composeVerseWindow(mal, lastChapter, lastVerse);
expect(w).not.toBeNull();
expect(w!.bookId).toBe("MAL");
expect(w!.endChapter).toBe(lastChapter);
});
});
describe("composeVerseWindow — invalid input", () => {
test("unknown book number returns null", () => {
expect(composeVerseWindow(999, 1, 1)).toBeNull();
});
test("chapter out of range returns null", () => {
expect(composeVerseWindow(bookIdToNumber["GEN"], 999, 1)).toBeNull();
});
test("verse out of range returns null", () => {
expect(composeVerseWindow(bookIdToNumber["GEN"], 1, 9999)).toBeNull();
});
});
describe("formatWindowReference", () => {
test("same chapter, range", () => {
expect(formatWindowReference("Genesis", 1, 1, 1, 3)).toBe("Genesis 1:1-3");
});
test("same chapter, single verse", () => {
expect(formatWindowReference("Jude", 1, 5, 1, 5)).toBe("Jude 1:5");
});
test("cross chapter uses en-dash", () => {
expect(formatWindowReference("Genesis", 1, 31, 2, 1)).toBe("Genesis 1:31\u20132:1");
});
});
describe("composeVerseWindow — every book's opening verse is safe", () => {
// The spec guarantees every book has >=3 verses after its opening, so Gen 1:1-style
// fall-forward must succeed for the first verse of every book.
test("book 1:1 of all 66 books resolves to a 3-verse in-book window", () => {
const ids = Object.keys(bookIdToNumber);
for (const id of ids) {
const num = bookIdToNumber[id];
const w = composeVerseWindow(num, 1, 1);
expect(w, `${getBookById(id)!.name} 1:1 should resolve`).not.toBeNull();
expect(w!.verses).toHaveLength(3);
expect(w!.bookId).toBe(id);
}
});
});
+11 -1
View File
@@ -1,5 +1,16 @@
# in progress # in progress
(admin/stats)
- [ ] WAU/MAU graph
- [ ] new players per day should show 3 months
- [ ] full width graphs need to have mouse tracking correctly
- [ ] re: "segment mix over time": we need a counter for recently returning players
- [ ] (add clarification for past 30 days) maybe just remove "churned"
(community stats)
- [ ] show yesterday's verse in the community stats
- [ ] show human readable numbers in the community stats
- [ ] fix the "last 30 days" github style view to be full-width
# todo # todo
@@ -17,7 +28,6 @@
- How do you balance rewarding knowledge vs incentivising learning? - How do you balance rewarding knowledge vs incentivising learning?
- instructions - instructions
- classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible. - classic mode: identify what book the verse is from (e.g. Genesis, John, Revelations...) in as few guesses as possible.
+13 -2
View File
@@ -1,10 +1,21 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig, loadEnv } from 'vite';
export default defineConfig({ export default defineConfig(({ mode }) => {
// SvelteKit evaluates server modules during the build, and `$lib/server/db`
// requires DATABASE_URL at import time. Bun does not always surface the
// auto-loaded `.env` to Vite's build workers, so copy `.env` values into
// `process.env` here (without overriding a real environment variable).
const env = loadEnv(mode, process.cwd(), '');
for (const [key, value] of Object.entries(env)) {
if (process.env[key] === undefined) process.env[key] = value;
}
return {
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
server: { server: {
allowedHosts: ['bibdle.com'] allowedHosts: ['bibdle.com']
} }
};
}); });