From 45ffa39f2af5a555dc25825a6492c78800d9a198 Mon Sep 17 00:00:00 2001 From: George Powell Date: Tue, 22 Sep 2026 11:38:39 -0400 Subject: [PATCH] first pass analytics redo --- .env.example | 16 + README.md | 24 +- bun.lock | 10 - drizzle/0004_slim_nomad.sql | 31 + drizzle/meta/0003_snapshot.json | 88 +++ drizzle/meta/0004_snapshot.json | 581 +++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 2 +- src/lib/components/WinScreen.svelte | 6 + src/lib/components/charts/BarList.svelte | 33 + .../components/charts/CalendarHeatmap.svelte | 58 ++ .../components/charts/CohortHeatmap.svelte | 47 ++ src/lib/components/charts/HeatmapGrid.svelte | 54 ++ .../components/charts/MiniLineChart.svelte | 254 +++++++ src/lib/components/charts/Sparkline.svelte | 49 ++ src/lib/components/charts/StatusPill.svelte | 32 + src/lib/components/stats/BookCoverage.svelte | 147 ++++ src/lib/components/stats/ConsoleChrome.svelte | 61 ++ src/lib/components/stats/DateNavigator.svelte | 65 ++ src/lib/components/stats/FeatureImpact.svelte | 20 + src/lib/components/stats/Glossary.svelte | 54 ++ src/lib/components/stats/GrowthLoop.svelte | 96 +++ src/lib/components/stats/HabitLab.svelte | 68 ++ src/lib/components/stats/KpiCard.svelte | 61 ++ src/lib/components/stats/Pulse.svelte | 103 +++ src/lib/components/stats/RetentionLab.svelte | 83 +++ src/lib/components/stats/SettingsMenu.svelte | 70 ++ src/lib/components/stats/SignalsFeed.svelte | 41 ++ src/lib/components/stats/VerseMixLab.svelte | 172 +++++ src/lib/server/admin.ts | 8 + src/lib/server/auth.test.ts | 3 +- src/lib/server/auth.ts | 3 +- src/lib/server/db/schema.ts | 60 +- src/lib/server/stats/dates.ts | 37 + src/lib/server/stats/db.ts | 4 + src/lib/server/stats/digest.ts | 44 ++ src/lib/server/stats/events.ts | 70 ++ src/lib/server/stats/metrics.ts | 478 ++++++++++++ src/lib/server/stats/segments.ts | 80 ++ src/lib/server/stats/settings.ts | 82 +++ src/lib/server/stats/signals.ts | 246 +++++++ src/lib/server/stats/snapshot.ts | 684 ++++++++++++++++++ src/lib/stores/game-persistence.svelte.ts | 16 +- src/lib/types/stats.ts | 252 +++++++ src/lib/utils/acquisition.ts | 36 + src/lib/utils/analytics.ts | 65 ++ src/lib/utils/stats-format.ts | 43 ++ src/routes/+layout.svelte | 4 + src/routes/+page.server.ts | 13 + src/routes/+page.ts | 4 +- src/routes/admin/stats/+page.server.ts | 89 +++ src/routes/admin/stats/+page.svelte | 75 ++ src/routes/api/admin/rebuild-stats/+server.ts | 31 + src/routes/api/cron/build-stats/+server.ts | 31 + src/routes/api/daily-verse/+server.ts | 17 +- src/routes/api/events/+server.ts | 52 ++ src/routes/global/+page.server.ts | 497 +------------ src/routes/global/+page.svelte | 665 ++--------------- statistics-implementation-checklist.md | 369 ++++++++++ statistics-plan.md | 340 +++++++++ tests/stats-analytics.test.ts | 31 + tests/stats-digest.test.ts | 77 ++ tests/stats-events.test.ts | 85 +++ tests/stats-metrics.test.ts | 193 +++++ tests/stats-segments.test.ts | 68 ++ tests/stats-settings.test.ts | 51 ++ tests/stats-signals.test.ts | 140 ++++ tests/stats-snapshot.test.ts | 141 ++++ vite.config.ts | 21 +- 69 files changed, 6400 insertions(+), 1138 deletions(-) create mode 100644 drizzle/0004_slim_nomad.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 src/lib/components/charts/BarList.svelte create mode 100644 src/lib/components/charts/CalendarHeatmap.svelte create mode 100644 src/lib/components/charts/CohortHeatmap.svelte create mode 100644 src/lib/components/charts/HeatmapGrid.svelte create mode 100644 src/lib/components/charts/MiniLineChart.svelte create mode 100644 src/lib/components/charts/Sparkline.svelte create mode 100644 src/lib/components/charts/StatusPill.svelte create mode 100644 src/lib/components/stats/BookCoverage.svelte create mode 100644 src/lib/components/stats/ConsoleChrome.svelte create mode 100644 src/lib/components/stats/DateNavigator.svelte create mode 100644 src/lib/components/stats/FeatureImpact.svelte create mode 100644 src/lib/components/stats/Glossary.svelte create mode 100644 src/lib/components/stats/GrowthLoop.svelte create mode 100644 src/lib/components/stats/HabitLab.svelte create mode 100644 src/lib/components/stats/KpiCard.svelte create mode 100644 src/lib/components/stats/Pulse.svelte create mode 100644 src/lib/components/stats/RetentionLab.svelte create mode 100644 src/lib/components/stats/SettingsMenu.svelte create mode 100644 src/lib/components/stats/SignalsFeed.svelte create mode 100644 src/lib/components/stats/VerseMixLab.svelte create mode 100644 src/lib/server/stats/dates.ts create mode 100644 src/lib/server/stats/db.ts create mode 100644 src/lib/server/stats/digest.ts create mode 100644 src/lib/server/stats/events.ts create mode 100644 src/lib/server/stats/metrics.ts create mode 100644 src/lib/server/stats/segments.ts create mode 100644 src/lib/server/stats/settings.ts create mode 100644 src/lib/server/stats/signals.ts create mode 100644 src/lib/server/stats/snapshot.ts create mode 100644 src/lib/types/stats.ts create mode 100644 src/lib/utils/acquisition.ts create mode 100644 src/lib/utils/analytics.ts create mode 100644 src/lib/utils/stats-format.ts create mode 100644 src/routes/admin/stats/+page.server.ts create mode 100644 src/routes/admin/stats/+page.svelte create mode 100644 src/routes/api/admin/rebuild-stats/+server.ts create mode 100644 src/routes/api/cron/build-stats/+server.ts create mode 100644 src/routes/api/events/+server.ts create mode 100644 statistics-implementation-checklist.md create mode 100644 statistics-plan.md create mode 100644 tests/stats-analytics.test.ts create mode 100644 tests/stats-digest.test.ts create mode 100644 tests/stats-events.test.ts create mode 100644 tests/stats-metrics.test.ts create mode 100644 tests/stats-segments.test.ts create mode 100644 tests/stats-settings.test.ts create mode 100644 tests/stats-signals.test.ts create mode 100644 tests/stats-snapshot.test.ts diff --git a/.env.example b/.env.example index cdd58d7..d597b77 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index db16417..1931c38 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bun.lock b/bun.lock index b69aaaf..3b69a06 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/drizzle/0004_slim_nomad.sql b/drizzle/0004_slim_nomad.sql new file mode 100644 index 0000000..48d8538 --- /dev/null +++ b/drizzle/0004_slim_nomad.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json index ed8432c..fcd6834 100644 --- a/drizzle/meta/0003_snapshot.json +++ b/drizzle/meta/0003_snapshot.json @@ -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": {}, diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..1a4f820 --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e120df6..8ac1da8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1774416309647, "tag": "0003_overjoyed_mindworm", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1789505566968, + "tag": "0004_slim_nomad", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package.json b/package.json index 81a50c1..9642fe1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/components/WinScreen.svelte b/src/lib/components/WinScreen.svelte index 608f9ff..8a74bac 100644 --- a/src/lib/components/WinScreen.svelte +++ b/src/lib/components/WinScreen.svelte @@ -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")} > track("donate_click")} > ❤️ Support bibdle's development
+ {#each items as item (item.label)} +
+ {item.label} +
+
+
+ + {signed && item.value >= 0 ? '+' : ''}{format(item.value)} + +
+ {/each} +
diff --git a/src/lib/components/charts/CalendarHeatmap.svelte b/src/lib/components/charts/CalendarHeatmap.svelte new file mode 100644 index 0000000..2067d75 --- /dev/null +++ b/src/lib/components/charts/CalendarHeatmap.svelte @@ -0,0 +1,58 @@ + + +
+
+
+ SunWedSat +
+
+ {#each weeks as week, wi (wi)} +
+ {#each week as cell (cell.date)} + {@const count = values.get(cell.date) ?? 0} +
+ {/each} +
+ {/each} +
+
+
\ No newline at end of file diff --git a/src/lib/components/charts/CohortHeatmap.svelte b/src/lib/components/charts/CohortHeatmap.svelte new file mode 100644 index 0000000..cca8e69 --- /dev/null +++ b/src/lib/components/charts/CohortHeatmap.svelte @@ -0,0 +1,47 @@ + + +
+ + + + + + {#each Array(weeks) as _w, k (k)} + + {/each} + + + + {#each cohorts as row (row.weekStart)} + + + + {#each Array(weeks) as _w, k (k)} + {@const rate = row.rates[k] ?? null} + + {/each} + + {/each} + +
CohortSizeW{k}
{row.weekStart.slice(5)}{row.size} + {rate === null ? '' : Math.round(rate)} +
+
\ No newline at end of file diff --git a/src/lib/components/charts/HeatmapGrid.svelte b/src/lib/components/charts/HeatmapGrid.svelte new file mode 100644 index 0000000..36ae02b --- /dev/null +++ b/src/lib/components/charts/HeatmapGrid.svelte @@ -0,0 +1,54 @@ + + +
+ + + + + {#each colLabels as label, c (c)} + + {/each} + + + + {#each rowLabels as row, r (r)} + + + {#each colLabels as _label, c (c)} + {@const v = values[r]?.[c] ?? 0} + + {/each} + + {/each} + +
{label}
{row} + {v > 0 ? format(v) : ''} +
+
\ No newline at end of file diff --git a/src/lib/components/charts/MiniLineChart.svelte b/src/lib/components/charts/MiniLineChart.svelte new file mode 100644 index 0000000..ad46c12 --- /dev/null +++ b/src/lib/components/charts/MiniLineChart.svelte @@ -0,0 +1,254 @@ + + + diff --git a/src/lib/components/charts/Sparkline.svelte b/src/lib/components/charts/Sparkline.svelte new file mode 100644 index 0000000..b30ce18 --- /dev/null +++ b/src/lib/components/charts/Sparkline.svelte @@ -0,0 +1,49 @@ + + + diff --git a/src/lib/components/charts/StatusPill.svelte b/src/lib/components/charts/StatusPill.svelte new file mode 100644 index 0000000..b2fcabd --- /dev/null +++ b/src/lib/components/charts/StatusPill.svelte @@ -0,0 +1,32 @@ + + + + + {#if children}{@render children()}{:else}{label}{/if} + diff --git a/src/lib/components/stats/BookCoverage.svelte b/src/lib/components/stats/BookCoverage.svelte new file mode 100644 index 0000000..c80aacc --- /dev/null +++ b/src/lib/components/stats/BookCoverage.svelte @@ -0,0 +1,147 @@ + + +
+ +
+
+ Featured less + Days featured → + Featured more +
+
+
+ {#each legendTicks as tick} + {tick} + {/each} +
+

+ + never featured (0 days) +

+
+ + +
+ {#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} +
+
+

+ {group.section} + + {group.testament === 'old' ? 'OT' : 'NT'} + +

+

+ {group.covered}/{group.books.length} books covered · + {group.days} days ({share}%) · + {group.plays} plays + {#if avgFirstTry !== null} + · avg first-try {avgFirstTry}% + {/if} +

+
+
+
+
+
+ {#each group.books as book (book.bookId)} +
+ {book.bookId} + {book.days} +
+ {/each} +
+
+ {/each} +
+ +

+ 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. +

+
\ No newline at end of file diff --git a/src/lib/components/stats/ConsoleChrome.svelte b/src/lib/components/stats/ConsoleChrome.svelte new file mode 100644 index 0000000..b364f48 --- /dev/null +++ b/src/lib/components/stats/ConsoleChrome.svelte @@ -0,0 +1,61 @@ + + +
+
+ + +
+

+ Showing {formatDateLabel(date)} + · snapshot built {generatedLabel} UTC + {#if buildKind} · {buildKind}{/if} + {#if matured} + · Matured — uses data after this date. + {/if} +

+
+ + +
+
diff --git a/src/lib/components/stats/DateNavigator.svelte b/src/lib/components/stats/DateNavigator.svelte new file mode 100644 index 0000000..803fd3e --- /dev/null +++ b/src/lib/components/stats/DateNavigator.svelte @@ -0,0 +1,65 @@ + + +
+ + ← + + + {#if nextDisabled} + + → + + {:else} + + → + + {/if} + + Latest + +
diff --git a/src/lib/components/stats/FeatureImpact.svelte b/src/lib/components/stats/FeatureImpact.svelte new file mode 100644 index 0000000..88e026d --- /dev/null +++ b/src/lib/components/stats/FeatureImpact.svelte @@ -0,0 +1,20 @@ + + +
+

7 · Feature Impact

+ +

Needs data

+

+ Feature-usage events are deferred. The analytics_events table already supports + them; only the emit points remain. Once added, this panel will show: +

+
    +
  • Imposter-mode entries and their return rate
  • +
  • Hint reveals per finish and whether they help retention
  • +
  • Progress-page visits as a signup/retention signal
  • +
  • Verse submissions and their effect on next-day return
  • +
+
+
\ No newline at end of file diff --git a/src/lib/components/stats/Glossary.svelte b/src/lib/components/stats/Glossary.svelte new file mode 100644 index 0000000..75d646a --- /dev/null +++ b/src/lib/components/stats/Glossary.svelte @@ -0,0 +1,54 @@ + + +
+

8 · Notes & Glossary

+ + + + + + + + + + + {#each GLOSSARY as entry (entry.name)} + + + + + + {/each} + +
MetricPlain definitionWhat to do if it's off
{entry.name}{entry.definition}{entry.action}
+
+

+ Snapshots are point-in-time. A day where a metric did not exist shows + “—”. +

+
diff --git a/src/lib/components/stats/GrowthLoop.svelte b/src/lib/components/stats/GrowthLoop.svelte new file mode 100644 index 0000000..3b34812 --- /dev/null +++ b/src/lib/components/stats/GrowthLoop.svelte @@ -0,0 +1,96 @@ + + +
+

2 · Growth Loop

+ + +

New players per day

+

First-time players each day. Hover later for post annotations.

+ ({ date: d.date, value: d.newPlayers })) }]} + showLegend={false} + /> +
+ +
+ +

Acquisition vs churn (7d)

+

+ New + resurrected − churned = net. Week-over-week. +

+ +

Net growth: {signed(payload.activity.netGrowth)}

+
+ + +

Lifecycle funnel

+

+ Of everyone active in the tracked window, how many reached each play count. +

+ ({ + label: f.label, + value: f.players, + color: 'rgba(52,211,153,0.5)' + }))} + /> +
+
+ +
+ +

Started → finished

+ {#if hasStarted} +

+ Completion rate {payload.kpis.completionRate ?? '—'}%. Started counts distinct + players who loaded the verse. +

+ ({ date: d.date, value: d.started })) }, + { name: 'Finished', color: '#34d399', points: series.map((d) => ({ date: d.date, value: d.completions })) } + ]} + /> + {:else} +

+ Needs data. Once players load the game this shows start → finish. +

+ {/if} +
+ + +

Signups

+

+ Account creations ({numOrDash(signupTotal)} in the last 90 days). Existing accounts show + “unknown” date. +

+ ({ date: s.date, value: s.count })) }]} + showLegend={false} + /> +
+
+
\ No newline at end of file diff --git a/src/lib/components/stats/HabitLab.svelte b/src/lib/components/stats/HabitLab.svelte new file mode 100644 index 0000000..d58fcde --- /dev/null +++ b/src/lib/components/stats/HabitLab.svelte @@ -0,0 +1,68 @@ + + +
+

