3 Commits
Author SHA1 Message Date
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
73 changed files with 6727 additions and 1244 deletions

No files matched your search

+16
View File
@@ -1,11 +1,27 @@
DATABASE_URL=example.db
# Local-only admin bypass for testing /admin/stats without the real admin
# account. NEVER enable in production (it is also force-disabled when
# NODE_ENV=production).
# DEV_ADMIN_ALL=true
# Cron job secret for protected endpoints (e.g. send-daily-verse)
CRON_SECRET=your-cron-secret-here
# Discord webhook URL for posting the daily verse
DISCORD_DAILY_WEBHOOK=https://discord.com/api/webhooks/your-webhook-url
# Discord admin webhook for the daily statistics digest (optional; digest is skipped if unset)
DISCORD_ADMIN_WEBHOOK=https://discord.com/api/webhooks/your-admin-webhook-url
# Statistics thresholds (optional; these override the built-in defaults)
# STATS_CORE_PLAYS_PER_WEEK=4
# STATS_CHURN_DAYS=30
# STATS_D1_TARGET=20
# STATS_RETURN7_TARGET=25
# STATS_FIRST_TRY_MIN=15
# STATS_FIRST_TRY_MAX=35
PUBLIC_SITE_URL=https://bibdle.com
# nodemailer
+19 -5
View File
@@ -44,14 +44,18 @@ 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` endpoint
- `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)
@@ -60,11 +64,14 @@ See `.env.example`. Required/used variables:
### Database Schema (`src/lib/server/db/schema.ts`)
- **user** — `id`, `firstName`, `lastName`, `email` (unique), `passwordHash`, `appleId` (unique), `googleId` (unique), `isPrivate`
- **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.
@@ -113,17 +120,21 @@ Authenticated users who have solved today's puzzle can submit a verse for schedu
| `/greek-random` | Debug page showing a random verse in parallel Greek (1904) / English (NKJV). |
| `/similarity` | Search a sentence and return the most similar Bible verses via the (currently disabled) embeddings model. |
| `/about` | About page with the project's backstory and social links. |
| `/global` | Public stats dashboard: completions today/all-time, unique & weekly & monthly players, active streak distribution, 14-day completions trend, retention/return-rate metrics. |
| `/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. |
| `POST /api/daily-verse` | Fetch (and cache) the verse for a given `YYYY-MM-DD` date. Also records a deduped `verse_served` event for the supplied `anonymousId` (started→finished funnel). |
| `POST /api/events` | Fire-and-forget analytics sink. Allowlisted names: `verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`; `userId` resolved from the session. Always returns 204. |
| `POST /api/cron/build-stats` | Cron-only (bearer `CRON_SECRET`). Builds yesterday's snapshot (`America/New_York`) and posts the digest to `DISCORD_ADMIN_WEBHOOK`. |
| `POST /api/admin/rebuild-stats` | Admin-only. Rebuilds one snapshot: `{ date: 'YYYY-MM-DD' }`. |
| `POST /api/submit-completion` | Submit a game result (`anonymousId`, `date`, `guessCount`, `guesses`); returns solve rank, guess rank, total solves, average guesses, ties, and percentile. Unique on `(anonymousId, date)`. |
| `GET /api/streak?anonymousId=X&localDate=Y` | Current streak: walks backwards from the client's local date through completed dates, counting consecutive days. Single-day streaks are reported as 0 (minimum displayed streak is 2). |
| `GET /api/streak-percentile?streak=N&localDate=Y` | Streak percentile ranking computed across all players' current streaks. |
@@ -184,7 +195,10 @@ A streak counts consecutive calendar days (in the player's local timezone) on wh
| `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 |
| `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
-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=="],
"@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/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=="],
"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=="],
"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-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=="],
"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=="],
"@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=="],
"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": {},
"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": {},
+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,
"tag": "0003_overjoyed_mindworm",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1789505566968,
"tag": "0004_slim_nomad",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "bun --bun vite dev",
"build": "vite build",
"build": "bun --bun vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+6
View File
@@ -11,6 +11,7 @@
import StreakCounter from "./StreakCounter.svelte";
import ChapterGuess from "./ChapterGuess.svelte";
import SubmitVerse from "./SubmitVerse.svelte";
import { track } from "$lib/utils/analytics";
interface StatsData {
solveRank: number;
@@ -256,11 +257,13 @@
: "Copy to Clipboard"}
onclick={() => {
if (hasWebShare) {
track("share", { snippet: includeSnippet });
shareResult(effectiveShareText);
} else {
if (!copyTracked) {
copyTracked = true;
}
track("share_copy", { snippet: includeSnippet });
clipboardCopy(effectiveShareText);
copySuccess = true;
setTimeout(() => {
@@ -289,6 +292,7 @@
if (!copyTracked) {
copyTracked = true;
}
track("share_copy", { snippet: includeSnippet });
clipboardCopy(effectiveShareText);
showSnippetOption = true;
bubbleCopied = true;
@@ -429,6 +433,7 @@
rel="noopener noreferrer"
class="discord-btn w-full"
data-umami-event="Join the BIBDLE Discord"
onclick={() => track("discord_click")}
>
<svg
class="discord-icon"
@@ -454,6 +459,7 @@
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">
+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,58 @@
<script lang="ts">
interface Props {
data: { date: string; count: number }[];
color?: string;
}
let { data, color = '52,211,153' }: Props = $props();
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
const max = $derived(Math.max(1, ...data.map((d) => d.count)));
// Build Sunday-aligned weeks ending on the last date.
const weeks = $derived.by(() => {
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
const first = new Date(data[0].date + 'T00:00:00Z');
const start = new Date(first);
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
const end = new Date(last);
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
const range = new Set(data.map((d) => d.date));
const out: { date: string; inRange: boolean }[][] = [];
let cursor = new Date(start);
while (cursor <= end) {
const week: { date: string; inRange: boolean }[] = [];
for (let d = 0; d < 7; d++) {
const iso = cursor.toISOString().slice(0, 10);
week.push({ date: iso, inRange: range.has(iso) });
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
out.push(week);
}
return out;
});
</script>
<div class="overflow-x-auto">
<div class="flex gap-1">
<div class="flex flex-col justify-between py-0.5 text-[9px] text-gray-500">
<span>Sun</span><span>Wed</span><span>Sat</span>
</div>
<div class="flex gap-[3px]">
{#each weeks as week, wi (wi)}
<div class="flex flex-col gap-[3px]">
{#each week as cell (cell.date)}
{@const count = values.get(cell.date) ?? 0}
<div
class="h-3 w-3 rounded-[2px]"
style={cell.inRange
? `background:rgba(${color},${(0.12 + 0.88 * (count / max)).toFixed(3)})`
: 'background:transparent'}
title={cell.inRange ? `${cell.date}: ${count} finishes` : cell.date}
></div>
{/each}
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { CohortRow } from '$lib/types/stats';
interface Props {
cohorts: CohortRow[];
weeks?: number;
}
let { cohorts, weeks = 8 }: Props = $props();
function shade(rate: number | null): string {
if (rate === null) return 'background:rgba(255,255,255,0.03)';
const t = Math.min(1, rate / 100);
return `background:rgba(16,185,129,${(0.08 + 0.82 * t).toFixed(3)})`;
}
</script>
<div class="overflow-x-auto">
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
<thead>
<tr>
<th class="pr-2 text-right font-normal">Cohort</th>
<th class="pr-2 font-normal">Size</th>
{#each Array(weeks) as _w, k (k)}
<th class="w-8 font-normal">W{k}</th>
{/each}
</tr>
</thead>
<tbody>
{#each cohorts as row (row.weekStart)}
<tr>
<td class="pr-2 text-right whitespace-nowrap text-gray-300">{row.weekStart.slice(5)}</td>
<td class="pr-2 text-right text-gray-400">{row.size}</td>
{#each Array(weeks) as _w, k (k)}
{@const rate = row.rates[k] ?? null}
<td
class="h-6 w-8 rounded-sm text-center align-middle text-gray-900"
style={shade(rate)}
title={`Week ${k}: ${rate === null ? 'not resolved' : rate + '%'}`}
>
{rate === null ? '' : Math.round(rate)}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
interface Props {
rowLabels: string[];
colLabels: string[];
values: number[][];
max?: number;
color?: string;
format?: (v: number) => string;
cellSize?: number;
}
let {
rowLabels,
colLabels,
values,
max,
color = '16,185,129',
format = (v) => String(v),
cellSize = 22
}: Props = $props();
const effectiveMax = $derived(
max ?? Math.max(1, ...values.flat().filter((v) => Number.isFinite(v)))
);
</script>
<div class="overflow-x-auto">
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
<thead>
<tr>
<th></th>
{#each colLabels as label, c (c)}
<th class="px-0.5 font-normal">{label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rowLabels as row, r (r)}
<tr>
<th class="pr-1 text-right font-normal whitespace-nowrap">{row}</th>
{#each colLabels as _label, c (c)}
{@const v = values[r]?.[c] ?? 0}
<td
class="rounded-sm text-center align-middle text-[9px] text-gray-900"
style={`width:${cellSize}px;height:${cellSize}px;background:rgba(${color},${Math.min(1, v / effectiveMax).toFixed(3)})`}
title={`${row} · ${colLabels[c]}: ${format(v)}`}
>
{v > 0 ? format(v) : ''}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
@@ -0,0 +1,254 @@
<script lang="ts">
interface Series {
name: string;
color: string;
points: { date: string; value: number | null }[];
step?: boolean;
}
interface Props {
series: Series[];
height?: number;
ySuffix?: string;
yMin?: number;
yMax?: number;
showLegend?: boolean;
}
let {
series,
height = 180,
ySuffix = '',
yMin,
yMax,
showLegend = true
}: Props = $props();
const W = 600;
const pad = { l: 36, r: 10, t: 10, b: 22 };
const innerW = W - pad.l - pad.r;
const innerH = $derived(height - pad.t - pad.b);
const allValues = $derived(
series.flatMap((s) => s.points.map((p) => p.value)).filter((v): v is number => v !== null)
);
const domain = $derived.by(() => {
const lo = yMin ?? (allValues.length ? Math.min(...allValues) : 0);
const hi = yMax ?? (allValues.length ? Math.max(...allValues) : 1);
const span = hi - lo || 1;
return { lo: yMin ?? lo - span * 0.1, hi: yMax ?? hi + span * 0.1 };
});
const pointCount = $derived(
Math.max(1, ...series.map((s) => s.points.length))
);
function xAt(i: number): number {
if (pointCount <= 1) return pad.l + innerW / 2;
return pad.l + (i / (pointCount - 1)) * innerW;
}
function yAt(v: number): number {
const t = (v - domain.lo) / (domain.hi - domain.lo || 1);
return pad.t + innerH - t * innerH;
}
/** Break a series into path segments so nulls create gaps. */
const paths = $derived(
series.map((s) => {
const segments: string[] = [];
let current: string[] = [];
s.points.forEach((p, i) => {
if (p.value === null) {
if (current.length) segments.push(current.join(' '));
current = [];
return;
}
current.push(`${xAt(i).toFixed(1)},${yAt(p.value).toFixed(1)}`);
});
if (current.length) segments.push(current.join(' '));
return { name: s.name, color: s.color, segments, step: s.step };
})
);
const firstLabel = $derived(series[0]?.points[0]?.date?.slice(5) ?? '');
const lastLabel = $derived(
series[0]?.points[series[0].points.length - 1]?.date?.slice(5) ?? ''
);
// --- Hover / tooltip state -------------------------------------------------
let wrapperEl: HTMLDivElement | undefined = $state();
let hoverIndex = $state<number | null>(null);
function handlePointerMove(event: PointerEvent) {
if (!wrapperEl || pointCount <= 0) return;
const rect = wrapperEl.getBoundingClientRect();
if (rect.width === 0) return;
const scale = rect.width / W;
const svgX = (event.clientX - rect.left) / scale;
if (pointCount <= 1) {
hoverIndex = 0;
return;
}
const t = (svgX - pad.l) / innerW;
const i = Math.round(t * (pointCount - 1));
hoverIndex = Math.min(pointCount - 1, Math.max(0, i));
}
function clearHover() {
hoverIndex = null;
}
/** Rows shown in the tooltip for the currently hovered index. */
const hoverRows = $derived.by(() => {
const i = hoverIndex;
if (i === null) return [];
return series.map((s) => ({
name: s.name,
color: s.color,
value: s.points[i]?.value ?? null
}));
});
const hoveredDate = $derived.by(() => {
if (hoverIndex === null) return null;
for (const s of series) {
const point = s.points[hoverIndex];
if (point) return point.date;
}
return null;
});
const hasHoverValues = $derived(hoverRows.some((r) => r.value !== null));
const tooltipLeft = $derived(
hoverIndex === null ? 50 : Math.min(85, Math.max(15, (xAt(hoverIndex) / W) * 100))
);
function formatValue(value: number | null): string {
if (value === null) return '—';
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1));
return `${rounded.toLocaleString()}${ySuffix}`;
}
</script>
<div
class="relative w-full touch-pan-y"
bind:this={wrapperEl}
role="presentation"
onpointermove={handlePointerMove}
onpointerleave={clearHover}
onpointercancel={clearHover}
>
<svg
viewBox={`0 0 ${W} ${height}`}
class="w-full text-gray-400"
style={`height:${height}px`}
role="img"
>
<!-- gridlines -->
{#each [0, 0.5, 1] as t}
<line
x1={pad.l}
x2={W - pad.r}
y1={pad.t + innerH * t}
y2={pad.t + innerH * t}
stroke="currentColor"
stroke-opacity="0.15"
stroke-dasharray="2 3"
/>
{/each}
<text x={4} y={pad.t + 4} fill="currentColor" fill-opacity="0.8" font-size="9">
{domain.hi.toFixed(0)}{ySuffix}
</text>
<text x={4} y={pad.t + innerH} fill="currentColor" fill-opacity="0.8" font-size="9">
{domain.lo.toFixed(0)}{ySuffix}
</text>
{#each paths as s (s.name)}
{#each s.segments as d}
<polyline
points={d}
fill="none"
stroke={s.color}
stroke-width="1.75"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
opacity={hoverIndex === null ? 1 : 0.45}
style="transition:opacity 120ms ease"
/>
{/each}
{/each}
{#if hoverIndex !== null}
<!-- crosshair -->
<line
x1={xAt(hoverIndex)}
x2={xAt(hoverIndex)}
y1={pad.t}
y2={pad.t + innerH}
stroke="currentColor"
stroke-opacity="0.5"
stroke-dasharray="3 3"
vector-effect="non-scaling-stroke"
/>
<!-- highlighted points -->
{#each hoverRows as row (row.name)}
{#if row.value !== null}
<circle
cx={xAt(hoverIndex)}
cy={yAt(row.value)}
r="3.5"
fill={row.color}
stroke="#0b0b12"
stroke-width="1.5"
vector-effect="non-scaling-stroke"
/>
{/if}
{/each}
{/if}
<text x={pad.l} y={height - 6} fill="currentColor" fill-opacity="0.8" font-size="9">
{firstLabel}
</text>
<text
x={W - pad.r}
y={height - 6}
text-anchor="end"
fill="currentColor"
fill-opacity="0.8"
font-size="9"
>
{lastLabel}
</text>
</svg>
{#if hoverIndex !== null && hoveredDate !== null && hasHoverValues}
<div
class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-lg border border-white/15 bg-gray-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-gray-100 shadow-lg backdrop-blur-sm"
style={`left:${tooltipLeft}%`}
>
<p class="mb-1 font-semibold text-gray-300">{hoveredDate}</p>
<ul class="flex flex-col gap-0.5">
{#each hoverRows as row (row.name)}
<li class="flex items-center justify-between gap-3">
<span class="inline-flex items-center gap-1.5">
<span class="h-2 w-2 shrink-0 rounded-full" style={`background:${row.color}`}></span>
{row.name}
</span>
<span class="font-semibold tabular-nums text-gray-100">{formatValue(row.value)}</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if showLegend && series.length > 1}
<div class="mt-1 flex flex-wrap gap-3 text-[11px] text-gray-300">
{#each series as s (s.name)}
<span class="inline-flex items-center gap-1">
<span class="h-2 w-2 rounded-full" style={`background:${s.color}`}></span>
{s.name}
</span>
{/each}
</div>
{/if}
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
interface Props {
values: number[];
width?: number;
height?: number;
class?: string;
stroke?: string;
}
let {
values,
width = 120,
height = 32,
class: className = '',
stroke = 'currentColor'
}: Props = $props();
const points = $derived.by(() => {
if (values.length === 0) return '';
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
return values
.map((v, i) => {
const x = values.length === 1 ? width / 2 : (i / (values.length - 1)) * width;
const y = height - ((v - min) / span) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
</script>
<svg
viewBox={`0 0 ${width} ${height}`}
class={className}
preserveAspectRatio="none"
aria-hidden="true"
>
{#if points}
<polyline
{points}
fill="none"
{stroke}
stroke-width="1.5"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
{/if}
</svg>
@@ -0,0 +1,32 @@
<script lang="ts">
import type { SignalSeverity } from '$lib/types/stats';
interface Props {
severity: SignalSeverity;
children?: import('svelte').Snippet;
label?: string;
}
let { severity, children, label }: Props = $props();
const styles: Record<SignalSeverity, string> = {
good: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
info: 'bg-sky-500/15 text-sky-300 border-sky-500/30',
warn: 'bg-amber-500/15 text-amber-200 border-amber-500/30',
bad: 'bg-rose-500/15 text-rose-300 border-rose-500/30'
};
const dots: Record<SignalSeverity, string> = {
good: 'bg-emerald-400',
info: 'bg-sky-400',
warn: 'bg-amber-400',
bad: 'bg-rose-400'
};
</script>
<span
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold {styles[
severity
]}"
>
<span class="h-1.5 w-1.5 rounded-full {dots[severity]}"></span>
{#if children}{@render children()}{:else}{label}{/if}
</span>
@@ -0,0 +1,147 @@
<script lang="ts">
import { bibleBooks, type BibleSection, type Testament } from '$lib/types/bible';
import type { BookStat } from '$lib/types/stats';
interface Props {
coverage: BookStat[];
}
let { coverage }: Props = $props();
interface Tile extends BookStat {
name: string;
}
interface SectionGroup {
section: BibleSection;
testament: Testament;
books: Tile[];
days: number;
plays: number;
covered: number;
firstTryWeightedSum: number;
firstTryPlays: number;
}
const byId = $derived(new Map(coverage.map((c) => [c.bookId, c])));
const maxDays = $derived(Math.max(1, ...coverage.map((c) => c.days)));
const totalDays = $derived(coverage.reduce((a, b) => a + b.days, 0));
const totalPlays = $derived(coverage.reduce((a, b) => a + b.plays, 0));
/** Red → orange → yellow → green, driven by days featured relative to the busiest book. */
function coverageColor(days: number): string {
if (days <= 0) return 'hsl(220, 9%, 55%)';
const t = Math.min(1, days / maxDays);
return `hsl(${Math.round(t * 142)}, 78%, 55%)`;
}
const legendStops = [0, 0.25, 0.5, 0.75, 1];
const legendGradient = $derived(
`linear-gradient(90deg, ${legendStops
.map((t) => `hsl(${Math.round(t * 142)}, 78%, 55%) ${Math.round(t * 100)}%`)
.join(', ')})`
);
const legendTicks = $derived(legendStops.map((t) => Math.round(t * maxDays)));
/** Consecutive runs of books sharing a section (preserves canonical order). */
const groups = $derived.by(() => {
const out: SectionGroup[] = [];
for (const book of bibleBooks) {
const stat = byId.get(book.id);
if (!stat) continue;
let group = out[out.length - 1];
if (!group || group.section !== book.section || group.testament !== book.testament) {
group = {
section: book.section,
testament: book.testament,
books: [],
days: 0,
plays: 0,
covered: 0,
firstTryWeightedSum: 0,
firstTryPlays: 0
};
out.push(group);
}
group.books.push({ ...stat, name: book.name });
group.days += stat.days;
group.plays += stat.plays;
if (stat.days > 0) group.covered++;
if (stat.plays > 0) {
group.firstTryWeightedSum += stat.firstTryPct * stat.plays;
group.firstTryPlays += stat.plays;
}
}
return out;
});
</script>
<div class="flex w-full flex-col gap-5">
<!-- Numeric colour legend -->
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-gray-400">
<span>Featured less</span>
<span>Days featured →</span>
<span>Featured more</span>
</div>
<div class="h-3 w-full rounded-full ring-1 ring-inset ring-white/10" style={`background:${legendGradient}`}></div>
<div class="flex justify-between text-[10px] tabular-nums text-gray-300">
{#each legendTicks as tick}
<span class="font-semibold">{tick}</span>
{/each}
</div>
<p class="flex items-center gap-1.5 text-[10px] text-gray-500">
<span class="inline-block h-2.5 w-2.5 rounded-sm ring-1 ring-inset ring-white/10" style="background:hsl(220, 9%, 55%)"></span>
never featured (0 days)
</p>
</div>
<!-- Sections -->
<div class="flex flex-col gap-6">
{#each groups as group (group.testament + group.section)}
{@const share = totalDays ? Math.round((group.days / totalDays) * 100) : 0}
{@const avgFirstTry = group.firstTryPlays
? Math.round(group.firstTryWeightedSum / group.firstTryPlays)
: null}
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-white/10 pb-1">
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-100">
{group.section}
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-300">
{group.testament === 'old' ? 'OT' : 'NT'}
</span>
</h3>
<p class="text-[11px] text-gray-400">
<span class="font-semibold text-gray-200">{group.covered}/{group.books.length}</span> books covered ·
<span class="font-semibold text-gray-200">{group.days}</span> days ({share}%) ·
<span class="font-semibold text-gray-200">{group.plays}</span> plays
{#if avgFirstTry !== null}
· avg first-try <span class="font-semibold text-gray-200">{avgFirstTry}%</span>
{/if}
</p>
</div>
<div class="h-1 w-full overflow-hidden rounded-full bg-white/5">
<div
class="h-full rounded-full bg-white/40"
style={`width:${share}%`}
></div>
</div>
<div class="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-11">
{#each group.books as book (book.bookId)}
<div
class="flex min-h-11 flex-col items-center justify-center rounded-lg bg-white/5 px-1 py-1 text-center ring-1 ring-inset ring-white/10 transition-colors duration-100 hover:bg-white/10"
title={`${book.name}: featured ${book.days} day${book.days === 1 ? '' : 's'}, ${book.plays} play${book.plays === 1 ? '' : 's'}`}
>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{book.bookId}</span>
<span class="text-base font-bold leading-tight" style={`color:${coverageColor(book.days)}`}>{book.days}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
<p class="text-[11px] text-gray-500">
66 books over the last 180 days · {totalDays} featured days · {totalPlays} plays. Colour is scaled
to the busiest book ({maxDays} day{maxDays === 1 ? '' : 's'}). Hover a tile for full details.
</p>
</div>
@@ -0,0 +1,61 @@
<script lang="ts">
import DateNavigator from './DateNavigator.svelte';
import SettingsMenu from './SettingsMenu.svelte';
import { formatDateLabel } from '$lib/utils/stats-format';
import type { StatsSettings } from '$lib/types/stats';
interface Props {
date: string;
today: string;
generatedAt: Date;
buildKind: string;
matured: boolean;
stale: boolean;
settings: StatsSettings;
}
let { date, today, generatedAt, buildKind, matured, stale, settings }: Props = $props();
const matureHref = $derived(
`/admin/stats?date=${date}${matured ? '' : '&mature=1'}`
);
const generatedLabel = $derived(
generatedAt instanceof Date ? generatedAt.toISOString().slice(0, 16).replace('T', ' ') : ''
);
</script>
<header
class="sticky top-0 z-20 -mx-4 mb-4 border-b border-white/10 bg-gray-950/85 px-4 py-3 backdrop-blur"
>
<div class="mx-auto flex max-w-6xl flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-200">← Game</a>
<h1 class="text-xl font-bold text-gray-100">Growth Console</h1>
</div>
<div class="flex flex-wrap items-center gap-2">
<DateNavigator {date} {today} mature={matured} />
<a
href={matureHref}
class="rounded-lg border px-3 py-1.5 text-xs font-semibold {matured
? 'border-amber-400/50 bg-amber-400/15 text-amber-200'
: 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10'}"
>
{matured ? 'Matured view ✓' : 'Mature this view'}
</a>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<p class="text-xs text-gray-400">
Showing <span class="font-semibold text-gray-200">{formatDateLabel(date)}</span>
· snapshot built {generatedLabel} UTC
{#if buildKind}<span class="uppercase tracking-wide"> · {buildKind}</span>{/if}
{#if matured}
<span class="font-semibold text-amber-300"> · Matured — uses data after this date.</span>
{/if}
</p>
</div>
<SettingsMenu {settings} {stale} />
</div>
</header>
@@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation';
interface Props {
date: string;
today: string;
mature: boolean;
}
let { date, today, mature }: Props = $props();
function shift(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
const hrefFor = (d: string) => `/admin/stats?date=${d}${mature ? '&mature=1' : ''}`;
const prev = $derived(shift(date, -1));
const next = $derived(shift(date, 1));
const nextDisabled = $derived(next > today);
function onDateChange(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
if (value) goto(hrefFor(value));
}
</script>
<div class="flex flex-wrap items-center gap-2">
<a
href={hrefFor(prev)}
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
aria-label="Previous day"
>
←
</a>
<input
type="date"
value={date}
max={today}
onchange={onDateChange}
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
/>
{#if nextDisabled}
<span
class="cursor-not-allowed rounded-lg border border-white/5 bg-white/5 px-3 py-1.5 text-sm text-gray-600"
aria-label="Next day (unavailable)"
>
→
</span>
{:else}
<a
href={hrefFor(next)}
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
aria-label="Next day"
>
→
</a>
{/if}
<a
href={hrefFor(today)}
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10"
>
Latest
</a>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import Container from '$lib/components/Container.svelte';
</script>
<section class="flex flex-col gap-3">
<h2 class="text-lg font-bold text-gray-100">7 · Feature Impact</h2>
<Container class="p-4 w-full items-start text-left gap-2">
<p class="font-semibold text-gray-100">Needs data</p>
<p class="text-xs text-gray-400">
Feature-usage events are deferred. The <code>analytics_events</code> table already supports
them; only the emit points remain. Once added, this panel will show:
</p>
<ul class="list-disc pl-5 text-xs text-gray-400">
<li>Imposter-mode entries and their return rate</li>
<li>Hint reveals per finish and whether they help retention</li>
<li>Progress-page visits as a signup/retention signal</li>
<li>Verse submissions and their effect on next-day return</li>
</ul>
</Container>
</section>
+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>
+8
View File
@@ -3,6 +3,14 @@
// 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;
}
+2 -1
View File
@@ -102,7 +102,8 @@ export async function createUser(anonymousId: string, email: string, passwordHas
googleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
isPrivate: false,
createdAt: new Date()
};
await db.insert(table.user).values(user);
return user;
+2 -1
View File
@@ -102,7 +102,8 @@ export async function createUser(anonymousId: string, email: string, passwordHas
googleId: null,
firstName: firstName || null,
lastName: lastName || null,
isPrivate: false
isPrivate: false,
createdAt: new Date()
};
await db.insert(table.user).values(user);
return user;
+58 -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', {
id: text('id').primaryKey(),
@@ -8,7 +9,10 @@ export const user = sqliteTable('user', {
passwordHash: text('password_hash'),
appleId: text('apple_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', {
@@ -65,3 +69,55 @@ export const verseSubmissions = sqliteTable('verse_submissions', {
]);
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;
}
+37
View File
@@ -0,0 +1,37 @@
/** Pure UTC date arithmetic on `YYYY-MM-DD` strings. No DB, no Svelte. */
const DAY_MS = 1000 * 60 * 60 * 24;
export function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
/** Whole-day difference `b - a`. */
export function dayDiff(a: string, b: string): number {
return Math.round(
(new Date(b + 'T00:00:00Z').getTime() - new Date(a + 'T00:00:00Z').getTime()) / DAY_MS
);
}
/** The Sunday on or before `dateStr` (week cohorts are Sunday-aligned). */
export function startOfWeek(dateStr: string): string {
const day = new Date(dateStr + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
return addDays(dateStr, -day);
}
/** All dates from `start` to `end` inclusive. */
export function dateRange(start: string, end: string): string[] {
const out: string[] = [];
for (let d = start; d <= end; d = addDays(d, 1)) out.push(d);
return out;
}
export function todayUtc(): string {
return new Date().toISOString().slice(0, 10);
}
export function isValidDate(dateStr: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !Number.isNaN(new Date(dateStr + 'T00:00:00Z').getTime());
}
+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;
}
+684
View File
@@ -0,0 +1,684 @@
import { and, eq, lte } from 'drizzle-orm';
import { db as defaultDb } from '$lib/server/db';
import {
analyticsEvents,
dailyCompletions,
dailyVerses,
statsSnapshot,
user
} from '$lib/server/db/schema';
import type { StatsDb } from './db';
import type {
Activity,
CompletionRow,
DailyPoint,
HabitData,
HistogramBin,
Kpis,
PlayerState,
SeriesPoint,
SnapshotPayload,
StatsSettings,
VerseRow
} from '$lib/types/stats';
import { bibleBooks } from '$lib/types/bible';
import { getSettings, hashSettings } from './settings';
import { deriveSegments, countSegments } from './segments';
import { evaluateSignals } from './signals';
import {
activePlayers,
averageGuesses,
bookCoverage,
cohortTriangle,
dailyCompletionsSeries,
entropy,
firstPlayDates,
firstTryRate,
groupDatesByPlayer,
guessHistogram,
netGrowth,
newVsReturning,
overallReturnRate,
repeatIntervals,
retentionByGuessBucket,
streakSurvival,
timeToSecondPlayHistogram,
weekdayHourHeatmap
} from './metrics';
import { addDays, dateRange, dayDiff, startOfWeek, todayUtc } from './dates';
export type BuildKind = 'scheduled' | 'lazy' | 'reconstructed';
/**
* Bump whenever the snapshot payload shape changes. Older snapshots are
* rebuilt lazily so the UI never reads a stale shape.
*/
export const PAYLOAD_SCHEMA_VERSION = 2;
const RETENTION_WINDOW_DAYS = 180;
const SERIES_DAYS = 90;
const RECENT_KPI_DAYS = 30;
function endOfDay(dateStr: string): Date {
return new Date(dateStr + 'T23:59:59.999Z');
}
function round1(n: number): number {
return Math.round(n * 10) / 10;
}
export interface LoadedData {
completions: CompletionRow[];
verses: VerseRow[];
startedByDate: Map<string, number>;
shareEvents: number;
firstEventDate: string | null;
signupDates: string[];
}
/** Load all rows needed to build a snapshot as of `cutoff` (inclusive). */
export async function loadStatsData(
cutoff: string,
database: StatsDb = defaultDb
): Promise<LoadedData> {
const cutoffDate = endOfDay(cutoff);
const completionRows = await database
.select({
anonymousId: dailyCompletions.anonymousId,
date: dailyCompletions.date,
guessCount: dailyCompletions.guessCount,
guesses: dailyCompletions.guesses,
completedAt: dailyCompletions.completedAt
})
.from(dailyCompletions)
.where(and(lte(dailyCompletions.date, cutoff), lte(dailyCompletions.completedAt, cutoffDate)));
const verseRows = await database
.select({ date: dailyVerses.date, bookId: dailyVerses.bookId })
.from(dailyVerses)
.where(lte(dailyVerses.date, cutoff));
const eventRows = await database
.select({
name: analyticsEvents.name,
date: analyticsEvents.date,
createdAt: analyticsEvents.createdAt
})
.from(analyticsEvents)
.where(lte(analyticsEvents.createdAt, cutoffDate));
const startedByDate = new Map<string, number>();
let shareEvents = 0;
let firstEventDate: string | null = null;
for (const e of eventRows) {
const eventDate = e.date ?? new Date(e.createdAt).toISOString().slice(0, 10);
if (!firstEventDate || eventDate < firstEventDate) firstEventDate = eventDate;
if (e.name === 'verse_served' && e.date && e.date <= cutoff) {
startedByDate.set(e.date, (startedByDate.get(e.date) ?? 0) + 1);
}
if ((e.name === 'share' || e.name === 'share_copy') && eventDate <= cutoff) {
shareEvents++;
}
}
const userRows = await database
.select({ id: user.id, createdAt: user.createdAt })
.from(user)
.where(lte(user.createdAt, cutoffDate));
return {
completions: completionRows as CompletionRow[],
verses: verseRows,
startedByDate,
shareEvents,
firstEventDate,
signupDates: userRows
.filter((u) => u.createdAt)
.map((u) => new Date(u.createdAt as Date).toISOString().slice(0, 10))
};
}
/** Compact per-player state as of `asOfDate`, for players active in the window. */
export function computePlayerState(
completions: CompletionRow[],
asOfDate: string,
activeWindowDays: number
): PlayerState[] {
const windowStart = addDays(asOfDate, -(activeWindowDays - 1));
const sevenStart = addDays(asOfDate, -6);
const thirtyStart = addDays(asOfDate, -29);
const datesByPlayer = groupDatesByPlayer(completions);
const out: PlayerState[] = [];
for (const [id, dates] of datesByPlayer) {
const inScope = dates.filter((d) => d <= asOfDate);
if (inScope.length === 0) continue;
const firstDate = inScope[0];
const lastDate = inScope[inScope.length - 1];
if (lastDate < windowStart) continue;
// Current consecutive-day streak ending at the last active day.
let streak = 1;
for (let i = inScope.length - 1; i > 0; i--) {
if (dayDiff(inScope[i - 1], inScope[i]) === 1) streak++;
else break;
}
out.push({
id,
firstDate,
lastDate,
totalPlays: inScope.length,
plays7: inScope.filter((d) => d >= sevenStart).length,
plays30: inScope.filter((d) => d >= thirtyStart).length,
streak
});
}
return out;
}
/** Pooled return rate over a run of cohort days (more stable than single days). */
function pooledReturn(
completions: CompletionRow[],
cohortEnd: string,
cohortDays: number,
n: number
): number | null {
const firsts = firstPlayDates(completions);
const datesByPlayer = groupDatesByPlayer(completions);
const start = addDays(cohortEnd, -(cohortDays - 1));
let cohort = 0;
let returned = 0;
for (const [id, first] of firsts) {
if (first < start || first > cohortEnd) continue;
cohort++;
const limit = addDays(first, n);
const dates = datesByPlayer.get(id) ?? [];
if (dates.some((d) => d > first && d <= limit)) returned++;
}
if (cohort === 0) return null;
return round1((returned / cohort) * 100);
}
function buildActivity(
pointCompletions: CompletionRow[],
asOfDate: string,
settings: StatsSettings
): Activity {
const dau = activePlayers(pointCompletions, asOfDate, 1);
const wau = activePlayers(pointCompletions, asOfDate, 7);
const mau = activePlayers(pointCompletions, asOfDate, 30);
const { returningShare } = newVsReturning(pointCompletions, asOfDate);
const growth = netGrowth(pointCompletions, asOfDate, 7, settings.resurrect_gap_days);
// Core count current + one week earlier, both with current thresholds.
const core = deriveSegments(
computePlayerState(pointCompletions, asOfDate, settings.active_window_days),
asOfDate,
settings
).core.length;
const prevDate = addDays(asOfDate, -7);
const corePrevious = deriveSegments(
computePlayerState(
pointCompletions.filter((c) => c.date <= prevDate),
prevDate,
settings.active_window_days
),
prevDate,
settings
).core.length;
const prev = addDays(asOfDate, -7);
const previous = {
dau: activePlayers(pointCompletions, prev, 1),
wau: activePlayers(pointCompletions, prev, 7),
mau: activePlayers(pointCompletions, prev, 30),
core: corePrevious
};
return {
dau,
wau,
mau,
core,
returningShare,
newPlayers: growth.newPlayers,
resurrected: growth.resurrected,
churned: growth.churned,
netGrowth: growth.netGrowth,
corePrevious,
previous
};
}
function buildKpis(
pointCompletions: CompletionRow[],
data: LoadedData,
asOfDate: string
): Kpis {
const recentStart = addDays(asOfDate, -(RECENT_KPI_DAYS - 1));
const recent = pointCompletions.filter((c) => c.date >= recentStart);
const shares = data.shareEvents;
const completionsInRecent = recent.length;
// Completion rate: only meaningful once verse_served events exist.
let started = 0;
for (const [date, count] of data.startedByDate) {
if (date >= recentStart && date <= asOfDate) started += count;
}
const completionRate = started > 0 ? round1((completionsInRecent / started) * 100) : null;
const shareRate = shares > 0 && completionsInRecent > 0
? round1((shares / completionsInRecent) * 100)
: null;
return {
// Most recent resolvable cohorts (as-of yesterday can't resolve its own D1).
d1Return: pooledReturn(pointCompletions, addDays(asOfDate, -1), 7, 1),
return7: pooledReturn(pointCompletions, addDays(asOfDate, -7), 7, 7),
overallReturnRate: overallReturnRate(pointCompletions),
completionRate,
shareRate,
firstTryRate: firstTryRate(recent),
avgGuesses: averageGuesses(recent)
};
}
function buildCurves(
pointCompletions: CompletionRow[],
allCompletions: CompletionRow[],
asOfDate: string,
n: number,
matured: boolean
): SeriesPoint[] {
const series: SeriesPoint[] = [];
// Only cohorts whose window has resolved by `asOfDate` are point-in-time
// honest; for matured builds the effective end is today.
const effectiveEnd = matured ? todayUtc() : asOfDate;
const latest = addDays(effectiveEnd, -n);
const start = addDays(latest, -29);
const firsts = firstPlayDates(pointCompletions);
const datesByPlayer = groupDatesByPlayer(allCompletions);
for (const date of dateRange(start, latest)) {
const limit = addDays(date, n);
let cohort = 0;
let returned = 0;
for (const [id, first] of firsts) {
if (first !== date) continue;
cohort++;
const dates = datesByPlayer.get(id) ?? [];
if (dates.some((d) => d > date && d <= limit)) returned++;
}
series.push({ date, value: cohort >= 3 ? round1((returned / cohort) * 100) : null });
}
return series;
}
function buildHabit(pointCompletions: CompletionRow[], players: PlayerState[], asOfDate: string): HabitData {
const calendarStart = addDays(asOfDate, -119);
const calendarCounts = new Map<string, number>();
for (const c of pointCompletions) {
if (c.date < calendarStart || c.date > asOfDate) continue;
calendarCounts.set(c.date, (calendarCounts.get(c.date) ?? 0) + 1);
}
const calendar = dateRange(calendarStart, asOfDate).map((date) => ({
date,
count: calendarCounts.get(date) ?? 0
}));
// Current-streak distribution, bucketed.
const buckets = new Map<string, number>([
['1', 0],
['2', 0],
['3–4', 0],
['5–6', 0],
['7–13', 0],
['14+', 0]
]);
for (const p of players) {
if (p.streak <= 1) continue;
const label =
p.streak === 2
? '2'
: p.streak <= 4
? '3–4'
: p.streak <= 6
? '5–6'
: p.streak <= 13
? '7–13'
: '14+';
buckets.set(label, (buckets.get(label) ?? 0) + 1);
}
const streakLengths: HistogramBin[] = [...buckets.entries()].map(([label, value]) => ({
label,
value
}));
return {
streakSurvival: streakSurvival(pointCompletions),
streakLengths,
calendar,
weekdayHour: weekdayHourHeatmap(pointCompletions)
};
}
function buildSegmentMix(
pointCompletions: CompletionRow[],
asOfDate: string,
settings: StatsSettings
): SnapshotPayload['retention']['segmentMix'] {
const start = addDays(asOfDate, -29);
const out: SnapshotPayload['retention']['segmentMix'] = [];
for (const date of dateRange(start, asOfDate)) {
const point = pointCompletions.filter((c) => c.date <= date);
const players = computePlayerState(point, date, settings.active_window_days);
const counts = countSegments(deriveSegments(players, date, settings));
out.push({
date,
new: counts.new,
core: counts.core,
returning: counts.returning,
resurrected: counts.resurrected,
atRisk: counts.atRisk,
churned: counts.churned
});
}
return out;
}
function buildGrowth(
data: LoadedData,
players: PlayerState[],
asOfDate: string
): SnapshotPayload['growth'] {
const thresholds = [1, 2, 5, 10, 25, 50];
const lifecycleFunnel = thresholds.map((t) => ({
label: `${t}+`,
players: players.filter((p) => p.totalPlays >= t).length
}));
const start = addDays(asOfDate, -89);
const counts = new Map<string, number>();
for (const d of data.signupDates) {
if (d >= start && d <= asOfDate) counts.set(d, (counts.get(d) ?? 0) + 1);
}
const signups = dateRange(start, asOfDate).map((date) => ({
date,
count: counts.get(date) ?? 0
}));
return { lifecycleFunnel, signups };
}
function buildVerseMix(
pointCompletions: CompletionRow[],
verses: VerseRow[],
asOfDate: string
): SnapshotPayload['verseMix'] {
const mixStart = addDays(asOfDate, -179);
const inRangeVerses = verses.filter((v) => v.date >= mixStart && v.date <= asOfDate);
const oldTestament = new Set(
bibleBooks.filter((b) => b.testament === 'old').map((b) => b.id)
);
const coverage = bookCoverage(inRangeVerses, pointCompletions, {
start: mixStart,
end: asOfDate
});
// Weekly testament mix + book-distribution entropy.
const weeksBack = 12;
const weekStart = startOfWeek(addDays(asOfDate, -(weeksBack - 1) * 7));
const testamentMix: { date: string; old: number; new: number }[] = [];
const entropySeries: SeriesPoint[] = [];
for (let w = 0; w < weeksBack; w++) {
const wStart = addDays(weekStart, w * 7);
const wEnd = addDays(wStart, 6);
const rows = inRangeVerses.filter((v) => v.date >= wStart && v.date <= wEnd);
if (rows.length === 0) {
testamentMix.push({ date: wStart, old: 0, new: 0 });
entropySeries.push({ date: wStart, value: null });
continue;
}
const bookCounts = new Map<string, number>();
let old = 0;
let nt = 0;
for (const v of rows) {
bookCounts.set(v.bookId, (bookCounts.get(v.bookId) ?? 0) + 1);
if (oldTestament.has(v.bookId)) old++;
else nt++;
}
testamentMix.push({ date: wStart, old, new: nt });
entropySeries.push({ date: wStart, value: entropy([...bookCounts.values()]) });
}
const firstTryByBook = coverage
.filter((b) => b.plays >= 3)
.sort((a, b) => b.plays - a.plays);
return {
coverage,
testamentMix,
entropy: entropySeries,
repeatIntervals: repeatIntervals(verses),
firstTryByBook,
retentionByBook: coverage.filter((b) => b.d1Return !== null),
retentionByGuessBucket: retentionByGuessBucket(pointCompletions),
achievability: guessHistogram(pointCompletions.filter((c) => c.date >= addDays(asOfDate, -29)))
};
}
export interface ComputeOptions {
/** Data cutoff for retention resolution (defaults to asOfDate). */
matureThrough?: string;
}
/**
* Compute a full snapshot payload. Pure with respect to I/O: everything it
* needs is passed in, which keeps the point-in-time cutoff testable.
*/
export function computePayload(
asOfDate: string,
settings: StatsSettings,
data: LoadedData,
opts: ComputeOptions = {}
): SnapshotPayload {
const pointCompletions = data.completions.filter((c) => c.date <= asOfDate);
// Retention uses everything loaded: identical for point-in-time builds, and
// extends past `asOfDate` for matured builds.
const allCompletions = data.completions;
const matured = !!opts.matureThrough && opts.matureThrough > asOfDate;
const activity = buildActivity(pointCompletions, asOfDate, settings);
const kpis = buildKpis(pointCompletions, data, asOfDate);
const seriesStart = addDays(asOfDate, -(SERIES_DAYS - 1));
const baseSeries = dailyCompletionsSeries(pointCompletions, seriesStart, asOfDate);
const dailySeries: DailyPoint[] = baseSeries.map((d) => ({
...d,
started: data.startedByDate.get(d.date) ?? 0
}));
const players = computePlayerState(pointCompletions, asOfDate, settings.active_window_days);
const cohortStart = startOfWeek(addDays(asOfDate, -11 * 7));
const cohorts = cohortTriangle(allCompletions, {
start: cohortStart,
weeks: 12,
asOf: matured ? todayUtc() : asOfDate,
minSize: 3
});
const retention: SnapshotPayload['retention'] = {
cohorts,
curves: {
d1: buildCurves(pointCompletions, allCompletions, asOfDate, 1, matured),
d7: buildCurves(pointCompletions, allCompletions, asOfDate, 7, matured),
d30: buildCurves(pointCompletions, allCompletions, asOfDate, 30, matured)
},
timeToSecondPlay: timeToSecondPlayHistogram(allCompletions),
segmentMix: buildSegmentMix(pointCompletions, asOfDate, settings)
};
const growth = buildGrowth(data, players, asOfDate);
const verseMix = buildVerseMix(pointCompletions, data.verses, asOfDate);
const habit = buildHabit(pointCompletions, players, asOfDate);
const yesterday = pointCompletions.filter((c) => c.date === asOfDate);
const totals = {
completions: pointCompletions.length,
players: firstPlayDates(pointCompletions).size,
yesterdayCompletions: yesterday.length,
yesterdayAvgGuesses: averageGuesses(yesterday)
};
const payload: SnapshotPayload = {
asOfDate,
activity,
dailySeries,
kpis,
retention,
verseMix,
habit,
growth,
totals,
signals: [],
meta: {
hasStartedData: data.startedByDate.size > 0,
hasShareData: data.shareEvents > 0,
firstEventDate: data.firstEventDate,
generatedAt: Date.now(),
schemaVersion: PAYLOAD_SCHEMA_VERSION
}
};
payload.signals = evaluateSignals(payload, settings);
return payload;
}
export interface StoredSnapshot {
date: string;
generatedAt: Date;
buildKind: BuildKind;
configVersion: string;
payload: SnapshotPayload;
config: StatsSettings;
playerState: PlayerState[];
}
function parseRow(row: typeof statsSnapshot.$inferSelect): StoredSnapshot {
return {
date: row.date,
generatedAt: row.generatedAt,
buildKind: row.buildKind as BuildKind,
configVersion: row.configVersion,
payload: JSON.parse(row.payload) as SnapshotPayload,
config: JSON.parse(row.config) as StatsSettings,
playerState: row.playerState ? (JSON.parse(row.playerState) as PlayerState[]) : []
};
}
export async function getSnapshot(
date: string,
database: StatsDb = defaultDb
): Promise<StoredSnapshot | null> {
const [row] = await database.select().from(statsSnapshot).where(eq(statsSnapshot.date, date));
return row ? parseRow(row) : null;
}
export interface BuildResult {
snapshot: StoredSnapshot;
settings: StatsSettings;
buildKind: BuildKind;
}
/**
* Build and persist a snapshot. `replace=false` never overwrites an existing
* (immutable) snapshot; the caller re-reads instead.
*/
export async function buildSnapshot(
asOfDate: string,
opts: { kind: BuildKind; replace?: boolean; matureThrough?: string; persist?: boolean },
database: StatsDb = defaultDb
): Promise<BuildResult> {
const settings = await getSettings(database);
const version = hashSettings(settings);
const cutoff = opts.matureThrough ?? asOfDate;
const data = await loadStatsData(cutoff, database);
const payload = computePayload(asOfDate, settings, data, {
matureThrough: opts.matureThrough
});
const playerState = computePlayerState(
data.completions.filter((c) => c.date <= asOfDate),
asOfDate,
settings.active_window_days
);
const row = {
date: asOfDate,
generatedAt: new Date(),
buildKind: opts.kind,
configVersion: version,
payload: JSON.stringify(payload),
playerState: JSON.stringify(playerState),
config: JSON.stringify(settings)
};
if (opts.persist !== false) {
if (opts.replace) {
await database
.insert(statsSnapshot)
.values(row)
.onConflictDoUpdate({ target: statsSnapshot.date, set: row });
} else {
await database.insert(statsSnapshot).values(row).onConflictDoNothing();
}
}
return {
snapshot: {
date: asOfDate,
generatedAt: row.generatedAt,
buildKind: opts.kind,
configVersion: version,
payload,
config: settings,
playerState
},
settings,
buildKind: opts.kind
};
}
/**
* Return the snapshot for `date`, building it lazily if missing. Race-safe via
* `INSERT OR IGNORE` followed by a re-read.
*/
export async function ensureSnapshot(
date: string,
opts: { matureThrough?: string } = {},
database: StatsDb = defaultDb
): Promise<StoredSnapshot> {
const existing = await getSnapshot(date, database);
if (existing && existing.payload.meta?.schemaVersion === PAYLOAD_SCHEMA_VERSION) {
return existing;
}
// Missing or built with an older payload shape: (re)build deterministically
// for this point in time. The point-in-time cutoff keeps it faithful.
await buildSnapshot(
date,
{ kind: 'lazy', matureThrough: opts.matureThrough, replace: !!existing },
database
);
const stored = await getSnapshot(date, database);
if (!stored) throw new Error(`Failed to build snapshot for ${date}`);
return stored;
}
/** True when a stored snapshot was built with different thresholds or shape. */
export async function isSnapshotStale(
date: string,
database: StatsDb = defaultDb
): Promise<boolean> {
const snapshot = await getSnapshot(date, database);
if (!snapshot) return false;
if (snapshot.payload.meta?.schemaVersion !== PAYLOAD_SCHEMA_VERSION) return true;
const current = hashSettings(await getSettings(database));
return snapshot.configVersion !== current;
}
+2 -14
View File
@@ -1,18 +1,6 @@
import { browser } from "$app/environment";
import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game";
// Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet.
// Used to attribute stats to a player who hasn't signed in.
function getOrCreateAnonymousId(): string {
if (!browser) return "";
const key = "bibdle-anonymous-id";
let id = localStorage.getItem(key);
if (!id) {
id = generateUUID();
localStorage.setItem(key, id);
}
return id;
}
import { evaluateGuess, type Guess } from "$lib/utils/game";
import { getOrCreateAnonymousId } from "$lib/utils/analytics";
// Reactive store that keeps in-memory game state in sync with localStorage.
// Accepts getter functions (rather than plain values) so Svelte's reactivity
+252
View File
@@ -0,0 +1,252 @@
/**
* 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;
};
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 */
}
}
+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 ? '—' : String(n);
}
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' };
}
+4 -6
View File
@@ -6,6 +6,7 @@
import favicon from "$lib/assets/favicon.ico";
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
import { captureFirstVisit } from "$lib/utils/analytics";
let isDev = $state(false);
@@ -14,6 +15,9 @@
window.location.host === "localhost:5173" ||
window.location.host === "test.bibdle.com";
// Instrumentation E: capture first-touch referrer / UTM once per browser.
captureFirstVisit();
// Inject analytics script
const script = document.createElement("script");
script.defer = true;
@@ -31,12 +35,6 @@
<svelte:head>
<link rel="icon" href={favicon} />
<link
rel="alternate"
type="application/rss+xml"
title="Bibdle RSS Feed"
href="/feed.xml"
/>
<meta name="description" content="A Wordle-inspired daily Bible game" />
</svelte:head>
+13
View File
@@ -18,6 +18,18 @@ export const actions: Actions = {
const anonymousId = formData.get('anonymousId') as string;
const date = formData.get('date') as string;
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
if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) {
@@ -33,6 +45,7 @@ export const actions: Actions = {
anonymousId,
date,
guessCount,
guesses: guesses && guesses.length > 0 ? JSON.stringify(guesses) : null,
completedAt,
});
} catch (err: any) {
+3 -1
View File
@@ -1,15 +1,17 @@
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
export const ssr = false;
export const load: PageLoad = async ({ fetch, data }) => {
const localDate = new Date().toLocaleDateString("en-CA");
const anonymousId = getOrCreateAnonymousId();
const res = await fetch('/api/daily-verse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: localDate }),
body: JSON.stringify({ date: localDate, anonymousId }),
});
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 });
};
@@ -0,0 +1,31 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { addDays } 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 yesterday in `America/New_York`
* and 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 todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
const yesterday = addDays(todayEst, -1);
const { snapshot } = await buildSnapshot(yesterday, {
kind: 'scheduled',
replace: true
});
// Digest failure must never fail the build.
await sendDigest(snapshot.payload);
return json({ ok: true, date: yesterday, buildKind: 'scheduled' });
};
+15 -2
View File
@@ -2,10 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVerseForDate } from '$lib/server/daily-verse';
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 { date } = body;
const { date, anonymousId } = body;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
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 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({
dailyVerse,
correctBookId: dailyVerse.bookId,
+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 });
};
+69 -102
View File
@@ -1,133 +1,99 @@
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { dailyVerses } from '$lib/server/db/schema';
import { desc } from 'drizzle-orm';
// Helper: Escape XML special characters
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 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;
}
import { desc, lte } from 'drizzle-orm';
import {
FEED_ITEM_LIMIT,
cdata,
escapeXml,
feedFingerprint,
formatRFC822,
formatReadableDate,
formatVerseText,
latestModification,
todayInTimeZone
} from '$lib/server/rss';
export const GET: RequestHandler = async ({ request }) => {
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
.select()
.from(dailyVerses)
.where(lte(dailyVerses.date, today))
.orderBy(desc(dailyVerses.date))
.limit(30);
.limit(FEED_ITEM_LIMIT);
// Generate ETag based on latest verse date
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
// Content-addressed ETag + Last-Modified let aggregators revalidate
// 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
if (request.headers.get('If-None-Match') === etag) {
return new Response(null, { status: 304 });
const notModified =
request.headers.get('If-None-Match') === etag ||
(() => {
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 items = verses
.map(
(verse) => `
.map((verse) => {
const body = formatVerseText(verse.verseText);
const escaped = escapeXml(body);
return `
<item>
<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>
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
<pubDate>${formatRFC822(verse.date)}</pubDate>
</item>`
)
</item>`;
})
.join('');
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>
<title>Bibdle</title>
<link>${SITE_URL}</link>
<description>A daily Bible game</description>
<description>A daily Bible verse guessing game</description>
<language>en-us</language>
<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>
</rss>`;
@@ -135,11 +101,12 @@ export const GET: RequestHandler = async ({ request }) => {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
ETag: etag
ETag: etag,
'Last-Modified': lastModified
}
});
} catch (error) {
console.error('RSS feed generation error:', error);
return new Response('Internal Server Error', { status: 500 });
}
};
}
+18 -477
View File
@@ -1,484 +1,25 @@
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 { ensureSnapshot } from '$lib/server/stats/snapshot';
import { addDays } from '$lib/server/stats/dates';
function estDateStr(daysAgo = 0): string {
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
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);
}
/**
* Slim public community widget. Reads the latest daily snapshot and exposes
* only safe aggregates (no retention, churn, signup or revenue data).
*/
export const load: PageServerLoad = async () => {
const todayEst = estDateStr(0);
const yesterdayEst = estDateStr(1);
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;
}
}
const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
const yesterday = addDays(todayEst, -1);
const snapshot = await ensureSnapshot(yesterday);
const payload = snapshot.payload;
return {
todayEst,
sessionDepthCards,
stats: {
todayCount,
totalCount,
uniquePlayers,
weeklyPlayers,
activeStreaks,
avgGuessesToday,
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
asOfDate: payload.asOfDate,
completions: payload.totals.completions,
players: payload.totals.players,
weeklyPlayers: payload.activity.wau,
monthlyPlayers: payload.activity.mau,
yesterdayCompletions: payload.totals.yesterdayCompletions,
yesterdayAvgGuesses: payload.totals.yesterdayAvgGuesses,
calendar: payload.habit.calendar.slice(-30)
};
};
+46 -617
View File
@@ -1,627 +1,56 @@
<script lang="ts">
import Container from "$lib/components/Container.svelte";
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
interface Stats {
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 }[];
}
import type { PageData } from './$types';
import Container from '$lib/components/Container.svelte';
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
let { data }: { data: PageData } = $props();
const {
stats,
last14Days,
todayEst,
streakChart,
growth,
retention7dSeries,
retention30dSeries,
overallReturnRate,
newPlayerReturnSeries,
newPlayerReturnVelocity,
wauWeeks,
avgWau,
mauMonths,
calendarMauMonths,
sessionDepthCards,
} = $derived(data);
let mauMode = $state<'rolling' | 'calendar'>('rolling');
function signed(n: number, unit = ""): string {
if (n > 0) return `+${n}${unit}`;
if (n < 0) return `${n}${unit}`;
return `0${unit}`;
}
function trendColor(n: number): string {
if (n > 0) return "text-green-400";
if (n < 0) return "text-red-400";
return "text-gray-400";
}
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>
<svelte:head>
<title>Global Stats | Bibdle</title>
<title>Community Stats | Bibdle</title>
<meta name="description" content="Bibdle community statistics, as of yesterday." />
</svelte:head>
<div
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
>
<div class="max-w-6xl mx-auto px-4 py-8">
<a
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">
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
<p class="text-gray-400 text-sm mt-1">
EST reference date: {todayEst}
</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>
<CollapsibleTable
rows={newPlayerReturnSeries}
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>
<h3 class="text-base font-semibold text-gray-200 mb-3">
7-Day Retention
</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>
</td>
{/snippet}
</CollapsibleTable>
</div>
<!-- 30-day retention -->
<div>
<h3 class="text-base font-semibold text-gray-200 mb-3">
30-Day Retention
</h3>
<CollapsibleTable
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>
</td>
{/snippet}
</CollapsibleTable>
</div>
</div>
</section>
<div class="mx-auto max-w-3xl px-4 py-8">
<div class="mb-6 text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Community Stats</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">As of {formatDateLabel(data.asOfDate)}</p>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<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 finishes</p>
<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">Yesterday's finishes</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 yesterday</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-2">
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily finishes (last 30 days)</p>
<CalendarHeatmap data={data.calendar} />
</Container>
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
Updated once a day from completed games. <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
</p>
</div>
+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 yesterday's snapshot 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:** only data with `date <= asOfDate` (and `completedAt` before end of that day) — guarantees point-in-time.
- **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 },
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 },
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();
});
});
+141
View File
@@ -0,0 +1,141 @@
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,
PAYLOAD_SCHEMA_VERSION,
type LoadedData
} from '$lib/server/stats/snapshot';
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('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);
});
});
+16 -5
View File
@@ -1,10 +1,21 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
server: {
allowedHosts: ['bibdle.com']
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()],
server: {
allowedHosts: ['bibdle.com']
}
};
});