5 · Habit Lab

+ +
+ +

Streak survival

+

+ If you've played N days in a row, how likely are you to play day N+1. +

+ ({ date: String(s.length), value: s.probability })) + } + ]} + showLegend={false} + ySuffix="%" + /> +
+ + +

Current streak lengths

+

Players whose current run has reached each length.

+ ({ label: `${b.label} days`, value: b.value, color: 'rgba(251,191,36,0.5)' }))} + /> +
+
+ + +

Daily finishes (last 120 days)

+ +
+ + +

When people play

+

+ Finishes by weekday and hour. Times are server time (UTC) and therefore approximate. +

+ String(v)} + /> +
+
\ No newline at end of file diff --git a/src/lib/components/stats/KpiCard.svelte b/src/lib/components/stats/KpiCard.svelte new file mode 100644 index 0000000..666498e --- /dev/null +++ b/src/lib/components/stats/KpiCard.svelte @@ -0,0 +1,61 @@ + + + +
+

+ {label} +

+ {#if delta} + {delta} + {/if} +
+

{value}

+ {#if sparkValues.length > 1} + + {/if} + {#if caption} +

{caption}

+ {/if} +
diff --git a/src/lib/components/stats/Pulse.svelte b/src/lib/components/stats/Pulse.svelte new file mode 100644 index 0000000..158f1b9 --- /dev/null +++ b/src/lib/components/stats/Pulse.svelte @@ -0,0 +1,103 @@ + + +
+
+

1 · Pulse

+ {#if topSignal} + {topSignal.title} + {/if} +
+ +
+ {verdict} +
+ +
+ + + + + + + + +
+
diff --git a/src/lib/components/stats/RetentionLab.svelte b/src/lib/components/stats/RetentionLab.svelte new file mode 100644 index 0000000..2141b20 --- /dev/null +++ b/src/lib/components/stats/RetentionLab.svelte @@ -0,0 +1,83 @@ + + +
+

3 · Retention Lab

+ +
+ +

+ Overall return rate +

+

+ {pctOrDash(payload.kpis.overallReturnRate)} +

+

+ Share of all players who ever played more than once. Target: + {settings.return7_target}%+ within 7 days. +

+
+ +

+ Time to second play +

+ ({ label: b.label, value: b.value }))} /> +
+
+ + +

Cohort retention triangle

+

+ Each row is a starting week; each column is weeks later. Darker = more came back. + Only resolved weeks are filled. +

+ +
+ + +

Retention curves

+

+ D1 / D7 / D30 return over time. Latest resolvable cohorts only. +

+ +
+ + +

Segment mix over time

+

+ Active players split into new, core, returning, at-risk and churned. Total tracked: + {numOrDash(payload.activity.mau)} recently active. +

+ ({ 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 })) } + ]} + /> +
+
\ No newline at end of file diff --git a/src/lib/components/stats/SettingsMenu.svelte b/src/lib/components/stats/SettingsMenu.svelte new file mode 100644 index 0000000..3b44e7c --- /dev/null +++ b/src/lib/components/stats/SettingsMenu.svelte @@ -0,0 +1,70 @@ + + +
+ + ⚙️ Settings & thresholds + {#if stale} + (snapshot definitions are outdated) + {/if} + + +

+ Changing these re-reads history; snapshots themselves are unchanged. +

+
+ {#each FIELDS as field (field.key)} + + {/each} +
+ + + Defaults come from SETTINGS_DEFAULTS / STATS_* env vars. + +
+
+
+
diff --git a/src/lib/components/stats/SignalsFeed.svelte b/src/lib/components/stats/SignalsFeed.svelte new file mode 100644 index 0000000..883fd74 --- /dev/null +++ b/src/lib/components/stats/SignalsFeed.svelte @@ -0,0 +1,41 @@ + + +
+

6 · Signals & Actions

+ {#if signals.length === 0} +

No signals for this date.

+ {:else} +
+ {#each signals as signal (signal.id)} + +
+

{signal.title}

+ +
+

{signal.detail}

+ {#if signal.action} +

+ What to do: {signal.action} +

+ {/if} +
+ {/each} +
+ {/if} +
diff --git a/src/lib/components/stats/VerseMixLab.svelte b/src/lib/components/stats/VerseMixLab.svelte new file mode 100644 index 0000000..d224239 --- /dev/null +++ b/src/lib/components/stats/VerseMixLab.svelte @@ -0,0 +1,172 @@ + + +
+

4 · Verse Mix Lab

+ + +

66-book coverage (180 days)

+

+ How often each book was the answer, grouped by canonical section. Red = rarely featured, + green = a staple. Aim for breadth, not repetition. +

+ +
+ +
+ +

Testament balance & variety

+

+ Weekly Old vs New Testament count, plus book-distribution entropy (higher = more variety). +

+ ({ date: t.date, value: t.old })) }, + { name: 'New', color: '#60a5fa', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.new })) } + ]} + /> + +
+ + +

Freshness

+

+ How long between repeats of the same book. More short gaps = repetitive mix. +

+ ({ label: b.label, value: b.value, color: 'rgba(244,114,182,0.5)' }))} + /> +

Achievability (last 30 days)

+ ({ label: `${b.label} guess${b.label === '1' ? '' : 'es'}`, value: b.value, color: 'rgba(96,165,250,0.5)' }))} + /> +
+
+ +
+ +

Retention by difficulty

+

+ Next-day return after solving in each guess bucket. Do hard days make people leave? +

+ + + + + + {#each payload.verseMix.retentionByGuessBucket as row (row.bucket)} + + + + + + {/each} + +
GuessesPlaysD1 return
{row.bucket}{row.plays} +
+
+
+
+ {row.d1 === null ? '—' : `${row.d1}%`} +
+
+
+ + +

Retention by book

+

+ Books whose day had below-average next-day return (min 5 plays). Candidate retirements. +

+ {#if alarms.length === 0} +

No clear churn outliers.

+ {:else} + + + + + + {#each alarms as b (b.bookId)} + + + + + + + {/each} + +
BookPlaysD1First-try
{nameOf(b.bookId)}{b.plays}{b.d1Return === null ? '—' : `${b.d1Return}%`}{b.firstTryPct}%
+ {/if} +
+
+ + +

Giveaway detector

+

+ Books solved first try more than half the time (min 3 plays). Likely self-revealing text — + down-weight in the curated mix. +

+ {#if giveaways.length === 0} +

No giveaway outliers.

+ {:else} + + + + + + {#each giveaways as b (b.bookId)} + + + + + + + {/each} + +
BookPlaysFirst-tryAvg guesses
{nameOf(b.bookId)}{b.plays}{b.firstTryPct}%{b.avgGuesses}
+ {/if} +
+ +

+ Before/after the curated mix system will appear here once enough pre/post data exists. +

+
\ No newline at end of file diff --git a/src/lib/server/admin.ts b/src/lib/server/admin.ts index de0b779..a6832e6 100644 --- a/src/lib/server/admin.ts +++ b/src/lib/server/admin.ts @@ -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; } diff --git a/src/lib/server/auth.test.ts b/src/lib/server/auth.test.ts index 77d4634..0df2dda 100644 --- a/src/lib/server/auth.test.ts +++ b/src/lib/server/auth.test.ts @@ -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; diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 724f398..bd52c52 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -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; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index a5bc1c1..03cce00 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -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; diff --git a/src/lib/server/stats/dates.ts b/src/lib/server/stats/dates.ts new file mode 100644 index 0000000..d7b24e3 --- /dev/null +++ b/src/lib/server/stats/dates.ts @@ -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()); +} diff --git a/src/lib/server/stats/db.ts b/src/lib/server/stats/db.ts new file mode 100644 index 0000000..4ad791d --- /dev/null +++ b/src/lib/server/stats/db.ts @@ -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; diff --git a/src/lib/server/stats/digest.ts b/src/lib/server/stats/digest.ts new file mode 100644 index 0000000..caa91c0 --- /dev/null +++ b/src/lib/server/stats/digest.ts @@ -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 { + 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; + } +} \ No newline at end of file diff --git a/src/lib/server/stats/events.ts b/src/lib/server/stats/events.ts new file mode 100644 index 0000000..b92a6b4 --- /dev/null +++ b/src/lib/server/stats/events.ts @@ -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 | 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 | 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 { + 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; + } +} diff --git a/src/lib/server/stats/metrics.ts b/src/lib/server/stats/metrics.ts new file mode 100644 index 0000000..f7f282e --- /dev/null +++ b/src/lib/server/stats/metrics.ts @@ -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 { + const map = new Map>(); + 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(); + 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 { + const out = new Map(); + 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(); + 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(); + 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(); + const continued = new Map(); + + 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(); + 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(); + const daysByBook = new Map>(); + 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(); + 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 }; diff --git a/src/lib/server/stats/segments.ts b/src/lib/server/stats/segments.ts new file mode 100644 index 0000000..3bba868 --- /dev/null +++ b/src/lib/server/stats/segments.ts @@ -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 + }; +} diff --git a/src/lib/server/stats/settings.ts b/src/lib/server/stats/settings.ts new file mode 100644 index 0000000..b96ed97 --- /dev/null +++ b/src/lib/server/stats/settings.ts @@ -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 { + 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, + database: StatsDb = defaultDb +): Promise { + 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 { + return hashSettings(await getSettings(database)); +} diff --git a/src/lib/server/stats/signals.ts b/src/lib/server/stats/signals.ts new file mode 100644 index 0000000..6e47bea --- /dev/null +++ b/src/lib/server/stats/signals.ts @@ -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 = { 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; +} diff --git a/src/lib/server/stats/snapshot.ts b/src/lib/server/stats/snapshot.ts new file mode 100644 index 0000000..ca62022 --- /dev/null +++ b/src/lib/server/stats/snapshot.ts @@ -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; + 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 { + 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(); + 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(); + 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([ + ['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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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; +} \ No newline at end of file diff --git a/src/lib/stores/game-persistence.svelte.ts b/src/lib/stores/game-persistence.svelte.ts index f4ef9d1..32531fe 100644 --- a/src/lib/stores/game-persistence.svelte.ts +++ b/src/lib/stores/game-persistence.svelte.ts @@ -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 diff --git a/src/lib/types/stats.ts b/src/lib/types/stats.ts new file mode 100644 index 0000000..782a9c2 --- /dev/null +++ b/src/lib/types/stats.ts @@ -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; + +// ── 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; diff --git a/src/lib/utils/acquisition.ts b/src/lib/utils/acquisition.ts new file mode 100644 index 0000000..261b82f --- /dev/null +++ b/src/lib/utils/acquisition.ts @@ -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')) + }; +} \ No newline at end of file diff --git a/src/lib/utils/analytics.ts b/src/lib/utils/analytics.ts new file mode 100644 index 0000000..5cc8d54 --- /dev/null +++ b/src/lib/utils/analytics.ts @@ -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 | 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 */ + } +} diff --git a/src/lib/utils/stats-format.ts b/src/lib/utils/stats-format.ts new file mode 100644 index 0000000..87ef115 --- /dev/null +++ b/src/lib/utils/stats-format.ts @@ -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' }; +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index d0ad2be..b330154 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 99fa61c..6f1bfe9 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -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) { diff --git a/src/routes/+page.ts b/src/routes/+page.ts index 5027f6b..279e48f 100644 --- a/src/routes/+page.ts +++ b/src/routes/+page.ts @@ -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(); diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts new file mode 100644 index 0000000..f190e6a --- /dev/null +++ b/src/routes/admin/stats/+page.server.ts @@ -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 = {}; + 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}` : ''}`); + } +}; \ No newline at end of file diff --git a/src/routes/admin/stats/+page.svelte b/src/routes/admin/stats/+page.svelte new file mode 100644 index 0000000..5247473 --- /dev/null +++ b/src/routes/admin/stats/+page.svelte @@ -0,0 +1,75 @@ + + + + Growth Console | Bibdle + + + +
+
+ + + + +
+

Segment mix (as of this date)

+
+ {#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)} + +

{item.label}

+

{numOrDash(item.value)}

+
+ {/each} +
+

+ Segments are re-derived from stored per-player state, so changing thresholds in Settings + updates every date instantly. Total tracked players: {numOrDash(segments.total)}. +

+
+ + + + + + + + +
+
\ No newline at end of file diff --git a/src/routes/api/admin/rebuild-stats/+server.ts b/src/routes/api/admin/rebuild-stats/+server.ts new file mode 100644 index 0000000..aca5949 --- /dev/null +++ b/src/routes/api/admin/rebuild-stats/+server.ts @@ -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 }); +}; \ No newline at end of file diff --git a/src/routes/api/cron/build-stats/+server.ts b/src/routes/api/cron/build-stats/+server.ts new file mode 100644 index 0000000..4dceef4 --- /dev/null +++ b/src/routes/api/cron/build-stats/+server.ts @@ -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' }); +}; \ No newline at end of file diff --git a/src/routes/api/daily-verse/+server.ts b/src/routes/api/daily-verse/+server.ts index 955809b..bed3631 100644 --- a/src/routes/api/daily-verse/+server.ts +++ b/src/routes/api/daily-verse/+server.ts @@ -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, diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts new file mode 100644 index 0000000..b12bfe4 --- /dev/null +++ b/src/routes/api/events/+server.ts @@ -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) + : 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 }); +}; \ No newline at end of file diff --git a/src/routes/global/+page.server.ts b/src/routes/global/+page.server.ts index 6ec4826..88015d4 100644 --- a/src/routes/global/+page.server.ts +++ b/src/routes/global/+page.server.ts @@ -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(); - const dateUsersMap = new Map>(); - 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(); - 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(); - const weekBUsers = new Set(); - const weekCUsers = new Set(); - - 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(); - 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(); - 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(); - 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(); - 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(); - 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) }; -}; +}; \ No newline at end of file diff --git a/src/routes/global/+page.svelte b/src/routes/global/+page.svelte index 2ec9ba6..4836d6f 100644 --- a/src/routes/global/+page.svelte +++ b/src/routes/global/+page.svelte @@ -1,627 +1,56 @@ - Global Stats | Bibdle + Community Stats | Bibdle + -
-
- - ← Back to Game - - -
-

Global Stats

-

- EST reference date: {todayEst} -

-
- -
- {#each statCards as card (card.label)} - - {card.label} - {card.value} - - {/each} -
- -
-

- Traffic & Growth (7-day windows) -

-
- - Completions Velocity - {signed(growth.completionsVelocity, "/day")} - vs prior 7 days - - - Completions Accel. - {signed(growth.completionsAcceleration, "/day")} - rate of change of velocity - - - User Velocity - {signed(growth.userVelocity)} - unique players, wk/wk - - - User Acceleration - {signed(growth.userAcceleration)} - rate of change of user velocity - - - New Players (7d) - {String(growth.newUsers7d)} - first-time players - - - Churned (7d) - {String(growth.churned7d)} - played wk prior, not this wk - - - Net Growth (7d) - {signed(growth.netGrowth7d)} - new minus churned - -
-
- -
-

Survival Curve

-

- Of players who completed N sessions, what % came back for N+1? -

-
- {#each sessionDepthCards as card (card.depth)} - - After {card.depth} plays - - {card.returnRate != null ? `${card.returnRate}%` : "N/A"} - - {card.players} players - - {/each} -
-
- -
-

- New Player Return Rate (7-day rolling avg) -

-
- - Return Rate (7d avg) - - {newPlayerReturnVelocity.current7dAvg != null - ? `${newPlayerReturnVelocity.current7dAvg}%` - : "N/A"} - - new players who came back - - - Return Rate Change - - {newPlayerReturnVelocity.change != null - ? signed(newPlayerReturnVelocity.change, "pp") - : "N/A"} - - vs prior 7 days - - - Prior 7d Avg - - {newPlayerReturnVelocity.prior7dAvg != null - ? `${newPlayerReturnVelocity.prior7dAvg}%` - : "N/A"} - - days 8–14 ago - -
- - - {#snippet row(item)} - {item.date} - {item.cohort} - - {item.rate != null ? `${item.rate}%` : '—'} - - - {item.rollingAvg != null ? `${item.rollingAvg}%` : '—'} - - -
- {#if item.rollingAvg != null} -
- {/if} -
- - {/snippet} -
-
- -
-

- Weekly Active Users -

-

- Unique players per 7-day window. Most recent week first. Avg - WAU: {avgWau} -

- - {#snippet row(item)} - {@const barPct = Math.round((item.wau / maxWau) * 100)} - {item.weekStart} – {item.weekEnd} - {item.wau} - - {item.changePct != null ? signed(item.changePct, '%') : '—'} - - -
-
-
- - {/snippet} -
-
- -
-

Monthly Active Users

-

- {mauMode === 'rolling' - ? 'Unique players per 30-day window. Most recent first.' - : 'Unique players per calendar month. Current month projected to end of month.'} -

- - {#if mauMode === 'rolling'} - - {#snippet row(item)} - {@const barPct = Math.round((item.mau / maxMau) * 100)} - {item.monthStart} – {item.monthEnd} - {item.mau} - - {item.changePct != null ? signed(item.changePct, '%') : '—'} - - -
-
-
- - {/snippet} -
- {:else} - - {#snippet row(item)} - {@const displayMau = item.projectedMau ?? item.mau} - {@const barPct = Math.round((displayMau / maxCalMau) * 100)} - - {item.label} - {#if item.isCurrentMonth} - (projected) - {/if} - - - {#if item.isCurrentMonth} - {item.mau} → {item.projectedMau ?? item.mau} - {:else} - {item.mau} - {/if} - - - {#if item.changePct != null} - {item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')} - {:else} - — - {/if} - - -
-
-
- - {/snippet} -
- {/if} -
- -
-

- Last 14 Days — Completions -

- - {#snippet row(item)} - {@const barPct = Math.round((item.count / maxCount) * 100)} - {item.date} - {item.count} - -
-
-
- - {/snippet} -
-
- -
-

- Active Streak Distribution -

- - {#snippet row(item)} - {@const barPct = Math.round((item.count / maxStreakCount) * 100)} - {item.days} - {item.count} - -
-
-
- - {/snippet} - {#snippet empty()} -

No active streaks yet.

- {/snippet} -
-
- -
-

- Retention Over Time -

-

- % 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. -

- -
- -
-

- 7-Day Retention -

- - {#snippet row(item)} - {item.date} - {item.cohortSize} - {item.rate}% - -
-
-
- - {/snippet} -
-
- - -
-

- 30-Day Retention -

- - {#snippet row(item)} - {item.date} - {item.cohortSize} - {item.rate}% - -
-
-
- - {/snippet} -
-
-
-
+
+
+

Community Stats

+

As of {formatDateLabel(data.asOfDate)}

-
+ +
+ +

All-time finishes

+

{numOrDash(data.completions)}

+
+ +

All-time players

+

{numOrDash(data.players)}

+
+ +

Players this week

+

{numOrDash(data.weeklyPlayers)}

+
+ +

Players this month

+

{numOrDash(data.monthlyPlayers)}

+
+ +

Yesterday's finishes

+

{numOrDash(data.yesterdayCompletions)}

+
+ +

Avg guesses yesterday

+

{numOrDash(data.yesterdayAvgGuesses)}

+
+
+ + +

Daily finishes (last 30 days)

+ +
+ +

+ Updated once a day from completed games. Play today's verse → +

+
\ No newline at end of file diff --git a/statistics-implementation-checklist.md b/statistics-implementation-checklist.md new file mode 100644 index 0000000..a24c9d2 --- /dev/null +++ b/statistics-implementation-checklist.md @@ -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/.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 `` 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.' } +``` \ No newline at end of file diff --git a/statistics-plan.md b/statistics-plan.md new file mode 100644 index 0000000..64c9314 --- /dev/null +++ b/statistics-plan.md @@ -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 `` | +| Annotated area | Growth Loop | `` + marker overlay | +| Waterfall | Growth Loop | floating `` segments | +| Funnel | Growth Loop, signups | stacked ``/`` | +| Cohort heatmap | Retention | `` + grid of `` | +| Calendar heatmap | Habit, public widget | `` + weekday×week `` grid | +| 2-D heatmap | Habit | `` + `` grid | +| Bubble scatter | Verse Mix | `` with radius scale | +| Histogram | Retention, Verse Mix, Habit | `` from binned data | +| Step / survival line | Verse Mix, Habit | `` with step interpolation | +| Ridgeline | Habit | stacked ``/density | +| Box plot | Retention | custom ``+`` | +| Stacked area / bars | Retention, Verse Mix | ``/`` stacked | +| Gauge / status pill | Pulse | custom `` 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. \ No newline at end of file diff --git a/tests/stats-analytics.test.ts b/tests/stats-analytics.test.ts new file mode 100644 index 0000000..7dab147 --- /dev/null +++ b/tests/stats-analytics.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/tests/stats-digest.test.ts b/tests/stats-digest.test.ts new file mode 100644 index 0000000..26faf76 --- /dev/null +++ b/tests/stats-digest.test.ts @@ -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 —%'); + }); +}); \ No newline at end of file diff --git a/tests/stats-events.test.ts b/tests/stats-events.test.ts new file mode 100644 index 0000000..ba15358 --- /dev/null +++ b/tests/stats-events.test.ts @@ -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 }); + }); +}); diff --git a/tests/stats-metrics.test.ts b/tests/stats-metrics.test.ts new file mode 100644 index 0000000..914342e --- /dev/null +++ b/tests/stats-metrics.test.ts @@ -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 }); + }); +}); diff --git a/tests/stats-segments.test.ts b/tests/stats-segments.test.ts new file mode 100644 index 0000000..9f689ea --- /dev/null +++ b/tests/stats-segments.test.ts @@ -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 { + return { ...SETTINGS_DEFAULTS, ...overrides }; +} + +function player(id: string, overrides: Partial): 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 + }); +}); diff --git a/tests/stats-settings.test.ts b/tests/stats-settings.test.ts new file mode 100644 index 0000000..c9c681f --- /dev/null +++ b/tests/stats-settings.test.ts @@ -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 }); + }); +}); diff --git a/tests/stats-signals.test.ts b/tests/stats-signals.test.ts new file mode 100644 index 0000000..718eff5 --- /dev/null +++ b/tests/stats-signals.test.ts @@ -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; + activity?: Partial; + 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(); + }); +}); diff --git a/tests/stats-snapshot.test.ts b/tests/stats-snapshot.test.ts new file mode 100644 index 0000000..8ba628f --- /dev/null +++ b/tests/stats-snapshot.test.ts @@ -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); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index acadd4f..0145c38 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'] + } + }; });