mirror of
https://github.com/pupperpowell/bibdle.git
synced 2026-10-22 05:54:10 -04:00
Compare commits
3
Commits
4a53e09ab3
...
45ffa39f2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ffa39f2a | ||
|
|
b656fd04c0 | ||
|
|
97bff35323 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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;
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1774416309647,
|
||||
"tag": "0003_overjoyed_mindworm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1789505566968,
|
||||
"tag": "0004_slim_nomad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<link rel="icon" href="/favicon.png" type="image/png" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Bibdle RSS Feed" href="/feed.xml" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import StreakCounter from "./StreakCounter.svelte";
|
||||
import ChapterGuess from "./ChapterGuess.svelte";
|
||||
import SubmitVerse from "./SubmitVerse.svelte";
|
||||
import { track } from "$lib/utils/analytics";
|
||||
|
||||
interface StatsData {
|
||||
solveRank: number;
|
||||
@@ -256,11 +257,13 @@
|
||||
: "Copy to Clipboard"}
|
||||
onclick={() => {
|
||||
if (hasWebShare) {
|
||||
track("share", { snippet: includeSnippet });
|
||||
shareResult(effectiveShareText);
|
||||
} else {
|
||||
if (!copyTracked) {
|
||||
copyTracked = true;
|
||||
}
|
||||
track("share_copy", { snippet: includeSnippet });
|
||||
clipboardCopy(effectiveShareText);
|
||||
copySuccess = true;
|
||||
setTimeout(() => {
|
||||
@@ -289,6 +292,7 @@
|
||||
if (!copyTracked) {
|
||||
copyTracked = true;
|
||||
}
|
||||
track("share_copy", { snippet: includeSnippet });
|
||||
clipboardCopy(effectiveShareText);
|
||||
showSnippetOption = true;
|
||||
bubbleCopied = true;
|
||||
@@ -429,6 +433,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="discord-btn w-full"
|
||||
data-umami-event="Join the BIBDLE Discord"
|
||||
onclick={() => track("discord_click")}
|
||||
>
|
||||
<svg
|
||||
class="discord-icon"
|
||||
@@ -454,6 +459,7 @@
|
||||
rel="noopener noreferrer"
|
||||
class="progress-btn w-full"
|
||||
data-umami-event="Support bibdle's development"
|
||||
onclick={() => track("donate_click")}
|
||||
>
|
||||
<span>❤️ Support bibdle's development</span>
|
||||
<svg class="progress-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
interface Item {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
sub?: string;
|
||||
}
|
||||
interface Props {
|
||||
items: Item[];
|
||||
format?: (v: number) => string;
|
||||
signed?: boolean;
|
||||
}
|
||||
let { items, format = (v) => String(v), signed = false }: Props = $props();
|
||||
|
||||
const maxAbs = $derived(Math.max(1, ...items.map((i) => Math.abs(i.value))));
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
{#each items as item (item.label)}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="w-20 shrink-0 text-gray-400">{item.label}</span>
|
||||
<div class="relative h-4 flex-1 overflow-hidden rounded bg-white/5">
|
||||
<div
|
||||
class="absolute top-0 h-full rounded {item.value < 0 ? 'right-1/2 bg-rose-500/60' : 'left-0'}"
|
||||
style={`width:${(Math.abs(item.value) / maxAbs) * 100}%;${item.value < 0 ? '' : `background:${item.color ?? 'rgba(56,189,248,0.5)'}`}`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="w-16 shrink-0 text-right font-medium text-gray-200">
|
||||
{signed && item.value >= 0 ? '+' : ''}{format(item.value)}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
data: { date: string; count: number }[];
|
||||
color?: string;
|
||||
}
|
||||
let { data, color = '52,211,153' }: Props = $props();
|
||||
|
||||
const values = $derived(new Map(data.map((d) => [d.date, d.count])));
|
||||
const max = $derived(Math.max(1, ...data.map((d) => d.count)));
|
||||
|
||||
// Build Sunday-aligned weeks ending on the last date.
|
||||
const weeks = $derived.by(() => {
|
||||
if (data.length === 0) return [] as { date: string; inRange: boolean }[][];
|
||||
const first = new Date(data[0].date + 'T00:00:00Z');
|
||||
const start = new Date(first);
|
||||
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
||||
const last = new Date(data[data.length - 1].date + 'T00:00:00Z');
|
||||
const end = new Date(last);
|
||||
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
|
||||
const range = new Set(data.map((d) => d.date));
|
||||
const out: { date: string; inRange: boolean }[][] = [];
|
||||
let cursor = new Date(start);
|
||||
while (cursor <= end) {
|
||||
const week: { date: string; inRange: boolean }[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const iso = cursor.toISOString().slice(0, 10);
|
||||
week.push({ date: iso, inRange: range.has(iso) });
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
out.push(week);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<div class="flex gap-1">
|
||||
<div class="flex flex-col justify-between py-0.5 text-[9px] text-gray-500">
|
||||
<span>Sun</span><span>Wed</span><span>Sat</span>
|
||||
</div>
|
||||
<div class="flex gap-[3px]">
|
||||
{#each weeks as week, wi (wi)}
|
||||
<div class="flex flex-col gap-[3px]">
|
||||
{#each week as cell (cell.date)}
|
||||
{@const count = values.get(cell.date) ?? 0}
|
||||
<div
|
||||
class="h-3 w-3 rounded-[2px]"
|
||||
style={cell.inRange
|
||||
? `background:rgba(${color},${(0.12 + 0.88 * (count / max)).toFixed(3)})`
|
||||
: 'background:transparent'}
|
||||
title={cell.inRange ? `${cell.date}: ${count} finishes` : cell.date}
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { CohortRow } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
cohorts: CohortRow[];
|
||||
weeks?: number;
|
||||
}
|
||||
let { cohorts, weeks = 8 }: Props = $props();
|
||||
|
||||
function shade(rate: number | null): string {
|
||||
if (rate === null) return 'background:rgba(255,255,255,0.03)';
|
||||
const t = Math.min(1, rate / 100);
|
||||
return `background:rgba(16,185,129,${(0.08 + 0.82 * t).toFixed(3)})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="pr-2 text-right font-normal">Cohort</th>
|
||||
<th class="pr-2 font-normal">Size</th>
|
||||
{#each Array(weeks) as _w, k (k)}
|
||||
<th class="w-8 font-normal">W{k}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each cohorts as row (row.weekStart)}
|
||||
<tr>
|
||||
<td class="pr-2 text-right whitespace-nowrap text-gray-300">{row.weekStart.slice(5)}</td>
|
||||
<td class="pr-2 text-right text-gray-400">{row.size}</td>
|
||||
{#each Array(weeks) as _w, k (k)}
|
||||
{@const rate = row.rates[k] ?? null}
|
||||
<td
|
||||
class="h-6 w-8 rounded-sm text-center align-middle text-gray-900"
|
||||
style={shade(rate)}
|
||||
title={`Week ${k}: ${rate === null ? 'not resolved' : rate + '%'}`}
|
||||
>
|
||||
{rate === null ? '' : Math.round(rate)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
rowLabels: string[];
|
||||
colLabels: string[];
|
||||
values: number[][];
|
||||
max?: number;
|
||||
color?: string;
|
||||
format?: (v: number) => string;
|
||||
cellSize?: number;
|
||||
}
|
||||
let {
|
||||
rowLabels,
|
||||
colLabels,
|
||||
values,
|
||||
max,
|
||||
color = '16,185,129',
|
||||
format = (v) => String(v),
|
||||
cellSize = 22
|
||||
}: Props = $props();
|
||||
|
||||
const effectiveMax = $derived(
|
||||
max ?? Math.max(1, ...values.flat().filter((v) => Number.isFinite(v)))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="border-separate border-spacing-0.5 text-[10px] text-gray-400">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
{#each colLabels as label, c (c)}
|
||||
<th class="px-0.5 font-normal">{label}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rowLabels as row, r (r)}
|
||||
<tr>
|
||||
<th class="pr-1 text-right font-normal whitespace-nowrap">{row}</th>
|
||||
{#each colLabels as _label, c (c)}
|
||||
{@const v = values[r]?.[c] ?? 0}
|
||||
<td
|
||||
class="rounded-sm text-center align-middle text-[9px] text-gray-900"
|
||||
style={`width:${cellSize}px;height:${cellSize}px;background:rgba(${color},${Math.min(1, v / effectiveMax).toFixed(3)})`}
|
||||
title={`${row} · ${colLabels[c]}: ${format(v)}`}
|
||||
>
|
||||
{v > 0 ? format(v) : ''}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
interface Series {
|
||||
name: string;
|
||||
color: string;
|
||||
points: { date: string; value: number | null }[];
|
||||
step?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
series: Series[];
|
||||
height?: number;
|
||||
ySuffix?: string;
|
||||
yMin?: number;
|
||||
yMax?: number;
|
||||
showLegend?: boolean;
|
||||
}
|
||||
let {
|
||||
series,
|
||||
height = 180,
|
||||
ySuffix = '',
|
||||
yMin,
|
||||
yMax,
|
||||
showLegend = true
|
||||
}: Props = $props();
|
||||
|
||||
const W = 600;
|
||||
const pad = { l: 36, r: 10, t: 10, b: 22 };
|
||||
const innerW = W - pad.l - pad.r;
|
||||
const innerH = $derived(height - pad.t - pad.b);
|
||||
|
||||
const allValues = $derived(
|
||||
series.flatMap((s) => s.points.map((p) => p.value)).filter((v): v is number => v !== null)
|
||||
);
|
||||
const domain = $derived.by(() => {
|
||||
const lo = yMin ?? (allValues.length ? Math.min(...allValues) : 0);
|
||||
const hi = yMax ?? (allValues.length ? Math.max(...allValues) : 1);
|
||||
const span = hi - lo || 1;
|
||||
return { lo: yMin ?? lo - span * 0.1, hi: yMax ?? hi + span * 0.1 };
|
||||
});
|
||||
|
||||
const pointCount = $derived(
|
||||
Math.max(1, ...series.map((s) => s.points.length))
|
||||
);
|
||||
|
||||
function xAt(i: number): number {
|
||||
if (pointCount <= 1) return pad.l + innerW / 2;
|
||||
return pad.l + (i / (pointCount - 1)) * innerW;
|
||||
}
|
||||
function yAt(v: number): number {
|
||||
const t = (v - domain.lo) / (domain.hi - domain.lo || 1);
|
||||
return pad.t + innerH - t * innerH;
|
||||
}
|
||||
|
||||
/** Break a series into path segments so nulls create gaps. */
|
||||
const paths = $derived(
|
||||
series.map((s) => {
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
s.points.forEach((p, i) => {
|
||||
if (p.value === null) {
|
||||
if (current.length) segments.push(current.join(' '));
|
||||
current = [];
|
||||
return;
|
||||
}
|
||||
current.push(`${xAt(i).toFixed(1)},${yAt(p.value).toFixed(1)}`);
|
||||
});
|
||||
if (current.length) segments.push(current.join(' '));
|
||||
return { name: s.name, color: s.color, segments, step: s.step };
|
||||
})
|
||||
);
|
||||
|
||||
const firstLabel = $derived(series[0]?.points[0]?.date?.slice(5) ?? '');
|
||||
const lastLabel = $derived(
|
||||
series[0]?.points[series[0].points.length - 1]?.date?.slice(5) ?? ''
|
||||
);
|
||||
|
||||
// --- Hover / tooltip state -------------------------------------------------
|
||||
let wrapperEl: HTMLDivElement | undefined = $state();
|
||||
let hoverIndex = $state<number | null>(null);
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!wrapperEl || pointCount <= 0) return;
|
||||
const rect = wrapperEl.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
const scale = rect.width / W;
|
||||
const svgX = (event.clientX - rect.left) / scale;
|
||||
if (pointCount <= 1) {
|
||||
hoverIndex = 0;
|
||||
return;
|
||||
}
|
||||
const t = (svgX - pad.l) / innerW;
|
||||
const i = Math.round(t * (pointCount - 1));
|
||||
hoverIndex = Math.min(pointCount - 1, Math.max(0, i));
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
hoverIndex = null;
|
||||
}
|
||||
|
||||
/** Rows shown in the tooltip for the currently hovered index. */
|
||||
const hoverRows = $derived.by(() => {
|
||||
const i = hoverIndex;
|
||||
if (i === null) return [];
|
||||
return series.map((s) => ({
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
value: s.points[i]?.value ?? null
|
||||
}));
|
||||
});
|
||||
|
||||
const hoveredDate = $derived.by(() => {
|
||||
if (hoverIndex === null) return null;
|
||||
for (const s of series) {
|
||||
const point = s.points[hoverIndex];
|
||||
if (point) return point.date;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const hasHoverValues = $derived(hoverRows.some((r) => r.value !== null));
|
||||
|
||||
const tooltipLeft = $derived(
|
||||
hoverIndex === null ? 50 : Math.min(85, Math.max(15, (xAt(hoverIndex) / W) * 100))
|
||||
);
|
||||
|
||||
function formatValue(value: number | null): string {
|
||||
if (value === null) return '—';
|
||||
const rounded = Number.isInteger(value) ? value : Number(value.toFixed(1));
|
||||
return `${rounded.toLocaleString()}${ySuffix}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative w-full touch-pan-y"
|
||||
bind:this={wrapperEl}
|
||||
role="presentation"
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerleave={clearHover}
|
||||
onpointercancel={clearHover}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${height}`}
|
||||
class="w-full text-gray-400"
|
||||
style={`height:${height}px`}
|
||||
role="img"
|
||||
>
|
||||
<!-- gridlines -->
|
||||
{#each [0, 0.5, 1] as t}
|
||||
<line
|
||||
x1={pad.l}
|
||||
x2={W - pad.r}
|
||||
y1={pad.t + innerH * t}
|
||||
y2={pad.t + innerH * t}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.15"
|
||||
stroke-dasharray="2 3"
|
||||
/>
|
||||
{/each}
|
||||
<text x={4} y={pad.t + 4} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||
{domain.hi.toFixed(0)}{ySuffix}
|
||||
</text>
|
||||
<text x={4} y={pad.t + innerH} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||
{domain.lo.toFixed(0)}{ySuffix}
|
||||
</text>
|
||||
|
||||
{#each paths as s (s.name)}
|
||||
{#each s.segments as d}
|
||||
<polyline
|
||||
points={d}
|
||||
fill="none"
|
||||
stroke={s.color}
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
opacity={hoverIndex === null ? 1 : 0.45}
|
||||
style="transition:opacity 120ms ease"
|
||||
/>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
{#if hoverIndex !== null}
|
||||
<!-- crosshair -->
|
||||
<line
|
||||
x1={xAt(hoverIndex)}
|
||||
x2={xAt(hoverIndex)}
|
||||
y1={pad.t}
|
||||
y2={pad.t + innerH}
|
||||
stroke="currentColor"
|
||||
stroke-opacity="0.5"
|
||||
stroke-dasharray="3 3"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
<!-- highlighted points -->
|
||||
{#each hoverRows as row (row.name)}
|
||||
{#if row.value !== null}
|
||||
<circle
|
||||
cx={xAt(hoverIndex)}
|
||||
cy={yAt(row.value)}
|
||||
r="3.5"
|
||||
fill={row.color}
|
||||
stroke="#0b0b12"
|
||||
stroke-width="1.5"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<text x={pad.l} y={height - 6} fill="currentColor" fill-opacity="0.8" font-size="9">
|
||||
{firstLabel}
|
||||
</text>
|
||||
<text
|
||||
x={W - pad.r}
|
||||
y={height - 6}
|
||||
text-anchor="end"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.8"
|
||||
font-size="9"
|
||||
>
|
||||
{lastLabel}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
{#if hoverIndex !== null && hoveredDate !== null && hasHoverValues}
|
||||
<div
|
||||
class="pointer-events-none absolute top-1 z-10 -translate-x-1/2 rounded-lg border border-white/15 bg-gray-900/95 px-2.5 py-1.5 text-[11px] leading-tight text-gray-100 shadow-lg backdrop-blur-sm"
|
||||
style={`left:${tooltipLeft}%`}
|
||||
>
|
||||
<p class="mb-1 font-semibold text-gray-300">{hoveredDate}</p>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
{#each hoverRows as row (row.name)}
|
||||
<li class="flex items-center justify-between gap-3">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" style={`background:${row.color}`}></span>
|
||||
{row.name}
|
||||
</span>
|
||||
<span class="font-semibold tabular-nums text-gray-100">{formatValue(row.value)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showLegend && series.length > 1}
|
||||
<div class="mt-1 flex flex-wrap gap-3 text-[11px] text-gray-300">
|
||||
{#each series as s (s.name)}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="h-2 w-2 rounded-full" style={`background:${s.color}`}></span>
|
||||
{s.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
values: number[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
class?: string;
|
||||
stroke?: string;
|
||||
}
|
||||
let {
|
||||
values,
|
||||
width = 120,
|
||||
height = 32,
|
||||
class: className = '',
|
||||
stroke = 'currentColor'
|
||||
}: Props = $props();
|
||||
|
||||
const points = $derived.by(() => {
|
||||
if (values.length === 0) return '';
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min || 1;
|
||||
return values
|
||||
.map((v, i) => {
|
||||
const x = values.length === 1 ? width / 2 : (i / (values.length - 1)) * width;
|
||||
const y = height - ((v - min) / span) * (height - 4) - 2;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
class={className}
|
||||
preserveAspectRatio="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#if points}
|
||||
<polyline
|
||||
{points}
|
||||
fill="none"
|
||||
{stroke}
|
||||
stroke-width="1.5"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { SignalSeverity } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
severity: SignalSeverity;
|
||||
children?: import('svelte').Snippet;
|
||||
label?: string;
|
||||
}
|
||||
let { severity, children, label }: Props = $props();
|
||||
|
||||
const styles: Record<SignalSeverity, string> = {
|
||||
good: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
|
||||
info: 'bg-sky-500/15 text-sky-300 border-sky-500/30',
|
||||
warn: 'bg-amber-500/15 text-amber-200 border-amber-500/30',
|
||||
bad: 'bg-rose-500/15 text-rose-300 border-rose-500/30'
|
||||
};
|
||||
const dots: Record<SignalSeverity, string> = {
|
||||
good: 'bg-emerald-400',
|
||||
info: 'bg-sky-400',
|
||||
warn: 'bg-amber-400',
|
||||
bad: 'bg-rose-400'
|
||||
};
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold {styles[
|
||||
severity
|
||||
]}"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full {dots[severity]}"></span>
|
||||
{#if children}{@render children()}{:else}{label}{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { bibleBooks, type BibleSection, type Testament } from '$lib/types/bible';
|
||||
import type { BookStat } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
coverage: BookStat[];
|
||||
}
|
||||
let { coverage }: Props = $props();
|
||||
|
||||
interface Tile extends BookStat {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SectionGroup {
|
||||
section: BibleSection;
|
||||
testament: Testament;
|
||||
books: Tile[];
|
||||
days: number;
|
||||
plays: number;
|
||||
covered: number;
|
||||
firstTryWeightedSum: number;
|
||||
firstTryPlays: number;
|
||||
}
|
||||
|
||||
const byId = $derived(new Map(coverage.map((c) => [c.bookId, c])));
|
||||
const maxDays = $derived(Math.max(1, ...coverage.map((c) => c.days)));
|
||||
const totalDays = $derived(coverage.reduce((a, b) => a + b.days, 0));
|
||||
const totalPlays = $derived(coverage.reduce((a, b) => a + b.plays, 0));
|
||||
|
||||
/** Red → orange → yellow → green, driven by days featured relative to the busiest book. */
|
||||
function coverageColor(days: number): string {
|
||||
if (days <= 0) return 'hsl(220, 9%, 55%)';
|
||||
const t = Math.min(1, days / maxDays);
|
||||
return `hsl(${Math.round(t * 142)}, 78%, 55%)`;
|
||||
}
|
||||
|
||||
const legendStops = [0, 0.25, 0.5, 0.75, 1];
|
||||
const legendGradient = $derived(
|
||||
`linear-gradient(90deg, ${legendStops
|
||||
.map((t) => `hsl(${Math.round(t * 142)}, 78%, 55%) ${Math.round(t * 100)}%`)
|
||||
.join(', ')})`
|
||||
);
|
||||
const legendTicks = $derived(legendStops.map((t) => Math.round(t * maxDays)));
|
||||
|
||||
/** Consecutive runs of books sharing a section (preserves canonical order). */
|
||||
const groups = $derived.by(() => {
|
||||
const out: SectionGroup[] = [];
|
||||
for (const book of bibleBooks) {
|
||||
const stat = byId.get(book.id);
|
||||
if (!stat) continue;
|
||||
let group = out[out.length - 1];
|
||||
if (!group || group.section !== book.section || group.testament !== book.testament) {
|
||||
group = {
|
||||
section: book.section,
|
||||
testament: book.testament,
|
||||
books: [],
|
||||
days: 0,
|
||||
plays: 0,
|
||||
covered: 0,
|
||||
firstTryWeightedSum: 0,
|
||||
firstTryPlays: 0
|
||||
};
|
||||
out.push(group);
|
||||
}
|
||||
group.books.push({ ...stat, name: book.name });
|
||||
group.days += stat.days;
|
||||
group.plays += stat.plays;
|
||||
if (stat.days > 0) group.covered++;
|
||||
if (stat.plays > 0) {
|
||||
group.firstTryWeightedSum += stat.firstTryPct * stat.plays;
|
||||
group.firstTryPlays += stat.plays;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-5">
|
||||
<!-- Numeric colour legend -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between text-[10px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
<span>Featured less</span>
|
||||
<span>Days featured →</span>
|
||||
<span>Featured more</span>
|
||||
</div>
|
||||
<div class="h-3 w-full rounded-full ring-1 ring-inset ring-white/10" style={`background:${legendGradient}`}></div>
|
||||
<div class="flex justify-between text-[10px] tabular-nums text-gray-300">
|
||||
{#each legendTicks as tick}
|
||||
<span class="font-semibold">{tick}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="flex items-center gap-1.5 text-[10px] text-gray-500">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-sm ring-1 ring-inset ring-white/10" style="background:hsl(220, 9%, 55%)"></span>
|
||||
never featured (0 days)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Sections -->
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each groups as group (group.testament + group.section)}
|
||||
{@const share = totalDays ? Math.round((group.days / totalDays) * 100) : 0}
|
||||
{@const avgFirstTry = group.firstTryPlays
|
||||
? Math.round(group.firstTryWeightedSum / group.firstTryPlays)
|
||||
: null}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-b border-white/10 pb-1">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-100">
|
||||
{group.section}
|
||||
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-gray-300">
|
||||
{group.testament === 'old' ? 'OT' : 'NT'}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-[11px] text-gray-400">
|
||||
<span class="font-semibold text-gray-200">{group.covered}/{group.books.length}</span> books covered ·
|
||||
<span class="font-semibold text-gray-200">{group.days}</span> days ({share}%) ·
|
||||
<span class="font-semibold text-gray-200">{group.plays}</span> plays
|
||||
{#if avgFirstTry !== null}
|
||||
· avg first-try <span class="font-semibold text-gray-200">{avgFirstTry}%</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="h-1 w-full overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
class="h-full rounded-full bg-white/40"
|
||||
style={`width:${share}%`}
|
||||
></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-11">
|
||||
{#each group.books as book (book.bookId)}
|
||||
<div
|
||||
class="flex min-h-11 flex-col items-center justify-center rounded-lg bg-white/5 px-1 py-1 text-center ring-1 ring-inset ring-white/10 transition-colors duration-100 hover:bg-white/10"
|
||||
title={`${book.name}: featured ${book.days} day${book.days === 1 ? '' : 's'}, ${book.plays} play${book.plays === 1 ? '' : 's'}`}
|
||||
>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{book.bookId}</span>
|
||||
<span class="text-base font-bold leading-tight" style={`color:${coverageColor(book.days)}`}>{book.days}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-gray-500">
|
||||
66 books over the last 180 days · {totalDays} featured days · {totalPlays} plays. Colour is scaled
|
||||
to the busiest book ({maxDays} day{maxDays === 1 ? '' : 's'}). Hover a tile for full details.
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import DateNavigator from './DateNavigator.svelte';
|
||||
import SettingsMenu from './SettingsMenu.svelte';
|
||||
import { formatDateLabel } from '$lib/utils/stats-format';
|
||||
import type { StatsSettings } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
today: string;
|
||||
generatedAt: Date;
|
||||
buildKind: string;
|
||||
matured: boolean;
|
||||
stale: boolean;
|
||||
settings: StatsSettings;
|
||||
}
|
||||
let { date, today, generatedAt, buildKind, matured, stale, settings }: Props = $props();
|
||||
|
||||
const matureHref = $derived(
|
||||
`/admin/stats?date=${date}${matured ? '' : '&mature=1'}`
|
||||
);
|
||||
const generatedLabel = $derived(
|
||||
generatedAt instanceof Date ? generatedAt.toISOString().slice(0, 16).replace('T', ' ') : ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-20 -mx-4 mb-4 border-b border-white/10 bg-gray-950/85 px-4 py-3 backdrop-blur"
|
||||
>
|
||||
<div class="mx-auto flex max-w-6xl flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-200">← Game</a>
|
||||
<h1 class="text-xl font-bold text-gray-100">Growth Console</h1>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<DateNavigator {date} {today} mature={matured} />
|
||||
<a
|
||||
href={matureHref}
|
||||
class="rounded-lg border px-3 py-1.5 text-xs font-semibold {matured
|
||||
? 'border-amber-400/50 bg-amber-400/15 text-amber-200'
|
||||
: 'border-white/10 bg-white/5 text-gray-300 hover:bg-white/10'}"
|
||||
>
|
||||
{matured ? 'Matured view ✓' : 'Mature this view'}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="text-xs text-gray-400">
|
||||
Showing <span class="font-semibold text-gray-200">{formatDateLabel(date)}</span>
|
||||
· snapshot built {generatedLabel} UTC
|
||||
{#if buildKind}<span class="uppercase tracking-wide"> · {buildKind}</span>{/if}
|
||||
{#if matured}
|
||||
<span class="font-semibold text-amber-300"> · Matured — uses data after this date.</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsMenu {settings} {stale} />
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
today: string;
|
||||
mature: boolean;
|
||||
}
|
||||
let { date, today, mature }: Props = $props();
|
||||
|
||||
function shift(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const hrefFor = (d: string) => `/admin/stats?date=${d}${mature ? '&mature=1' : ''}`;
|
||||
const prev = $derived(shift(date, -1));
|
||||
const next = $derived(shift(date, 1));
|
||||
const nextDisabled = $derived(next > today);
|
||||
|
||||
function onDateChange(event: Event) {
|
||||
const value = (event.currentTarget as HTMLInputElement).value;
|
||||
if (value) goto(hrefFor(value));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<a
|
||||
href={hrefFor(prev)}
|
||||
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
|
||||
aria-label="Previous day"
|
||||
>
|
||||
←
|
||||
</a>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
max={today}
|
||||
onchange={onDateChange}
|
||||
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
|
||||
/>
|
||||
{#if nextDisabled}
|
||||
<span
|
||||
class="cursor-not-allowed rounded-lg border border-white/5 bg-white/5 px-3 py-1.5 text-sm text-gray-600"
|
||||
aria-label="Next day (unavailable)"
|
||||
>
|
||||
→
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href={hrefFor(next)}
|
||||
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-gray-200 hover:bg-white/10"
|
||||
aria-label="Next day"
|
||||
>
|
||||
→
|
||||
</a>
|
||||
{/if}
|
||||
<a
|
||||
href={hrefFor(today)}
|
||||
class="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-semibold text-gray-300 hover:bg-white/10"
|
||||
>
|
||||
Latest
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">7 · Feature Impact</h2>
|
||||
<Container class="p-4 w-full items-start text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Needs data</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Feature-usage events are deferred. The <code>analytics_events</code> table already supports
|
||||
them; only the emit points remain. Once added, this panel will show:
|
||||
</p>
|
||||
<ul class="list-disc pl-5 text-xs text-gray-400">
|
||||
<li>Imposter-mode entries and their return rate</li>
|
||||
<li>Hint reveals per finish and whether they help retention</li>
|
||||
<li>Progress-page visits as a signup/retention signal</li>
|
||||
<li>Verse submissions and their effect on next-day return</li>
|
||||
</ul>
|
||||
</Container>
|
||||
</section>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
|
||||
interface GlossaryEntry {
|
||||
name: string;
|
||||
definition: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/** Mirrors statistics-plan.md §5. Single source for on-page definitions. */
|
||||
const GLOSSARY: GlossaryEntry[] = [
|
||||
{ name: 'Players today', definition: 'Finished the puzzle yesterday.', action: 'Investigate content or traffic.' },
|
||||
{ name: 'WAU / MAU', definition: 'Played at least once in the last 7 / 30 days.', action: 'Reach vs habit.' },
|
||||
{ name: 'Core players', definition: 'Finished 4+ times this week.', action: 'If falling, it is a retention problem.' },
|
||||
{ name: 'Returning share', definition: 'Played before today vs. first time.', action: 'Low = acquisition-heavy and leaky.' },
|
||||
{ name: 'D1 return', definition: 'First-timers who came back the next day.', action: 'The number one activation lever.' },
|
||||
{ name: 'Return rate (any)', definition: 'Played again within N days.', action: 'Onboarding quality.' },
|
||||
{ name: 'Overall return rate', definition: 'Ever played more than once.', action: 'Overall stickiness.' },
|
||||
{ name: 'Net growth', definition: 'New + resurrected − churned.', action: 'Is the base expanding?' },
|
||||
{ name: 'Completion rate', definition: 'Started → finished.', action: 'Friction or confusion.' },
|
||||
{ name: 'Signup rate', definition: 'Players who made an account.', action: 'Account value.' },
|
||||
{ name: 'First-try rate', definition: 'Solved with no wrong guesses.', action: 'Too high = giveaway; too low = brutal.' },
|
||||
{ name: 'Share rate', definition: 'Shares ÷ finishes.', action: 'Virality.' },
|
||||
{ name: 'Streak survival', definition: 'Chance a streak continues.', action: 'Streak feature return on investment.' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">8 · Notes & Glossary</h2>
|
||||
<Container class="p-2 md:p-4 w-full overflow-x-auto">
|
||||
<table class="w-full text-left text-sm text-gray-200">
|
||||
<thead class="border-b border-white/10 text-xs uppercase text-gray-400">
|
||||
<tr>
|
||||
<th class="px-2 py-2 md:px-3">Metric</th>
|
||||
<th class="px-2 py-2 md:px-3">Plain definition</th>
|
||||
<th class="px-2 py-2 md:px-3">What to do if it's off</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each GLOSSARY as entry (entry.name)}
|
||||
<tr class="border-b border-white/5 align-top">
|
||||
<td class="px-2 py-2 font-medium md:px-3">{entry.name}</td>
|
||||
<td class="px-2 py-2 text-gray-300 md:px-3">{entry.definition}</td>
|
||||
<td class="px-2 py-2 text-gray-400 md:px-3">{entry.action}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
<p class="text-xs text-gray-500">
|
||||
Snapshots are point-in-time. A day where a metric did not exist shows
|
||||
“—”.
|
||||
</p>
|
||||
</section>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||
import BarList from '$lib/components/charts/BarList.svelte';
|
||||
import type { SnapshotPayload } from '$lib/types/stats';
|
||||
import { signed, numOrDash } from '$lib/utils/stats-format';
|
||||
|
||||
interface Props {
|
||||
payload: SnapshotPayload;
|
||||
}
|
||||
let { payload }: Props = $props();
|
||||
|
||||
const series = $derived(payload.dailySeries.slice(-60));
|
||||
const hasStarted = $derived(payload.meta.hasStartedData);
|
||||
const signupTotal = $derived(payload.growth.signups.reduce((a, b) => a + b.count, 0));
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">2 · Growth Loop</h2>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">New players per day</p>
|
||||
<p class="text-xs text-gray-400">First-time players each day. Hover later for post annotations.</p>
|
||||
<MiniLineChart
|
||||
series={[{ name: 'New players', color: '#60a5fa', points: series.map((d) => ({ date: d.date, value: d.newPlayers })) }]}
|
||||
showLegend={false}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Acquisition vs churn (7d)</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
New + resurrected − churned = net. Week-over-week.
|
||||
</p>
|
||||
<BarList
|
||||
signed
|
||||
items={[
|
||||
{ label: 'New', value: payload.activity.newPlayers, color: 'rgba(96,165,250,0.6)' },
|
||||
{ label: 'Resurrected', value: payload.activity.resurrected, color: 'rgba(52,211,153,0.6)' },
|
||||
{ label: 'Churned', value: -payload.activity.churned, color: 'rgba(251,113,133,0.6)' },
|
||||
{ label: 'Net', value: payload.activity.netGrowth, color: 'rgba(167,139,250,0.6)' }
|
||||
]}
|
||||
/>
|
||||
<p class="text-xs text-gray-400">Net growth: <span class="font-semibold text-gray-200">{signed(payload.activity.netGrowth)}</span></p>
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Lifecycle funnel</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Of everyone active in the tracked window, how many reached each play count.
|
||||
</p>
|
||||
<BarList
|
||||
items={payload.growth.lifecycleFunnel.map((f) => ({
|
||||
label: f.label,
|
||||
value: f.players,
|
||||
color: 'rgba(52,211,153,0.5)'
|
||||
}))}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Started → finished</p>
|
||||
{#if hasStarted}
|
||||
<p class="text-xs text-gray-400">
|
||||
Completion rate {payload.kpis.completionRate ?? '—'}%. Started counts distinct
|
||||
players who loaded the verse.
|
||||
</p>
|
||||
<MiniLineChart
|
||||
series={[
|
||||
{ name: 'Started', color: '#f59e0b', points: series.map((d) => ({ date: d.date, value: d.started })) },
|
||||
{ name: 'Finished', color: '#34d399', points: series.map((d) => ({ date: d.date, value: d.completions })) }
|
||||
]}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400">
|
||||
Needs data. Once players load the game this shows start → finish.
|
||||
</p>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Signups</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Account creations ({numOrDash(signupTotal)} in the last 90 days). Existing accounts show
|
||||
“unknown” date.
|
||||
</p>
|
||||
<MiniLineChart
|
||||
series={[{ name: 'Signups', color: '#a78bfa', points: payload.growth.signups.map((s) => ({ date: s.date, value: s.count })) }]}
|
||||
showLegend={false}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||
import BarList from '$lib/components/charts/BarList.svelte';
|
||||
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
|
||||
import HeatmapGrid from '$lib/components/charts/HeatmapGrid.svelte';
|
||||
import type { SnapshotPayload } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
payload: SnapshotPayload;
|
||||
}
|
||||
let { payload }: Props = $props();
|
||||
|
||||
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const hourLabels = Array.from({ length: 24 }, (_, h) => (h % 3 === 0 ? String(h) : ''));
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">5 · Habit Lab</h2>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Streak survival</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
If you've played N days in a row, how likely are you to play day N+1.
|
||||
</p>
|
||||
<MiniLineChart
|
||||
series={[
|
||||
{
|
||||
name: 'Survival',
|
||||
color: '#34d399',
|
||||
points: payload.habit.streakSurvival.map((s) => ({ date: String(s.length), value: s.probability }))
|
||||
}
|
||||
]}
|
||||
showLegend={false}
|
||||
ySuffix="%"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Current streak lengths</p>
|
||||
<p class="text-xs text-gray-400">Players whose current run has reached each length.</p>
|
||||
<BarList
|
||||
items={payload.habit.streakLengths.map((b) => ({ label: `${b.label} days`, value: b.value, color: 'rgba(251,191,36,0.5)' }))}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Daily finishes (last 120 days)</p>
|
||||
<CalendarHeatmap data={payload.habit.calendar} />
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">When people play</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Finishes by weekday and hour. Times are server time (UTC) and therefore approximate.
|
||||
</p>
|
||||
<HeatmapGrid
|
||||
rowLabels={dayLabels}
|
||||
colLabels={hourLabels}
|
||||
values={payload.habit.weekdayHour}
|
||||
color="96,165,250"
|
||||
cellSize={16}
|
||||
format={(v) => String(v)}
|
||||
/>
|
||||
</Container>
|
||||
</section>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import Sparkline from '$lib/components/charts/Sparkline.svelte';
|
||||
import type { SignalSeverity } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
value: string;
|
||||
caption?: string;
|
||||
delta?: string;
|
||||
deltaDirection?: 'up' | 'down' | 'flat';
|
||||
sparkValues?: number[];
|
||||
severity?: SignalSeverity;
|
||||
}
|
||||
let {
|
||||
label,
|
||||
value,
|
||||
caption,
|
||||
delta,
|
||||
deltaDirection = 'flat',
|
||||
sparkValues = [],
|
||||
severity
|
||||
}: Props = $props();
|
||||
|
||||
const deltaClass = $derived(
|
||||
deltaDirection === 'up'
|
||||
? 'text-emerald-400'
|
||||
: deltaDirection === 'down'
|
||||
? 'text-rose-400'
|
||||
: 'text-gray-400'
|
||||
);
|
||||
const strokeColor = $derived(
|
||||
severity === 'bad'
|
||||
? '#fb7185'
|
||||
: severity === 'warn'
|
||||
? '#fbbf24'
|
||||
: '#34d399'
|
||||
);
|
||||
</script>
|
||||
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<div class="flex items-start justify-between gap-2 w-full">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
{label}
|
||||
</p>
|
||||
{#if delta}
|
||||
<span class="text-xs font-semibold {deltaClass}">{delta}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-100">{value}</p>
|
||||
{#if sparkValues.length > 1}
|
||||
<Sparkline
|
||||
values={sparkValues}
|
||||
stroke={strokeColor}
|
||||
class="mt-2 w-full h-8 text-gray-500"
|
||||
/>
|
||||
{/if}
|
||||
{#if caption}
|
||||
<p class="mt-1 text-xs leading-snug text-gray-400">{caption}</p>
|
||||
{/if}
|
||||
</Container>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import KpiCard from './KpiCard.svelte';
|
||||
import StatusPill from '$lib/components/charts/StatusPill.svelte';
|
||||
import type { Signal, SnapshotPayload, StatsSettings } from '$lib/types/stats';
|
||||
import {
|
||||
delta,
|
||||
numOrDash,
|
||||
pctOrDash,
|
||||
signed
|
||||
} from '$lib/utils/stats-format';
|
||||
|
||||
interface Props {
|
||||
payload: SnapshotPayload;
|
||||
settings: StatsSettings;
|
||||
verdict: string;
|
||||
topSignal: Signal | null;
|
||||
}
|
||||
let { payload, settings, verdict, topSignal }: Props = $props();
|
||||
|
||||
const series = $derived(payload.dailySeries.slice(-30));
|
||||
const spark = $derived(series.map((d) => d.completions));
|
||||
const newSpark = $derived(series.map((d) => d.newPlayers));
|
||||
|
||||
const d1Severity = $derived(
|
||||
payload.kpis.d1Return === null
|
||||
? 'info'
|
||||
: payload.kpis.d1Return >= settings.d1_target
|
||||
? 'good'
|
||||
: payload.kpis.d1Return < 12
|
||||
? 'bad'
|
||||
: 'warn'
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 class="text-lg font-bold text-gray-100">1 · Pulse</h2>
|
||||
{#if topSignal}
|
||||
<StatusPill severity={topSignal.severity}>{topSignal.title}</StatusPill>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-2xl border border-white/10 bg-white/5 p-3 text-sm text-gray-200"
|
||||
>
|
||||
{verdict}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<KpiCard
|
||||
label="Players today"
|
||||
value={numOrDash(payload.activity.dau)}
|
||||
delta={delta(payload.activity.dau, payload.activity.previous?.dau).text}
|
||||
deltaDirection={delta(payload.activity.dau, payload.activity.previous?.dau).direction}
|
||||
sparkValues={spark}
|
||||
caption="Finished yesterday's verse."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Players this week"
|
||||
value={numOrDash(payload.activity.wau)}
|
||||
delta={delta(payload.activity.wau, payload.activity.previous?.wau).text}
|
||||
deltaDirection={delta(payload.activity.wau, payload.activity.previous?.wau).direction}
|
||||
caption="Distinct players in the last 7 days."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Players this month"
|
||||
value={numOrDash(payload.activity.mau)}
|
||||
delta={delta(payload.activity.mau, payload.activity.previous?.mau).text}
|
||||
deltaDirection={delta(payload.activity.mau, payload.activity.previous?.mau).direction}
|
||||
caption="Distinct players in the last 30 days."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Core players"
|
||||
value={numOrDash(payload.activity.core)}
|
||||
delta={delta(payload.activity.core, payload.activity.corePrevious).text}
|
||||
deltaDirection={delta(payload.activity.core, payload.activity.corePrevious).direction}
|
||||
caption={`Finished ${settings.core_plays_per_week}+ times this week.`}
|
||||
/>
|
||||
<KpiCard
|
||||
label="New players"
|
||||
value={numOrDash(payload.activity.newPlayers)}
|
||||
sparkValues={newSpark}
|
||||
caption="First-time players in the last 7 days."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Returning share"
|
||||
value={pctOrDash(payload.activity.returningShare)}
|
||||
caption="Of yesterday's players, how many had played before."
|
||||
/>
|
||||
<KpiCard
|
||||
label="D1 return"
|
||||
value={pctOrDash(payload.kpis.d1Return)}
|
||||
severity={d1Severity}
|
||||
delta={`target ${settings.d1_target}%`}
|
||||
caption="First-timers who came back the next day."
|
||||
/>
|
||||
<KpiCard
|
||||
label="Net growth (7d)"
|
||||
value={signed(payload.activity.netGrowth)}
|
||||
caption={`New + returning − churned (${payload.activity.newPlayers} + ${payload.activity.resurrected} − ${payload.activity.churned}).`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import CohortHeatmap from '$lib/components/charts/CohortHeatmap.svelte';
|
||||
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||
import BarList from '$lib/components/charts/BarList.svelte';
|
||||
import type { SnapshotPayload, StatsSettings } from '$lib/types/stats';
|
||||
import { pctOrDash, numOrDash } from '$lib/utils/stats-format';
|
||||
|
||||
interface Props {
|
||||
payload: SnapshotPayload;
|
||||
settings: StatsSettings;
|
||||
}
|
||||
let { payload, settings }: Props = $props();
|
||||
|
||||
const mix = $derived(payload.retention.segmentMix);
|
||||
const colors = ['#60a5fa', '#34d399', '#a78bfa', '#f59e0b', '#fb7185'];
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">3 · Retention Lab</h2>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
Overall return rate
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-100">
|
||||
{pctOrDash(payload.kpis.overallReturnRate)}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Share of all players who ever played more than once. Target:
|
||||
{settings.return7_target}%+ within 7 days.
|
||||
</p>
|
||||
</Container>
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
|
||||
Time to second play
|
||||
</p>
|
||||
<BarList items={payload.retention.timeToSecondPlay.map((b) => ({ label: b.label, value: b.value }))} />
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Cohort retention triangle</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Each row is a starting week; each column is weeks later. Darker = more came back.
|
||||
Only resolved weeks are filled.
|
||||
</p>
|
||||
<CohortHeatmap cohorts={payload.retention.cohorts} weeks={8} />
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Retention curves</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
D1 / D7 / D30 return over time. Latest resolvable cohorts only.
|
||||
</p>
|
||||
<MiniLineChart
|
||||
series={[
|
||||
{ name: 'D1', color: '#34d399', points: payload.retention.curves.d1 },
|
||||
{ name: 'D7', color: '#60a5fa', points: payload.retention.curves.d7 },
|
||||
{ name: 'D30', color: '#a78bfa', points: payload.retention.curves.d30 }
|
||||
]}
|
||||
ySuffix="%"
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Segment mix over time</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Active players split into new, core, returning, at-risk and churned. Total tracked:
|
||||
{numOrDash(payload.activity.mau)} recently active.
|
||||
</p>
|
||||
<MiniLineChart
|
||||
series={[
|
||||
{ name: 'New', color: colors[0], points: mix.map((m) => ({ date: m.date, value: m.new })) },
|
||||
{ name: 'Core', color: colors[1], points: mix.map((m) => ({ date: m.date, value: m.core })) },
|
||||
{ name: 'Returning', color: colors[2], points: mix.map((m) => ({ date: m.date, value: m.returning })) },
|
||||
{ name: 'At risk', color: colors[3], points: mix.map((m) => ({ date: m.date, value: m.atRisk })) },
|
||||
{ name: 'Churned', color: colors[4], points: mix.map((m) => ({ date: m.date, value: m.churned })) }
|
||||
]}
|
||||
/>
|
||||
</Container>
|
||||
</section>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { SETTINGS_DEFAULTS, type SettingsKey, type StatsSettings } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
settings: StatsSettings;
|
||||
stale?: boolean;
|
||||
}
|
||||
let { settings, stale = false }: Props = $props();
|
||||
|
||||
const FIELDS: { key: SettingsKey; label: string; hint: string; suffix?: string }[] = [
|
||||
{ key: 'core_plays_per_week', label: 'Core player threshold', hint: 'Finishes in 7 days to count as core.' },
|
||||
{ key: 'at_risk_days', label: 'At-risk after (days)', hint: 'Days inactive before a player is at risk.' },
|
||||
{ key: 'churn_days', label: 'Churned after (days)', hint: 'Days inactive before a player is churned.' },
|
||||
{ key: 'resurrect_gap_days', label: 'Resurrection gap (days)', hint: 'Gap that makes a return "resurrected".' },
|
||||
{ key: 'active_window_days', label: 'Player-state window (days)', hint: 'How much player history is stored.' },
|
||||
{ key: 'd1_target', label: 'D1 return target', hint: 'Target for next-day return.', suffix: '%' },
|
||||
{ key: 'return7_target', label: '7-day return target', hint: 'Target for any return within 7 days.', suffix: '%' },
|
||||
{ key: 'completion_target', label: 'Completion target', hint: 'Started → finished target.', suffix: '%' },
|
||||
{ key: 'first_try_min', label: 'First-try band (min)', hint: 'Below this is too hard.', suffix: '%' },
|
||||
{ key: 'first_try_max', label: 'First-try band (max)', hint: 'Above this is too easy.', suffix: '%' },
|
||||
{ key: 'share_target', label: 'Share target', hint: 'Shares ÷ finishes target.', suffix: '%' },
|
||||
{ key: 'anomaly_mad_threshold', label: 'Anomaly sensitivity', hint: 'Lower = more alerts.' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<details class="group">
|
||||
<summary
|
||||
class="cursor-pointer list-none rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-gray-200 hover:bg-white/10"
|
||||
>
|
||||
⚙️ Settings & thresholds
|
||||
{#if stale}
|
||||
<span class="ml-2 text-xs font-normal text-amber-300"
|
||||
>(snapshot definitions are outdated)</span
|
||||
>
|
||||
{/if}
|
||||
</summary>
|
||||
<Container class="mt-3 p-4 w-full items-stretch text-left gap-3">
|
||||
<p class="text-xs text-gray-400">
|
||||
Changing these re-reads history; snapshots themselves are unchanged.
|
||||
</p>
|
||||
<form method="POST" action="?/updateSettings" class="grid gap-3 sm:grid-cols-2">
|
||||
{#each FIELDS as field (field.key)}
|
||||
<label class="flex flex-col gap-1 text-xs text-gray-300">
|
||||
<span class="font-semibold">{field.label}{field.suffix ? ` (${field.suffix})` : ''}</span>
|
||||
<input
|
||||
type="number"
|
||||
name={field.key}
|
||||
value={settings[field.key]}
|
||||
step="any"
|
||||
min={field.key === 'anomaly_mad_threshold' ? 0.5 : 0}
|
||||
class="rounded-lg border border-white/10 bg-black/30 px-2 py-1.5 text-sm text-gray-100 focus:border-blue-400 focus:outline-none"
|
||||
/>
|
||||
<span class="text-[11px] text-gray-500">{field.hint}</span>
|
||||
</label>
|
||||
{/each}
|
||||
<div class="flex items-center gap-2 sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500"
|
||||
>
|
||||
Save settings
|
||||
</button>
|
||||
<span class="text-[11px] text-gray-500">
|
||||
Defaults come from <code>SETTINGS_DEFAULTS</code> / <code>STATS_*</code> env vars.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</Container>
|
||||
</details>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import StatusPill from '$lib/components/charts/StatusPill.svelte';
|
||||
import type { Signal } from '$lib/types/stats';
|
||||
|
||||
interface Props {
|
||||
signals: Signal[];
|
||||
}
|
||||
let { signals }: Props = $props();
|
||||
|
||||
const labels: Record<Signal['severity'], string> = {
|
||||
good: 'On target',
|
||||
info: 'Needs data',
|
||||
warn: 'Watch',
|
||||
bad: 'Act now'
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">6 · Signals & Actions</h2>
|
||||
{#if signals.length === 0}
|
||||
<p class="text-sm text-gray-400">No signals for this date.</p>
|
||||
{:else}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each signals as signal (signal.id)}
|
||||
<Container class="p-4 w-full items-start text-left gap-2">
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<p class="font-semibold text-gray-100">{signal.title}</p>
|
||||
<StatusPill severity={signal.severity} label={labels[signal.severity]} />
|
||||
</div>
|
||||
<p class="text-sm text-gray-300">{signal.detail}</p>
|
||||
{#if signal.action}
|
||||
<p class="text-xs text-amber-200/90">
|
||||
<span class="font-semibold">What to do:</span> {signal.action}
|
||||
</p>
|
||||
{/if}
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import MiniLineChart from '$lib/components/charts/MiniLineChart.svelte';
|
||||
import BarList from '$lib/components/charts/BarList.svelte';
|
||||
import BookCoverage from '$lib/components/stats/BookCoverage.svelte';
|
||||
import type { SnapshotPayload } from '$lib/types/stats';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
|
||||
interface Props {
|
||||
payload: SnapshotPayload;
|
||||
}
|
||||
let { payload }: Props = $props();
|
||||
|
||||
const nameOf = (id: string) => bibleBooks.find((b) => b.id === id)?.name ?? id;
|
||||
const coverage = $derived(payload.verseMix.coverage);
|
||||
|
||||
const retained = $derived(payload.verseMix.retentionByBook);
|
||||
const avgD1 = $derived(
|
||||
retained.length ? retained.reduce((a, b) => a + (b.d1Return ?? 0), 0) / retained.length : 0
|
||||
);
|
||||
const giveaways = $derived(
|
||||
payload.verseMix.firstTryByBook
|
||||
.filter((b) => b.firstTryPct >= 50 && b.plays >= 3)
|
||||
.sort((a, b) => b.firstTryPct - a.firstTryPct)
|
||||
.slice(0, 8)
|
||||
);
|
||||
const alarms = $derived(
|
||||
retained
|
||||
.filter((b) => b.plays >= 5 && (b.d1Return ?? 100) < avgD1 - 5)
|
||||
.sort((a, b) => (a.d1Return ?? 0) - (b.d1Return ?? 0))
|
||||
.slice(0, 8)
|
||||
);
|
||||
|
||||
function bar(pct: number | null, color = 'rgba(52,211,153,0.6)'): string {
|
||||
const w = pct === null ? 0 : Math.min(100, pct);
|
||||
return `width:${w}%;background:${color}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">4 · Verse Mix Lab</h2>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">66-book coverage (180 days)</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
How often each book was the answer, grouped by canonical section. Red = rarely featured,
|
||||
green = a staple. Aim for breadth, not repetition.
|
||||
</p>
|
||||
<BookCoverage {coverage} />
|
||||
</Container>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Testament balance & variety</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Weekly Old vs New Testament count, plus book-distribution entropy (higher = more variety).
|
||||
</p>
|
||||
<MiniLineChart
|
||||
series={[
|
||||
{ name: 'Old', color: '#f59e0b', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.old })) },
|
||||
{ name: 'New', color: '#60a5fa', points: payload.verseMix.testamentMix.map((t) => ({ date: t.date, value: t.new })) }
|
||||
]}
|
||||
/>
|
||||
<MiniLineChart
|
||||
series={[{ name: 'Entropy (bits)', color: '#a78bfa', points: payload.verseMix.entropy }]}
|
||||
showLegend={false}
|
||||
height={120}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Freshness</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
How long between repeats of the same book. More short gaps = repetitive mix.
|
||||
</p>
|
||||
<BarList
|
||||
items={payload.verseMix.repeatIntervals.map((b) => ({ label: b.label, value: b.value, color: 'rgba(244,114,182,0.5)' }))}
|
||||
/>
|
||||
<p class="pt-2 text-xs text-gray-400">Achievability (last 30 days)</p>
|
||||
<BarList
|
||||
items={payload.verseMix.achievability.map((b) => ({ label: `${b.label} guess${b.label === '1' ? '' : 'es'}`, value: b.value, color: 'rgba(96,165,250,0.5)' }))}
|
||||
/>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Retention by difficulty</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Next-day return after solving in each guess bucket. Do hard days make people leave?
|
||||
</p>
|
||||
<table class="w-full text-left text-xs text-gray-300">
|
||||
<thead class="text-gray-500">
|
||||
<tr><th class="py-1">Guesses</th><th>Plays</th><th>D1 return</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each payload.verseMix.retentionByGuessBucket as row (row.bucket)}
|
||||
<tr>
|
||||
<td class="py-1">{row.bucket}</td>
|
||||
<td>{row.plays}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-24 overflow-hidden rounded bg-white/5">
|
||||
<div class="h-full rounded" style={bar(row.d1)}></div>
|
||||
</div>
|
||||
<span>{row.d1 === null ? '—' : `${row.d1}%`}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</Container>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Retention by book</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Books whose day had below-average next-day return (min 5 plays). Candidate retirements.
|
||||
</p>
|
||||
{#if alarms.length === 0}
|
||||
<p class="text-sm text-gray-400">No clear churn outliers.</p>
|
||||
{:else}
|
||||
<table class="w-full text-left text-xs text-gray-300">
|
||||
<thead class="text-gray-500">
|
||||
<tr><th class="py-1">Book</th><th>Plays</th><th>D1</th><th>First-try</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each alarms as b (b.bookId)}
|
||||
<tr>
|
||||
<td class="py-1">{nameOf(b.bookId)}</td>
|
||||
<td>{b.plays}</td>
|
||||
<td>{b.d1Return === null ? '—' : `${b.d1Return}%`}</td>
|
||||
<td>{b.firstTryPct}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container class="p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-100">Giveaway detector</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
Books solved first try more than half the time (min 3 plays). Likely self-revealing text —
|
||||
down-weight in the curated mix.
|
||||
</p>
|
||||
{#if giveaways.length === 0}
|
||||
<p class="text-sm text-gray-400">No giveaway outliers.</p>
|
||||
{:else}
|
||||
<table class="w-full text-left text-xs text-gray-300">
|
||||
<thead class="text-gray-500">
|
||||
<tr><th class="py-1">Book</th><th>Plays</th><th>First-try</th><th>Avg guesses</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each giveaways as b (b.bookId)}
|
||||
<tr>
|
||||
<td class="py-1">{nameOf(b.bookId)}</td>
|
||||
<td>{b.plays}</td>
|
||||
<td>{b.firstTryPct}%</td>
|
||||
<td>{b.avgGuesses}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</Container>
|
||||
|
||||
<p class="text-xs text-gray-500">
|
||||
Before/after the curated mix system will appear here once enough pre/post data exists.
|
||||
</p>
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { DailyVerse } from '$lib/server/db/schema';
|
||||
|
||||
/**
|
||||
* The feed presents calendar days, so every timestamp is anchored to midnight
|
||||
* in this reference timezone. Using a fixed zone (rather than the server's
|
||||
* local timezone) keeps output stable across UTC dev machines and NY hosts.
|
||||
*/
|
||||
export const FEED_TIMEZONE = 'America/New_York';
|
||||
|
||||
/** Number of most-recent verses exposed in the feed. */
|
||||
export const FEED_ITEM_LIMIT = 30;
|
||||
|
||||
/** Escape the five XML predefined entities. */
|
||||
export function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* The current calendar date (`YYYY-MM-DD`) in the feed's reference timezone.
|
||||
* Used to cap the feed so future/scheduled verses are never published early.
|
||||
*/
|
||||
export function todayInTimeZone(timeZone: string = FEED_TIMEZONE, now: Date = new Date()): string {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The UTC offset in effect on a given `YYYY-MM-DD` for a timezone, formatted
|
||||
* as `±HH:MM` (e.g. `-04:00` during EDT, `-05:00` during EST).
|
||||
*/
|
||||
export function timeZoneOffset(dateStr: string, timeZone: string = FEED_TIMEZONE): string {
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
// Noon UTC is safely inside the calendar day in any US offset, so DST is
|
||||
// resolved correctly regardless of the time of day.
|
||||
const reference = new Date(Date.UTC(year, month - 1, day, 12));
|
||||
const name = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
timeZoneName: 'longOffset'
|
||||
})
|
||||
.formatToParts(reference)
|
||||
.find((part) => part.type === 'timeZoneName')?.value;
|
||||
|
||||
const offset = (name ?? 'GMT+00:00').replace('GMT', '');
|
||||
return offset === '' ? '+00:00' : offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a `YYYY-MM-DD` day as an RFC 822 / RFC 2822 timestamp at midnight in
|
||||
* the feed's reference timezone. Always emits a valid numeric/GMT timestamp
|
||||
* (never a bare, ambiguous abbreviation like `EST`).
|
||||
*/
|
||||
export function formatRFC822(dateStr: string, timeZone: string = FEED_TIMEZONE): string {
|
||||
const offset = timeZoneOffset(dateStr, timeZone);
|
||||
return new Date(`${dateStr}T00:00:00${offset}`).toUTCString();
|
||||
}
|
||||
|
||||
/** Human-readable date that does not shift with the server's local timezone. */
|
||||
export function formatReadableDate(dateStr: string): string {
|
||||
const [year, month, day] = dateStr.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
|
||||
/** Wrap text in a CDATA section, splitting any embedded `]]>` safely. */
|
||||
export function cdata(text: string): string {
|
||||
return `<![CDATA[${text.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
||||
}
|
||||
|
||||
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||
export function formatVerseText(text: string): string {
|
||||
let formatted = text;
|
||||
|
||||
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||
const pairs: [string, string][] = [
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
['\u201C', '\u201D'], // " "
|
||||
['\u2018', '\u2019'] // ' '
|
||||
];
|
||||
|
||||
// Check if text starts with opening punctuation without closing
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += '...' + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text ends with closing punctuation without opening
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + '...' + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||
for (const [open, close] of pairs) {
|
||||
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||
if (openCount > closeCount) {
|
||||
formatted += close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Content fingerprint used as a strong ETag. Any change to the included rows
|
||||
* (new day, edited verse text, changed reference) produces a new hash, so
|
||||
* cached clients revalidate correctly.
|
||||
*/
|
||||
export function feedFingerprint(
|
||||
verses: Pick<DailyVerse, 'date' | 'reference' | 'verseText'>[]
|
||||
): string {
|
||||
let hash = 5381;
|
||||
for (const verse of verses) {
|
||||
const row = `${verse.date}\u0000${verse.reference}\u0000${verse.verseText}`;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
hash = ((hash << 5) + hash + row.charCodeAt(i)) | 0;
|
||||
}
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent `createdAt` across the given rows, used for `Last-Modified`.
|
||||
* Falls back to `fallback` when no row carries a timestamp.
|
||||
*/
|
||||
export function latestModification(
|
||||
verses: Pick<DailyVerse, 'createdAt'>[],
|
||||
fallback: Date
|
||||
): Date {
|
||||
return verses.reduce<Date | null>((latest, verse) => {
|
||||
if (!verse.createdAt) return latest;
|
||||
return !latest || verse.createdAt > latest ? verse.createdAt : latest;
|
||||
}, null) ?? fallback;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Pure UTC date arithmetic on `YYYY-MM-DD` strings. No DB, no Svelte. */
|
||||
|
||||
const DAY_MS = 1000 * 60 * 60 * 24;
|
||||
|
||||
export function addDays(dateStr: string, n: number): string {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + n);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whole-day difference `b - a`. */
|
||||
export function dayDiff(a: string, b: string): number {
|
||||
return Math.round(
|
||||
(new Date(b + 'T00:00:00Z').getTime() - new Date(a + 'T00:00:00Z').getTime()) / DAY_MS
|
||||
);
|
||||
}
|
||||
|
||||
/** The Sunday on or before `dateStr` (week cohorts are Sunday-aligned). */
|
||||
export function startOfWeek(dateStr: string): string {
|
||||
const day = new Date(dateStr + 'T00:00:00Z').getUTCDay(); // 0 = Sunday
|
||||
return addDays(dateStr, -day);
|
||||
}
|
||||
|
||||
/** All dates from `start` to `end` inclusive. */
|
||||
export function dateRange(start: string, end: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let d = start; d <= end; d = addDays(d, 1)) out.push(d);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function todayUtc(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isValidDate(dateStr: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(dateStr) && !Number.isNaN(new Date(dateStr + 'T00:00:00Z').getTime());
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { db } from '$lib/server/db';
|
||||
|
||||
/** The Drizzle database type (production db or a test db). */
|
||||
export type StatsDb = typeof db;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SnapshotPayload } from '$lib/types/stats';
|
||||
import { sortSignals } from './signals';
|
||||
|
||||
/** Compose the ~6-line plain-language daily digest. Pure / testable. */
|
||||
export function composeDigest(payload: SnapshotPayload): string {
|
||||
const { activity, kpis } = payload;
|
||||
const top = sortSignals(payload.signals)[0];
|
||||
const lines = [
|
||||
`**Bibdle stats — ${payload.asOfDate}**`,
|
||||
`Players: ${activity.dau} today · ${activity.wau} this week · ${activity.mau} this month`,
|
||||
`New ${activity.newPlayers} · returning ${activity.returningShare ?? '—'}% · net ${activity.netGrowth >= 0 ? '+' : ''}${activity.netGrowth}`,
|
||||
`D1 return ${kpis.d1Return ?? '—'}% · 7-day ${kpis.return7 ?? '—'}%`,
|
||||
top ? `Top signal: ${top.title} — ${top.detail}` : 'No signals.',
|
||||
`https://bibdle.com/admin/stats?date=${payload.asOfDate}`
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the digest to the admin Discord webhook. No-op (with a log) when the
|
||||
* webhook is unset; never throws so a digest failure can't fail the build.
|
||||
*/
|
||||
export async function sendDigest(
|
||||
payload: SnapshotPayload,
|
||||
webhookUrl: string | undefined = Bun.env.DISCORD_ADMIN_WEBHOOK
|
||||
): Promise<boolean> {
|
||||
const message = composeDigest(payload);
|
||||
if (!webhookUrl) {
|
||||
console.log('[stats] DISCORD_ADMIN_WEBHOOK not set; digest skipped:\n' + message);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: message })
|
||||
});
|
||||
if (!res.ok) console.error('[stats] digest webhook responded', res.status);
|
||||
return res.ok;
|
||||
} catch (err) {
|
||||
console.error('[stats] digest failed', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { db as defaultDb } from '$lib/server/db';
|
||||
import { analyticsEvents } from '$lib/server/db/schema';
|
||||
import { EVENT_NAMES, MAX_METADATA_BYTES, type EventName } from '$lib/types/stats';
|
||||
import type { StatsDb } from './db';
|
||||
|
||||
export interface RecordEventInput {
|
||||
name: string;
|
||||
anonymousId?: string | null;
|
||||
userId?: string | null;
|
||||
date?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function isEventName(name: string): name is EventName {
|
||||
return (EVENT_NAMES as readonly string[]).includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely serialize metadata, capping it at `MAX_METADATA_BYTES`. Oversized
|
||||
* payloads are replaced with a marker rather than truncated into invalid JSON.
|
||||
*/
|
||||
export function encodeMetadata(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||
if (metadata == null) return null;
|
||||
let json: string | undefined;
|
||||
try {
|
||||
json = JSON.stringify(metadata);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (json === undefined) return null;
|
||||
if (Buffer.byteLength(json) > MAX_METADATA_BYTES) {
|
||||
return JSON.stringify({ truncated: true });
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert one analytics event.
|
||||
*
|
||||
* Always uses `ON CONFLICT DO NOTHING`: the only unique constraint is the
|
||||
* partial `verse_served` index, so this dedupes reloads for `verse_served`
|
||||
* while other events insert freely.
|
||||
*
|
||||
* Never throws — analytics must not break gameplay. Returns true on insert.
|
||||
*/
|
||||
export async function recordEvent(
|
||||
input: RecordEventInput,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<boolean> {
|
||||
const { name, anonymousId, userId, date, metadata } = input;
|
||||
try {
|
||||
const inserted = await database
|
||||
.insert(analyticsEvents)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
anonymousId: anonymousId ?? null,
|
||||
userId: userId ?? null,
|
||||
date: date ?? null,
|
||||
createdAt: new Date(),
|
||||
metadata: encodeMetadata(metadata)
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: analyticsEvents.id });
|
||||
return inserted.length > 0;
|
||||
} catch (err) {
|
||||
console.error('[stats] failed to record event', name, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import type {
|
||||
BookStat,
|
||||
CohortRow,
|
||||
CompletionRow,
|
||||
HistogramBin,
|
||||
VerseRow
|
||||
} from '$lib/types/stats';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
import { addDays, dateRange, dayDiff, startOfWeek } from './dates';
|
||||
|
||||
// ── internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Unique play dates per player, sorted ascending. */
|
||||
export function groupDatesByPlayer(completions: CompletionRow[]): Map<string, string[]> {
|
||||
const map = new Map<string, Set<string>>();
|
||||
for (const c of completions) {
|
||||
let set = map.get(c.anonymousId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
map.set(c.anonymousId, set);
|
||||
}
|
||||
set.add(c.date);
|
||||
}
|
||||
const out = new Map<string, string[]>();
|
||||
for (const [id, set] of map) out.set(id, [...set].sort());
|
||||
return out;
|
||||
}
|
||||
|
||||
/** First-ever play date per player. */
|
||||
export function firstPlayDates(completions: CompletionRow[]): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const c of completions) {
|
||||
const current = out.get(c.anonymousId);
|
||||
if (current === undefined || c.date < current) out.set(c.anonymousId, c.date);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
function pct(part: number, whole: number): number | null {
|
||||
if (whole <= 0) return null;
|
||||
return round1((part / whole) * 100);
|
||||
}
|
||||
|
||||
// ── activity ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Distinct players with a completion in `[end - windowDays + 1, end]`. */
|
||||
export function activePlayers(
|
||||
completions: CompletionRow[],
|
||||
end: string,
|
||||
windowDays: number
|
||||
): number {
|
||||
const start = addDays(end, -(windowDays - 1));
|
||||
const set = new Set<string>();
|
||||
for (const c of completions) {
|
||||
if (c.date >= start && c.date <= end) set.add(c.anonymousId);
|
||||
}
|
||||
return set.size;
|
||||
}
|
||||
|
||||
export interface NewVsReturning {
|
||||
newPlayers: number;
|
||||
returning: number;
|
||||
returningShare: number | null;
|
||||
}
|
||||
|
||||
/** Split a single day's players into first-timers and returning players. */
|
||||
export function newVsReturning(completions: CompletionRow[], date: string): NewVsReturning {
|
||||
const firsts = firstPlayDates(completions);
|
||||
const players = new Set<string>();
|
||||
for (const c of completions) if (c.date === date) players.add(c.anonymousId);
|
||||
let newPlayers = 0;
|
||||
let returning = 0;
|
||||
for (const id of players) {
|
||||
if (firsts.get(id) === date) newPlayers++;
|
||||
else returning++;
|
||||
}
|
||||
return { newPlayers, returning, returningShare: pct(returning, players.size) };
|
||||
}
|
||||
|
||||
export interface NetGrowth {
|
||||
newPlayers: number;
|
||||
resurrected: number;
|
||||
churned: number;
|
||||
netGrowth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Growth accounting over a trailing window:
|
||||
* `new + resurrected - churned`.
|
||||
*
|
||||
* - new: first-ever play inside the window
|
||||
* - resurrected: played in the window after a gap longer than `resurrectGapDays`
|
||||
* - churned: played in the preceding equal window but not in this one
|
||||
*/
|
||||
export function netGrowth(
|
||||
completions: CompletionRow[],
|
||||
end: string,
|
||||
windowDays: number,
|
||||
resurrectGapDays: number
|
||||
): NetGrowth {
|
||||
const windowStart = addDays(end, -(windowDays - 1));
|
||||
const prevStart = addDays(windowStart, -windowDays);
|
||||
const prevEnd = addDays(windowStart, -1);
|
||||
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
|
||||
let newPlayers = 0;
|
||||
let resurrected = 0;
|
||||
let churned = 0;
|
||||
|
||||
for (const dates of datesByPlayer.values()) {
|
||||
const inWindow = dates.filter((d) => d >= windowStart && d <= end);
|
||||
const inPrev = dates.filter((d) => d >= prevStart && d <= prevEnd);
|
||||
|
||||
if (inWindow.length > 0) {
|
||||
const firstInWindow = inWindow[0];
|
||||
const earlier = dates.filter((d) => d < firstInWindow);
|
||||
if (earlier.length === 0) {
|
||||
if (firstInWindow >= windowStart) newPlayers++;
|
||||
} else {
|
||||
const gap = dayDiff(earlier[earlier.length - 1], firstInWindow);
|
||||
if (gap > resurrectGapDays) resurrected++;
|
||||
}
|
||||
} else if (inPrev.length > 0) {
|
||||
churned++;
|
||||
}
|
||||
}
|
||||
|
||||
return { newPlayers, resurrected, churned, netGrowth: newPlayers + resurrected - churned };
|
||||
}
|
||||
|
||||
// ── retention ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fraction of players first seen on `cohortDate` who played again within
|
||||
* `n` days. Returns null for cohorts smaller than `minSize`.
|
||||
*/
|
||||
export function returnWithin(
|
||||
completions: CompletionRow[],
|
||||
cohortDate: string,
|
||||
n: number,
|
||||
minSize = 1
|
||||
): number | null {
|
||||
const firsts = firstPlayDates(completions);
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const end = addDays(cohortDate, n);
|
||||
|
||||
let cohort = 0;
|
||||
let returned = 0;
|
||||
for (const [id, first] of firsts) {
|
||||
if (first !== cohortDate) continue;
|
||||
cohort++;
|
||||
const dates = datesByPlayer.get(id) ?? [];
|
||||
if (dates.some((d) => d > cohortDate && d <= end)) returned++;
|
||||
}
|
||||
if (cohort < minSize) return null;
|
||||
return pct(returned, cohort);
|
||||
}
|
||||
|
||||
/** Next-day return rate for a cohort of first-timers. */
|
||||
export function d1Return(completions: CompletionRow[], cohortDate: string, minSize = 1): number | null {
|
||||
return returnWithin(completions, cohortDate, 1, minSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly cohort triangle. Each row is a cohort (players whose first play fell
|
||||
* in that week) and each column k is the share active k weeks later.
|
||||
*/
|
||||
export function cohortTriangle(
|
||||
completions: CompletionRow[],
|
||||
opts: { start: string; weeks: number; asOf?: string; minSize?: number }
|
||||
): CohortRow[] {
|
||||
const { start, weeks, asOf, minSize = 1 } = opts;
|
||||
const firsts = firstPlayDates(completions);
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const rows: CohortRow[] = [];
|
||||
|
||||
for (let i = 0; i < weeks; i++) {
|
||||
const weekStart = addDays(start, i * 7);
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
const members: string[] = [];
|
||||
for (const [id, first] of firsts) {
|
||||
if (first >= weekStart && first <= weekEnd) members.push(id);
|
||||
}
|
||||
const size = members.length;
|
||||
const counts: number[] = [];
|
||||
const rates: (number | null)[] = [];
|
||||
for (let k = 0; k < weeks - i; k++) {
|
||||
const wStart = addDays(weekStart, k * 7);
|
||||
const wEnd = addDays(wStart, 6);
|
||||
if (asOf && wStart > asOf) {
|
||||
counts.push(0);
|
||||
rates.push(null);
|
||||
continue;
|
||||
}
|
||||
let active = 0;
|
||||
for (const id of members) {
|
||||
const dates = datesByPlayer.get(id) ?? [];
|
||||
if (dates.some((d) => d >= wStart && d <= wEnd)) active++;
|
||||
}
|
||||
counts.push(active);
|
||||
rates.push(size < minSize ? null : pct(active, size));
|
||||
}
|
||||
rows.push({ weekStart, size, counts, rates });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Days until a first-timer's second play, bucketed. */
|
||||
export function timeToSecondPlayHistogram(completions: CompletionRow[]): HistogramBin[] {
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const gaps: number[] = [];
|
||||
for (const dates of datesByPlayer.values()) {
|
||||
if (dates.length < 2) continue;
|
||||
gaps.push(dayDiff(dates[0], dates[1]));
|
||||
}
|
||||
const buckets: { label: string; test: (g: number) => boolean }[] = [
|
||||
{ label: '1 day', test: (g) => g === 1 },
|
||||
{ label: '2 days', test: (g) => g === 2 },
|
||||
{ label: '3–4 days', test: (g) => g >= 3 && g <= 4 },
|
||||
{ label: '5–7 days', test: (g) => g >= 5 && g <= 7 },
|
||||
{ label: '8–14 days', test: (g) => g >= 8 && g <= 14 },
|
||||
{ label: '15–30 days', test: (g) => g >= 15 && g <= 30 },
|
||||
{ label: '31+ days', test: (g) => g >= 31 }
|
||||
];
|
||||
return buckets.map((b) => ({ label: b.label, value: gaps.filter(b.test).length }));
|
||||
}
|
||||
|
||||
// ── guesses / difficulty ─────────────────────────────────────────────────────
|
||||
|
||||
/** Distribution of guess counts, with everything above `max` collapsed. */
|
||||
export function guessHistogram(completions: CompletionRow[], max = 8): HistogramBin[] {
|
||||
const bins: HistogramBin[] = [];
|
||||
for (let g = 1; g <= max; g++) bins.push({ label: String(g), value: 0 });
|
||||
bins.push({ label: `${max + 1}+`, value: 0 });
|
||||
for (const c of completions) {
|
||||
if (c.guessCount <= max) bins[c.guessCount - 1].value++;
|
||||
else bins[max].value++;
|
||||
}
|
||||
return bins;
|
||||
}
|
||||
|
||||
/** Share of completions solved on the very first guess, %. */
|
||||
export function firstTryRate(completions: CompletionRow[]): number | null {
|
||||
if (completions.length === 0) return null;
|
||||
const firstTry = completions.filter((c) => c.guessCount === 1).length;
|
||||
return pct(firstTry, completions.length);
|
||||
}
|
||||
|
||||
export function averageGuesses(completions: CompletionRow[]): number | null {
|
||||
if (completions.length === 0) return null;
|
||||
const total = completions.reduce((sum, c) => sum + c.guessCount, 0);
|
||||
return round1(total / completions.length);
|
||||
}
|
||||
|
||||
// ── streaks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface StreakSurvivalPoint {
|
||||
length: number;
|
||||
probability: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probability that a streak of length N continues to N+1.
|
||||
* Denominator counts every day a player's run reached length N.
|
||||
*/
|
||||
export function streakSurvival(completions: CompletionRow[]): StreakSurvivalPoint[] {
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const attempts = new Map<number, number>();
|
||||
const continued = new Map<number, number>();
|
||||
|
||||
for (const dates of datesByPlayer.values()) {
|
||||
let streak = 0;
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
streak = i > 0 && dayDiff(dates[i - 1], dates[i]) === 1 ? streak + 1 : 1;
|
||||
attempts.set(streak, (attempts.get(streak) ?? 0) + 1);
|
||||
const tomorrow = addDays(dates[i], 1);
|
||||
if (dates.includes(tomorrow)) {
|
||||
continued.set(streak, (continued.get(streak) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lengths = [...attempts.keys()].sort((a, b) => a - b);
|
||||
const out: StreakSurvivalPoint[] = [];
|
||||
for (const length of lengths) {
|
||||
const denom = attempts.get(length) ?? 0;
|
||||
if (denom === 0) continue;
|
||||
out.push({ length, probability: round1(((continued.get(length) ?? 0) / denom) * 100) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── content ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Shannon entropy (bits) of a count distribution. */
|
||||
export function entropy(counts: number[]): number {
|
||||
const total = counts.reduce((a, b) => a + b, 0);
|
||||
if (total <= 0) return 0;
|
||||
let h = 0;
|
||||
for (const c of counts) {
|
||||
if (c <= 0) continue;
|
||||
const p = c / total;
|
||||
h -= p * Math.log2(p);
|
||||
}
|
||||
return Math.round(h * 1000) / 1000;
|
||||
}
|
||||
|
||||
/** Next-feature interval per book, bucketed. */
|
||||
export function repeatIntervals(verses: VerseRow[]): HistogramBin[] {
|
||||
const byBook = new Map<string, string[]>();
|
||||
for (const v of verses) {
|
||||
const arr = byBook.get(v.bookId);
|
||||
if (arr) arr.push(v.date);
|
||||
else byBook.set(v.bookId, [v.date]);
|
||||
}
|
||||
const gaps: number[] = [];
|
||||
for (const dates of byBook.values()) {
|
||||
dates.sort();
|
||||
for (let i = 1; i < dates.length; i++) gaps.push(dayDiff(dates[i - 1], dates[i]));
|
||||
}
|
||||
const buckets: { label: string; test: (g: number) => boolean }[] = [
|
||||
{ label: '1–7d', test: (g) => g <= 7 },
|
||||
{ label: '8–14d', test: (g) => g >= 8 && g <= 14 },
|
||||
{ label: '15–30d', test: (g) => g >= 15 && g <= 30 },
|
||||
{ label: '31–60d', test: (g) => g >= 31 && g <= 60 },
|
||||
{ label: '61–90d', test: (g) => g >= 61 && g <= 90 },
|
||||
{ label: '90d+', test: (g) => g > 90 }
|
||||
];
|
||||
return buckets.map((b) => ({ label: b.label, value: gaps.filter(b.test).length }));
|
||||
}
|
||||
|
||||
export interface BookCoverageOptions {
|
||||
start: string;
|
||||
end: string;
|
||||
minPlays?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-book content + retention stats over `[start, end]`.
|
||||
* `days` is the number of distinct days the book was the answer; `plays` is the
|
||||
* number of completions on those days.
|
||||
*/
|
||||
export function bookCoverage(
|
||||
verses: VerseRow[],
|
||||
completions: CompletionRow[],
|
||||
opts: BookCoverageOptions
|
||||
): BookStat[] {
|
||||
const { start, end, minPlays = 1 } = opts;
|
||||
const verseInRange = verses.filter((v) => v.date >= start && v.date <= end);
|
||||
const bookByDate = new Map<string, string>();
|
||||
const daysByBook = new Map<string, Set<string>>();
|
||||
for (const v of verseInRange) {
|
||||
bookByDate.set(v.date, v.bookId);
|
||||
let set = daysByBook.get(v.bookId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
daysByBook.set(v.bookId, set);
|
||||
}
|
||||
set.add(v.date);
|
||||
}
|
||||
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const completionsInRange = completions.filter((c) => c.date >= start && c.date <= end);
|
||||
|
||||
// Aggregate per book
|
||||
const stats = new Map<
|
||||
string,
|
||||
{ plays: number; firstTry: number; guessSum: number; d1: number; d1Denom: number }
|
||||
>();
|
||||
for (const c of completionsInRange) {
|
||||
const bookId = bookByDate.get(c.date);
|
||||
if (!bookId) continue;
|
||||
let s = stats.get(bookId);
|
||||
if (!s) {
|
||||
s = { plays: 0, firstTry: 0, guessSum: 0, d1: 0, d1Denom: 0 };
|
||||
stats.set(bookId, s);
|
||||
}
|
||||
s.plays++;
|
||||
if (c.guessCount === 1) s.firstTry++;
|
||||
s.guessSum += c.guessCount;
|
||||
s.d1Denom++;
|
||||
const dates = datesByPlayer.get(c.anonymousId) ?? [];
|
||||
if (dates.includes(addDays(c.date, 1))) s.d1++;
|
||||
}
|
||||
|
||||
const out: BookStat[] = [];
|
||||
for (const book of bibleBooks) {
|
||||
const days = daysByBook.get(book.id)?.size ?? 0;
|
||||
const s = stats.get(book.id);
|
||||
if (!s || s.plays < minPlays) {
|
||||
out.push({
|
||||
bookId: book.id,
|
||||
days,
|
||||
plays: s?.plays ?? 0,
|
||||
firstTryPct: s ? (pct(s.firstTry, s.plays) ?? 0) : 0,
|
||||
avgGuesses: s ? round1(s.guessSum / s.plays) : 0,
|
||||
d1Return: null
|
||||
});
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
bookId: book.id,
|
||||
days,
|
||||
plays: s.plays,
|
||||
firstTryPct: pct(s.firstTry, s.plays) ?? 0,
|
||||
avgGuesses: round1(s.guessSum / s.plays),
|
||||
d1Return: pct(s.d1, s.d1Denom)
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Next-day return rate grouped by how many guesses the solve took. */
|
||||
export function retentionByGuessBucket(
|
||||
completions: CompletionRow[]
|
||||
): { bucket: string; d1: number | null; plays: number }[] {
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const buckets: { label: string; test: (g: number) => boolean }[] = [
|
||||
{ label: '1', test: (g) => g === 1 },
|
||||
{ label: '2', test: (g) => g === 2 },
|
||||
{ label: '3', test: (g) => g === 3 },
|
||||
{ label: '4–5', test: (g) => g >= 4 && g <= 5 },
|
||||
{ label: '6+', test: (g) => g >= 6 }
|
||||
];
|
||||
return buckets.map((b) => {
|
||||
const rows = completions.filter((c) => b.test(c.guessCount));
|
||||
let returned = 0;
|
||||
for (const c of rows) {
|
||||
const dates = datesByPlayer.get(c.anonymousId) ?? [];
|
||||
if (dates.includes(addDays(c.date, 1))) returned++;
|
||||
}
|
||||
return { bucket: b.label, d1: pct(returned, rows.length), plays: rows.length };
|
||||
});
|
||||
}
|
||||
|
||||
/** Completion counts by weekday (0=Sun) and UTC hour. */
|
||||
export function weekdayHourHeatmap(completions: CompletionRow[]): number[][] {
|
||||
const grid: number[][] = Array.from({ length: 7 }, () => Array(24).fill(0));
|
||||
for (const c of completions) {
|
||||
const d = new Date(c.completedAt);
|
||||
grid[d.getUTCDay()][d.getUTCHours()]++;
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** Overall share of players who ever played more than once. */
|
||||
export function overallReturnRate(completions: CompletionRow[]): number | null {
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
if (datesByPlayer.size === 0) return null;
|
||||
let returned = 0;
|
||||
for (const dates of datesByPlayer.values()) if (dates.length >= 2) returned++;
|
||||
return pct(returned, datesByPlayer.size);
|
||||
}
|
||||
|
||||
/** Players who completed on each date, for daily series. */
|
||||
export function dailyCompletionsSeries(
|
||||
completions: CompletionRow[],
|
||||
start: string,
|
||||
end: string
|
||||
): { date: string; completions: number; newPlayers: number }[] {
|
||||
const firsts = firstPlayDates(completions);
|
||||
const byDate = new Map<string, { completions: number; newPlayers: number }>();
|
||||
for (const date of dateRange(start, end)) byDate.set(date, { completions: 0, newPlayers: 0 });
|
||||
for (const c of completions) {
|
||||
const row = byDate.get(c.date);
|
||||
if (!row) continue;
|
||||
row.completions++;
|
||||
if (firsts.get(c.anonymousId) === c.date) row.newPlayers++;
|
||||
}
|
||||
return [...byDate.entries()].map(([date, v]) => ({ date, ...v }));
|
||||
}
|
||||
|
||||
export { startOfWeek, addDays, dayDiff };
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
PlayerState,
|
||||
SegmentCounts,
|
||||
SegmentSets,
|
||||
StatsSettings
|
||||
} from '$lib/types/stats';
|
||||
import { dayDiff } from './dates';
|
||||
|
||||
/**
|
||||
* Classify players into mutually-exclusive lifecycle segments using the
|
||||
* thresholds supplied at render time. The stored player state is frozen as of
|
||||
* the snapshot date, so changing thresholds re-segments all history instantly
|
||||
* without rebuilding anything.
|
||||
*/
|
||||
export function deriveSegments(
|
||||
players: PlayerState[],
|
||||
asOfDate: string,
|
||||
settings: StatsSettings
|
||||
): SegmentSets {
|
||||
const sets: SegmentSets = {
|
||||
new: [],
|
||||
core: [],
|
||||
returning: [],
|
||||
resurrected: [],
|
||||
atRisk: [],
|
||||
churned: [],
|
||||
active: []
|
||||
};
|
||||
|
||||
for (const p of players) {
|
||||
const inactive = dayDiff(p.lastDate, asOfDate);
|
||||
const firstAge = dayDiff(p.firstDate, asOfDate);
|
||||
|
||||
// Inactive segments first.
|
||||
if (inactive >= settings.churn_days) {
|
||||
sets.churned.push(p.id);
|
||||
continue;
|
||||
}
|
||||
if (inactive >= settings.at_risk_days) {
|
||||
sets.atRisk.push(p.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
sets.active.push(p.id);
|
||||
|
||||
if (firstAge < 7) {
|
||||
sets.new.push(p.id);
|
||||
} else if (p.plays7 >= settings.core_plays_per_week) {
|
||||
sets.core.push(p.id);
|
||||
} else if (
|
||||
p.plays7 > 0 &&
|
||||
firstAge > settings.resurrect_gap_days &&
|
||||
p.plays30 <= p.plays7
|
||||
) {
|
||||
// Active this week, no activity in the 8–30 day band, first seen long
|
||||
// ago → they came back after a long gap.
|
||||
sets.resurrected.push(p.id);
|
||||
} else {
|
||||
sets.returning.push(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
return sets;
|
||||
}
|
||||
|
||||
export function countSegments(sets: SegmentSets): SegmentCounts {
|
||||
return {
|
||||
new: sets.new.length,
|
||||
core: sets.core.length,
|
||||
returning: sets.returning.length,
|
||||
resurrected: sets.resurrected.length,
|
||||
atRisk: sets.atRisk.length,
|
||||
churned: sets.churned.length,
|
||||
active: sets.active.length,
|
||||
total:
|
||||
sets.active.length +
|
||||
sets.atRisk.length +
|
||||
sets.churned.length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { db as defaultDb } from '$lib/server/db';
|
||||
import { dashboardSettings } from '$lib/server/db/schema';
|
||||
import {
|
||||
SETTINGS_DEFAULTS,
|
||||
type SettingsKey,
|
||||
type StatsSettings
|
||||
} from '$lib/types/stats';
|
||||
import type { StatsDb } from './db';
|
||||
|
||||
const SETTINGS_KEYS = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
|
||||
|
||||
/** `core_plays_per_week` → `STATS_CORE_PLAYS_PER_WEEK` */
|
||||
export function settingsEnvVar(key: SettingsKey): string {
|
||||
return `STATS_${key.toUpperCase()}`;
|
||||
}
|
||||
|
||||
/** Defaults from `SETTINGS_DEFAULTS`, overridden by `STATS_*` env vars. */
|
||||
export function envSettings(): StatsSettings {
|
||||
const out = { ...SETTINGS_DEFAULTS } as StatsSettings;
|
||||
for (const key of SETTINGS_KEYS) {
|
||||
const raw = Bun.env[settingsEnvVar(key)];
|
||||
if (raw === undefined || raw === '') continue;
|
||||
const value = Number(raw);
|
||||
if (Number.isFinite(value)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function coerce(key: SettingsKey, raw: string): number {
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : SETTINGS_DEFAULTS[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads persisted overrides from `dashboard_settings` and overlays them on the
|
||||
* env/default baseline. Safe to call when the table does not yet exist.
|
||||
*/
|
||||
export async function getSettings(database: StatsDb = defaultDb): Promise<StatsSettings> {
|
||||
const settings = envSettings();
|
||||
try {
|
||||
const rows = await database.select().from(dashboardSettings);
|
||||
for (const row of rows) {
|
||||
if ((SETTINGS_KEYS as string[]).includes(row.key)) {
|
||||
settings[row.key as SettingsKey] = coerce(row.key as SettingsKey, row.value);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Table missing / DB unavailable — fall back to defaults.
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** Upserts a partial set of setting overrides. */
|
||||
export async function updateSettings(
|
||||
partial: Partial<StatsSettings>,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<StatsSettings> {
|
||||
const now = new Date();
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
if (!(SETTINGS_KEYS as string[]).includes(key)) continue;
|
||||
if (value === undefined || value === null || !Number.isFinite(value)) continue;
|
||||
await database
|
||||
.insert(dashboardSettings)
|
||||
.values({ key, value: String(value), updatedAt: now })
|
||||
.onConflictDoUpdate({
|
||||
target: dashboardSettings.key,
|
||||
set: { value: String(value), updatedAt: now }
|
||||
});
|
||||
}
|
||||
return getSettings(database);
|
||||
}
|
||||
|
||||
/** Stable short hash of the resolved settings — used to detect stale snapshots. */
|
||||
export function hashSettings(settings: StatsSettings): string {
|
||||
const canonical = SETTINGS_KEYS.map((k) => `${k}=${settings[k]}`).join(';');
|
||||
const digest = new Bun.CryptoHasher('sha256').update(canonical).digest('hex');
|
||||
return digest.slice(0, 12);
|
||||
}
|
||||
|
||||
export async function configVersion(database: StatsDb = defaultDb): Promise<string> {
|
||||
return hashSettings(await getSettings(database));
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { SeriesPoint, Signal, SignalSeverity, SnapshotPayload, StatsSettings } from '$lib/types/stats';
|
||||
|
||||
// ── generic anomaly detection ────────────────────────────────────────────────
|
||||
|
||||
export interface Anomaly {
|
||||
date: string;
|
||||
value: number;
|
||||
median: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Robust, weekday-aware anomaly detection. Compares the latest point with the
|
||||
* median/MAD of the same weekday over the preceding `lookback` points.
|
||||
* Returns null when there is not enough history or the series is flat.
|
||||
*/
|
||||
export function detectAnomaly(series: SeriesPoint[], threshold: number, lookback = 28): Anomaly | null {
|
||||
const points = series.filter((p) => p.value !== null) as { date: string; value: number }[];
|
||||
if (points.length < 5) return null;
|
||||
const latest = points[points.length - 1];
|
||||
const weekday = new Date(latest.date + 'T00:00:00Z').getUTCDay();
|
||||
const history = points
|
||||
.slice(0, -1)
|
||||
.filter((p) => new Date(p.date + 'T00:00:00Z').getUTCDay() === weekday)
|
||||
.slice(-lookback);
|
||||
if (history.length < 4) return null;
|
||||
|
||||
const values = history.map((p) => p.value).sort((a, b) => a - b);
|
||||
const median = medianOf(values);
|
||||
const deviations = values.map((v) => Math.abs(v - median)).sort((a, b) => a - b);
|
||||
const mad = medianOf(deviations);
|
||||
if (mad === 0) return null;
|
||||
const score = (0.6745 * (latest.value - median)) / mad;
|
||||
if (Math.abs(score) < threshold) return null;
|
||||
return { date: latest.date, value: latest.value, median: Math.round(median * 10) / 10, score: Math.round(score * 10) / 10 };
|
||||
}
|
||||
|
||||
function medianOf(sorted: number[]): number {
|
||||
if (sorted.length === 0) return 0;
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
||||
}
|
||||
|
||||
// ── rule helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Direction = 'up' | 'down';
|
||||
|
||||
interface TargetRule {
|
||||
id: string;
|
||||
title: string;
|
||||
action: string;
|
||||
value: number | null;
|
||||
target: number;
|
||||
/** At or above target is good when direction is 'up'. */
|
||||
direction?: Direction;
|
||||
/** Absolute value that means "red" regardless of the target. */
|
||||
red: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
function evaluateTarget(rule: TargetRule): Signal {
|
||||
const { id, title, action, value, target, red, direction = 'up', unit = '%' } = rule;
|
||||
if (value === null) {
|
||||
return {
|
||||
id,
|
||||
severity: 'info',
|
||||
title,
|
||||
detail: `Not enough data yet to measure this (needs instrumented events).`,
|
||||
metric: id,
|
||||
value: null,
|
||||
target
|
||||
};
|
||||
}
|
||||
|
||||
const meets = direction === 'up' ? value >= target : value <= target;
|
||||
const isRed = direction === 'up' ? value < red : value > red;
|
||||
let severity: SignalSeverity = 'good';
|
||||
if (!meets) severity = isRed ? 'bad' : 'warn';
|
||||
|
||||
const comparison =
|
||||
direction === 'up'
|
||||
? value >= target
|
||||
? `at or above the ${target}% target`
|
||||
: `below the ${target}% target`
|
||||
: value <= target
|
||||
? `at or below the ${target}% target`
|
||||
: `above the ${target}% target`;
|
||||
|
||||
return {
|
||||
id,
|
||||
severity,
|
||||
title,
|
||||
detail: `${title} is ${value}${unit}, ${comparison}.`,
|
||||
action: severity === 'good' ? undefined : action,
|
||||
metric: id,
|
||||
value,
|
||||
target
|
||||
};
|
||||
}
|
||||
|
||||
// ── main engine ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Evaluate the explicit targets plus anomaly detection. Pure: the snapshot
|
||||
* payload carries every number the engine needs.
|
||||
*/
|
||||
export function evaluateSignals(payload: SnapshotPayload, settings: StatsSettings): Signal[] {
|
||||
const { kpis, activity } = payload;
|
||||
const signals: Signal[] = [];
|
||||
|
||||
signals.push(
|
||||
evaluateTarget({
|
||||
id: 'd1_return',
|
||||
title: 'D1 return',
|
||||
action: 'Test a "come back tomorrow" prompt on the win screen.',
|
||||
value: kpis.d1Return,
|
||||
target: settings.d1_target,
|
||||
red: 12
|
||||
})
|
||||
);
|
||||
|
||||
signals.push(
|
||||
evaluateTarget({
|
||||
id: 'return7',
|
||||
title: '7-day return',
|
||||
action: 'Improve the first-session experience or send a day-2 reminder.',
|
||||
value: kpis.return7,
|
||||
target: settings.return7_target,
|
||||
red: 15
|
||||
})
|
||||
);
|
||||
|
||||
signals.push(
|
||||
evaluateTarget({
|
||||
id: 'completion',
|
||||
title: 'Completion rate',
|
||||
action: 'Look for friction between the first guess and the win screen.',
|
||||
value: kpis.completionRate,
|
||||
target: settings.completion_target,
|
||||
red: 55
|
||||
})
|
||||
);
|
||||
|
||||
// First-try rate is a band: both too high and too low are bad.
|
||||
if (kpis.firstTryRate === null) {
|
||||
signals.push({
|
||||
id: 'first_try',
|
||||
severity: 'info',
|
||||
title: 'First-try rate',
|
||||
detail: 'Not enough data yet to measure this.',
|
||||
metric: 'first_try'
|
||||
});
|
||||
} else {
|
||||
const v = kpis.firstTryRate;
|
||||
const inBand = v >= settings.first_try_min && v <= settings.first_try_max;
|
||||
const severe = v > 45 || v < 8;
|
||||
signals.push({
|
||||
id: 'first_try',
|
||||
severity: inBand ? 'good' : severe ? 'bad' : 'warn',
|
||||
title: 'Verse achievability',
|
||||
detail: `First-try rate is ${v}% (target band ${settings.first_try_min}–${settings.first_try_max}%).${
|
||||
v > settings.first_try_max
|
||||
? ' Verses may be too easy / self-revealing.'
|
||||
: v < settings.first_try_min
|
||||
? ' Verses may be too hard.'
|
||||
: ''
|
||||
}`,
|
||||
action: inBand ? undefined : 'Review the verse mix for the easiest / hardest books.',
|
||||
metric: 'first_try',
|
||||
value: v,
|
||||
target: settings.first_try_max
|
||||
});
|
||||
}
|
||||
|
||||
signals.push(
|
||||
evaluateTarget({
|
||||
id: 'share_rate',
|
||||
title: 'Share rate',
|
||||
action: 'Make the share action more prominent or reward sharing.',
|
||||
value: kpis.shareRate,
|
||||
target: settings.share_target,
|
||||
red: 15
|
||||
})
|
||||
);
|
||||
|
||||
// Core players: week-over-week direction.
|
||||
if (activity.corePrevious === null) {
|
||||
signals.push({
|
||||
id: 'core_players',
|
||||
severity: 'info',
|
||||
title: 'Core players',
|
||||
detail: `${activity.core} players finished 4+ times this week. No prior week to compare yet.`,
|
||||
metric: 'core_players',
|
||||
value: activity.core
|
||||
});
|
||||
} else {
|
||||
const delta = activity.core - activity.corePrevious;
|
||||
signals.push({
|
||||
id: 'core_players',
|
||||
severity: delta >= 0 ? 'good' : 'warn',
|
||||
title: 'Core players',
|
||||
detail: `${activity.core} players finished 4+ times this week (${delta >= 0 ? '+' : ''}${delta} vs last week).`,
|
||||
action: delta < 0 ? 'Check whether verse difficulty or reminders changed.' : undefined,
|
||||
metric: 'core_players',
|
||||
value: activity.core
|
||||
});
|
||||
}
|
||||
|
||||
// Anomaly detection on the daily completions series.
|
||||
const anomaly = detectAnomaly(
|
||||
payload.dailySeries.map((d) => ({ date: d.date, value: d.completions })),
|
||||
settings.anomaly_mad_threshold
|
||||
);
|
||||
if (anomaly) {
|
||||
const direction = anomaly.score > 0 ? 'above' : 'below';
|
||||
signals.push({
|
||||
id: 'completions_anomaly',
|
||||
severity: anomaly.score > 0 ? 'good' : 'warn',
|
||||
title: 'Unusual day',
|
||||
detail: `${anomaly.date} had ${anomaly.value} finishes, well ${direction} the usual ${anomaly.median} for that weekday.`,
|
||||
action: anomaly.score < 0 ? 'Check for outages or a hard verse.' : undefined,
|
||||
metric: 'completions',
|
||||
value: anomaly.value,
|
||||
target: anomaly.median
|
||||
});
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
const SEVERITY_ORDER: Record<SignalSeverity, number> = { bad: 0, warn: 1, info: 2, good: 3 };
|
||||
|
||||
export function sortSignals(signals: Signal[]): Signal[] {
|
||||
return signals
|
||||
.slice()
|
||||
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
||||
}
|
||||
|
||||
/** One-line verdict for the Pulse banner. */
|
||||
export function verdictLine(signals: Signal[]): string {
|
||||
const top = sortSignals(signals)[0];
|
||||
if (!top) return 'No signals yet.';
|
||||
if (top.severity === 'good') return `All core metrics are on target. ${top.detail}`;
|
||||
if (top.severity === 'info') return top.detail;
|
||||
return top.detail;
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
import { and, eq, lte } from 'drizzle-orm';
|
||||
import { db as defaultDb } from '$lib/server/db';
|
||||
import {
|
||||
analyticsEvents,
|
||||
dailyCompletions,
|
||||
dailyVerses,
|
||||
statsSnapshot,
|
||||
user
|
||||
} from '$lib/server/db/schema';
|
||||
import type { StatsDb } from './db';
|
||||
import type {
|
||||
Activity,
|
||||
CompletionRow,
|
||||
DailyPoint,
|
||||
HabitData,
|
||||
HistogramBin,
|
||||
Kpis,
|
||||
PlayerState,
|
||||
SeriesPoint,
|
||||
SnapshotPayload,
|
||||
StatsSettings,
|
||||
VerseRow
|
||||
} from '$lib/types/stats';
|
||||
import { bibleBooks } from '$lib/types/bible';
|
||||
import { getSettings, hashSettings } from './settings';
|
||||
import { deriveSegments, countSegments } from './segments';
|
||||
import { evaluateSignals } from './signals';
|
||||
import {
|
||||
activePlayers,
|
||||
averageGuesses,
|
||||
bookCoverage,
|
||||
cohortTriangle,
|
||||
dailyCompletionsSeries,
|
||||
entropy,
|
||||
firstPlayDates,
|
||||
firstTryRate,
|
||||
groupDatesByPlayer,
|
||||
guessHistogram,
|
||||
netGrowth,
|
||||
newVsReturning,
|
||||
overallReturnRate,
|
||||
repeatIntervals,
|
||||
retentionByGuessBucket,
|
||||
streakSurvival,
|
||||
timeToSecondPlayHistogram,
|
||||
weekdayHourHeatmap
|
||||
} from './metrics';
|
||||
import { addDays, dateRange, dayDiff, startOfWeek, todayUtc } from './dates';
|
||||
|
||||
export type BuildKind = 'scheduled' | 'lazy' | 'reconstructed';
|
||||
|
||||
/**
|
||||
* Bump whenever the snapshot payload shape changes. Older snapshots are
|
||||
* rebuilt lazily so the UI never reads a stale shape.
|
||||
*/
|
||||
export const PAYLOAD_SCHEMA_VERSION = 2;
|
||||
|
||||
const RETENTION_WINDOW_DAYS = 180;
|
||||
const SERIES_DAYS = 90;
|
||||
const RECENT_KPI_DAYS = 30;
|
||||
|
||||
function endOfDay(dateStr: string): Date {
|
||||
return new Date(dateStr + 'T23:59:59.999Z');
|
||||
}
|
||||
|
||||
function round1(n: number): number {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
export interface LoadedData {
|
||||
completions: CompletionRow[];
|
||||
verses: VerseRow[];
|
||||
startedByDate: Map<string, number>;
|
||||
shareEvents: number;
|
||||
firstEventDate: string | null;
|
||||
signupDates: string[];
|
||||
}
|
||||
|
||||
/** Load all rows needed to build a snapshot as of `cutoff` (inclusive). */
|
||||
export async function loadStatsData(
|
||||
cutoff: string,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<LoadedData> {
|
||||
const cutoffDate = endOfDay(cutoff);
|
||||
|
||||
const completionRows = await database
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
date: dailyCompletions.date,
|
||||
guessCount: dailyCompletions.guessCount,
|
||||
guesses: dailyCompletions.guesses,
|
||||
completedAt: dailyCompletions.completedAt
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.where(and(lte(dailyCompletions.date, cutoff), lte(dailyCompletions.completedAt, cutoffDate)));
|
||||
|
||||
const verseRows = await database
|
||||
.select({ date: dailyVerses.date, bookId: dailyVerses.bookId })
|
||||
.from(dailyVerses)
|
||||
.where(lte(dailyVerses.date, cutoff));
|
||||
|
||||
const eventRows = await database
|
||||
.select({
|
||||
name: analyticsEvents.name,
|
||||
date: analyticsEvents.date,
|
||||
createdAt: analyticsEvents.createdAt
|
||||
})
|
||||
.from(analyticsEvents)
|
||||
.where(lte(analyticsEvents.createdAt, cutoffDate));
|
||||
|
||||
const startedByDate = new Map<string, number>();
|
||||
let shareEvents = 0;
|
||||
let firstEventDate: string | null = null;
|
||||
for (const e of eventRows) {
|
||||
const eventDate = e.date ?? new Date(e.createdAt).toISOString().slice(0, 10);
|
||||
if (!firstEventDate || eventDate < firstEventDate) firstEventDate = eventDate;
|
||||
if (e.name === 'verse_served' && e.date && e.date <= cutoff) {
|
||||
startedByDate.set(e.date, (startedByDate.get(e.date) ?? 0) + 1);
|
||||
}
|
||||
if ((e.name === 'share' || e.name === 'share_copy') && eventDate <= cutoff) {
|
||||
shareEvents++;
|
||||
}
|
||||
}
|
||||
|
||||
const userRows = await database
|
||||
.select({ id: user.id, createdAt: user.createdAt })
|
||||
.from(user)
|
||||
.where(lte(user.createdAt, cutoffDate));
|
||||
|
||||
return {
|
||||
completions: completionRows as CompletionRow[],
|
||||
verses: verseRows,
|
||||
startedByDate,
|
||||
shareEvents,
|
||||
firstEventDate,
|
||||
signupDates: userRows
|
||||
.filter((u) => u.createdAt)
|
||||
.map((u) => new Date(u.createdAt as Date).toISOString().slice(0, 10))
|
||||
};
|
||||
}
|
||||
|
||||
/** Compact per-player state as of `asOfDate`, for players active in the window. */
|
||||
export function computePlayerState(
|
||||
completions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
activeWindowDays: number
|
||||
): PlayerState[] {
|
||||
const windowStart = addDays(asOfDate, -(activeWindowDays - 1));
|
||||
const sevenStart = addDays(asOfDate, -6);
|
||||
const thirtyStart = addDays(asOfDate, -29);
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const out: PlayerState[] = [];
|
||||
|
||||
for (const [id, dates] of datesByPlayer) {
|
||||
const inScope = dates.filter((d) => d <= asOfDate);
|
||||
if (inScope.length === 0) continue;
|
||||
const firstDate = inScope[0];
|
||||
const lastDate = inScope[inScope.length - 1];
|
||||
if (lastDate < windowStart) continue;
|
||||
|
||||
// Current consecutive-day streak ending at the last active day.
|
||||
let streak = 1;
|
||||
for (let i = inScope.length - 1; i > 0; i--) {
|
||||
if (dayDiff(inScope[i - 1], inScope[i]) === 1) streak++;
|
||||
else break;
|
||||
}
|
||||
|
||||
out.push({
|
||||
id,
|
||||
firstDate,
|
||||
lastDate,
|
||||
totalPlays: inScope.length,
|
||||
plays7: inScope.filter((d) => d >= sevenStart).length,
|
||||
plays30: inScope.filter((d) => d >= thirtyStart).length,
|
||||
streak
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Pooled return rate over a run of cohort days (more stable than single days). */
|
||||
function pooledReturn(
|
||||
completions: CompletionRow[],
|
||||
cohortEnd: string,
|
||||
cohortDays: number,
|
||||
n: number
|
||||
): number | null {
|
||||
const firsts = firstPlayDates(completions);
|
||||
const datesByPlayer = groupDatesByPlayer(completions);
|
||||
const start = addDays(cohortEnd, -(cohortDays - 1));
|
||||
let cohort = 0;
|
||||
let returned = 0;
|
||||
for (const [id, first] of firsts) {
|
||||
if (first < start || first > cohortEnd) continue;
|
||||
cohort++;
|
||||
const limit = addDays(first, n);
|
||||
const dates = datesByPlayer.get(id) ?? [];
|
||||
if (dates.some((d) => d > first && d <= limit)) returned++;
|
||||
}
|
||||
if (cohort === 0) return null;
|
||||
return round1((returned / cohort) * 100);
|
||||
}
|
||||
|
||||
function buildActivity(
|
||||
pointCompletions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
settings: StatsSettings
|
||||
): Activity {
|
||||
const dau = activePlayers(pointCompletions, asOfDate, 1);
|
||||
const wau = activePlayers(pointCompletions, asOfDate, 7);
|
||||
const mau = activePlayers(pointCompletions, asOfDate, 30);
|
||||
const { returningShare } = newVsReturning(pointCompletions, asOfDate);
|
||||
const growth = netGrowth(pointCompletions, asOfDate, 7, settings.resurrect_gap_days);
|
||||
|
||||
// Core count current + one week earlier, both with current thresholds.
|
||||
const core = deriveSegments(
|
||||
computePlayerState(pointCompletions, asOfDate, settings.active_window_days),
|
||||
asOfDate,
|
||||
settings
|
||||
).core.length;
|
||||
const prevDate = addDays(asOfDate, -7);
|
||||
const corePrevious = deriveSegments(
|
||||
computePlayerState(
|
||||
pointCompletions.filter((c) => c.date <= prevDate),
|
||||
prevDate,
|
||||
settings.active_window_days
|
||||
),
|
||||
prevDate,
|
||||
settings
|
||||
).core.length;
|
||||
|
||||
const prev = addDays(asOfDate, -7);
|
||||
const previous = {
|
||||
dau: activePlayers(pointCompletions, prev, 1),
|
||||
wau: activePlayers(pointCompletions, prev, 7),
|
||||
mau: activePlayers(pointCompletions, prev, 30),
|
||||
core: corePrevious
|
||||
};
|
||||
|
||||
return {
|
||||
dau,
|
||||
wau,
|
||||
mau,
|
||||
core,
|
||||
returningShare,
|
||||
newPlayers: growth.newPlayers,
|
||||
resurrected: growth.resurrected,
|
||||
churned: growth.churned,
|
||||
netGrowth: growth.netGrowth,
|
||||
corePrevious,
|
||||
previous
|
||||
};
|
||||
}
|
||||
|
||||
function buildKpis(
|
||||
pointCompletions: CompletionRow[],
|
||||
data: LoadedData,
|
||||
asOfDate: string
|
||||
): Kpis {
|
||||
const recentStart = addDays(asOfDate, -(RECENT_KPI_DAYS - 1));
|
||||
const recent = pointCompletions.filter((c) => c.date >= recentStart);
|
||||
|
||||
const shares = data.shareEvents;
|
||||
const completionsInRecent = recent.length;
|
||||
|
||||
// Completion rate: only meaningful once verse_served events exist.
|
||||
let started = 0;
|
||||
for (const [date, count] of data.startedByDate) {
|
||||
if (date >= recentStart && date <= asOfDate) started += count;
|
||||
}
|
||||
const completionRate = started > 0 ? round1((completionsInRecent / started) * 100) : null;
|
||||
const shareRate = shares > 0 && completionsInRecent > 0
|
||||
? round1((shares / completionsInRecent) * 100)
|
||||
: null;
|
||||
|
||||
return {
|
||||
// Most recent resolvable cohorts (as-of yesterday can't resolve its own D1).
|
||||
d1Return: pooledReturn(pointCompletions, addDays(asOfDate, -1), 7, 1),
|
||||
return7: pooledReturn(pointCompletions, addDays(asOfDate, -7), 7, 7),
|
||||
overallReturnRate: overallReturnRate(pointCompletions),
|
||||
completionRate,
|
||||
shareRate,
|
||||
firstTryRate: firstTryRate(recent),
|
||||
avgGuesses: averageGuesses(recent)
|
||||
};
|
||||
}
|
||||
|
||||
function buildCurves(
|
||||
pointCompletions: CompletionRow[],
|
||||
allCompletions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
n: number,
|
||||
matured: boolean
|
||||
): SeriesPoint[] {
|
||||
const series: SeriesPoint[] = [];
|
||||
// Only cohorts whose window has resolved by `asOfDate` are point-in-time
|
||||
// honest; for matured builds the effective end is today.
|
||||
const effectiveEnd = matured ? todayUtc() : asOfDate;
|
||||
const latest = addDays(effectiveEnd, -n);
|
||||
const start = addDays(latest, -29);
|
||||
const firsts = firstPlayDates(pointCompletions);
|
||||
const datesByPlayer = groupDatesByPlayer(allCompletions);
|
||||
for (const date of dateRange(start, latest)) {
|
||||
const limit = addDays(date, n);
|
||||
let cohort = 0;
|
||||
let returned = 0;
|
||||
for (const [id, first] of firsts) {
|
||||
if (first !== date) continue;
|
||||
cohort++;
|
||||
const dates = datesByPlayer.get(id) ?? [];
|
||||
if (dates.some((d) => d > date && d <= limit)) returned++;
|
||||
}
|
||||
series.push({ date, value: cohort >= 3 ? round1((returned / cohort) * 100) : null });
|
||||
}
|
||||
return series;
|
||||
}
|
||||
|
||||
function buildHabit(pointCompletions: CompletionRow[], players: PlayerState[], asOfDate: string): HabitData {
|
||||
const calendarStart = addDays(asOfDate, -119);
|
||||
const calendarCounts = new Map<string, number>();
|
||||
for (const c of pointCompletions) {
|
||||
if (c.date < calendarStart || c.date > asOfDate) continue;
|
||||
calendarCounts.set(c.date, (calendarCounts.get(c.date) ?? 0) + 1);
|
||||
}
|
||||
const calendar = dateRange(calendarStart, asOfDate).map((date) => ({
|
||||
date,
|
||||
count: calendarCounts.get(date) ?? 0
|
||||
}));
|
||||
|
||||
// Current-streak distribution, bucketed.
|
||||
const buckets = new Map<string, number>([
|
||||
['1', 0],
|
||||
['2', 0],
|
||||
['3–4', 0],
|
||||
['5–6', 0],
|
||||
['7–13', 0],
|
||||
['14+', 0]
|
||||
]);
|
||||
for (const p of players) {
|
||||
if (p.streak <= 1) continue;
|
||||
const label =
|
||||
p.streak === 2
|
||||
? '2'
|
||||
: p.streak <= 4
|
||||
? '3–4'
|
||||
: p.streak <= 6
|
||||
? '5–6'
|
||||
: p.streak <= 13
|
||||
? '7–13'
|
||||
: '14+';
|
||||
buckets.set(label, (buckets.get(label) ?? 0) + 1);
|
||||
}
|
||||
const streakLengths: HistogramBin[] = [...buckets.entries()].map(([label, value]) => ({
|
||||
label,
|
||||
value
|
||||
}));
|
||||
|
||||
return {
|
||||
streakSurvival: streakSurvival(pointCompletions),
|
||||
streakLengths,
|
||||
calendar,
|
||||
weekdayHour: weekdayHourHeatmap(pointCompletions)
|
||||
};
|
||||
}
|
||||
|
||||
function buildSegmentMix(
|
||||
pointCompletions: CompletionRow[],
|
||||
asOfDate: string,
|
||||
settings: StatsSettings
|
||||
): SnapshotPayload['retention']['segmentMix'] {
|
||||
const start = addDays(asOfDate, -29);
|
||||
const out: SnapshotPayload['retention']['segmentMix'] = [];
|
||||
for (const date of dateRange(start, asOfDate)) {
|
||||
const point = pointCompletions.filter((c) => c.date <= date);
|
||||
const players = computePlayerState(point, date, settings.active_window_days);
|
||||
const counts = countSegments(deriveSegments(players, date, settings));
|
||||
out.push({
|
||||
date,
|
||||
new: counts.new,
|
||||
core: counts.core,
|
||||
returning: counts.returning,
|
||||
resurrected: counts.resurrected,
|
||||
atRisk: counts.atRisk,
|
||||
churned: counts.churned
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildGrowth(
|
||||
data: LoadedData,
|
||||
players: PlayerState[],
|
||||
asOfDate: string
|
||||
): SnapshotPayload['growth'] {
|
||||
const thresholds = [1, 2, 5, 10, 25, 50];
|
||||
const lifecycleFunnel = thresholds.map((t) => ({
|
||||
label: `${t}+`,
|
||||
players: players.filter((p) => p.totalPlays >= t).length
|
||||
}));
|
||||
const start = addDays(asOfDate, -89);
|
||||
const counts = new Map<string, number>();
|
||||
for (const d of data.signupDates) {
|
||||
if (d >= start && d <= asOfDate) counts.set(d, (counts.get(d) ?? 0) + 1);
|
||||
}
|
||||
const signups = dateRange(start, asOfDate).map((date) => ({
|
||||
date,
|
||||
count: counts.get(date) ?? 0
|
||||
}));
|
||||
return { lifecycleFunnel, signups };
|
||||
}
|
||||
|
||||
function buildVerseMix(
|
||||
pointCompletions: CompletionRow[],
|
||||
verses: VerseRow[],
|
||||
asOfDate: string
|
||||
): SnapshotPayload['verseMix'] {
|
||||
const mixStart = addDays(asOfDate, -179);
|
||||
const inRangeVerses = verses.filter((v) => v.date >= mixStart && v.date <= asOfDate);
|
||||
const oldTestament = new Set(
|
||||
bibleBooks.filter((b) => b.testament === 'old').map((b) => b.id)
|
||||
);
|
||||
const coverage = bookCoverage(inRangeVerses, pointCompletions, {
|
||||
start: mixStart,
|
||||
end: asOfDate
|
||||
});
|
||||
|
||||
// Weekly testament mix + book-distribution entropy.
|
||||
const weeksBack = 12;
|
||||
const weekStart = startOfWeek(addDays(asOfDate, -(weeksBack - 1) * 7));
|
||||
const testamentMix: { date: string; old: number; new: number }[] = [];
|
||||
const entropySeries: SeriesPoint[] = [];
|
||||
for (let w = 0; w < weeksBack; w++) {
|
||||
const wStart = addDays(weekStart, w * 7);
|
||||
const wEnd = addDays(wStart, 6);
|
||||
const rows = inRangeVerses.filter((v) => v.date >= wStart && v.date <= wEnd);
|
||||
if (rows.length === 0) {
|
||||
testamentMix.push({ date: wStart, old: 0, new: 0 });
|
||||
entropySeries.push({ date: wStart, value: null });
|
||||
continue;
|
||||
}
|
||||
const bookCounts = new Map<string, number>();
|
||||
let old = 0;
|
||||
let nt = 0;
|
||||
for (const v of rows) {
|
||||
bookCounts.set(v.bookId, (bookCounts.get(v.bookId) ?? 0) + 1);
|
||||
if (oldTestament.has(v.bookId)) old++;
|
||||
else nt++;
|
||||
}
|
||||
testamentMix.push({ date: wStart, old, new: nt });
|
||||
entropySeries.push({ date: wStart, value: entropy([...bookCounts.values()]) });
|
||||
}
|
||||
|
||||
const firstTryByBook = coverage
|
||||
.filter((b) => b.plays >= 3)
|
||||
.sort((a, b) => b.plays - a.plays);
|
||||
|
||||
return {
|
||||
coverage,
|
||||
testamentMix,
|
||||
entropy: entropySeries,
|
||||
repeatIntervals: repeatIntervals(verses),
|
||||
firstTryByBook,
|
||||
retentionByBook: coverage.filter((b) => b.d1Return !== null),
|
||||
retentionByGuessBucket: retentionByGuessBucket(pointCompletions),
|
||||
achievability: guessHistogram(pointCompletions.filter((c) => c.date >= addDays(asOfDate, -29)))
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComputeOptions {
|
||||
/** Data cutoff for retention resolution (defaults to asOfDate). */
|
||||
matureThrough?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a full snapshot payload. Pure with respect to I/O: everything it
|
||||
* needs is passed in, which keeps the point-in-time cutoff testable.
|
||||
*/
|
||||
export function computePayload(
|
||||
asOfDate: string,
|
||||
settings: StatsSettings,
|
||||
data: LoadedData,
|
||||
opts: ComputeOptions = {}
|
||||
): SnapshotPayload {
|
||||
const pointCompletions = data.completions.filter((c) => c.date <= asOfDate);
|
||||
// Retention uses everything loaded: identical for point-in-time builds, and
|
||||
// extends past `asOfDate` for matured builds.
|
||||
const allCompletions = data.completions;
|
||||
const matured = !!opts.matureThrough && opts.matureThrough > asOfDate;
|
||||
|
||||
const activity = buildActivity(pointCompletions, asOfDate, settings);
|
||||
const kpis = buildKpis(pointCompletions, data, asOfDate);
|
||||
|
||||
const seriesStart = addDays(asOfDate, -(SERIES_DAYS - 1));
|
||||
const baseSeries = dailyCompletionsSeries(pointCompletions, seriesStart, asOfDate);
|
||||
const dailySeries: DailyPoint[] = baseSeries.map((d) => ({
|
||||
...d,
|
||||
started: data.startedByDate.get(d.date) ?? 0
|
||||
}));
|
||||
|
||||
const players = computePlayerState(pointCompletions, asOfDate, settings.active_window_days);
|
||||
|
||||
const cohortStart = startOfWeek(addDays(asOfDate, -11 * 7));
|
||||
const cohorts = cohortTriangle(allCompletions, {
|
||||
start: cohortStart,
|
||||
weeks: 12,
|
||||
asOf: matured ? todayUtc() : asOfDate,
|
||||
minSize: 3
|
||||
});
|
||||
const retention: SnapshotPayload['retention'] = {
|
||||
cohorts,
|
||||
curves: {
|
||||
d1: buildCurves(pointCompletions, allCompletions, asOfDate, 1, matured),
|
||||
d7: buildCurves(pointCompletions, allCompletions, asOfDate, 7, matured),
|
||||
d30: buildCurves(pointCompletions, allCompletions, asOfDate, 30, matured)
|
||||
},
|
||||
timeToSecondPlay: timeToSecondPlayHistogram(allCompletions),
|
||||
segmentMix: buildSegmentMix(pointCompletions, asOfDate, settings)
|
||||
};
|
||||
|
||||
const growth = buildGrowth(data, players, asOfDate);
|
||||
const verseMix = buildVerseMix(pointCompletions, data.verses, asOfDate);
|
||||
const habit = buildHabit(pointCompletions, players, asOfDate);
|
||||
const yesterday = pointCompletions.filter((c) => c.date === asOfDate);
|
||||
const totals = {
|
||||
completions: pointCompletions.length,
|
||||
players: firstPlayDates(pointCompletions).size,
|
||||
yesterdayCompletions: yesterday.length,
|
||||
yesterdayAvgGuesses: averageGuesses(yesterday)
|
||||
};
|
||||
|
||||
const payload: SnapshotPayload = {
|
||||
asOfDate,
|
||||
activity,
|
||||
dailySeries,
|
||||
kpis,
|
||||
retention,
|
||||
verseMix,
|
||||
habit,
|
||||
growth,
|
||||
totals,
|
||||
signals: [],
|
||||
meta: {
|
||||
hasStartedData: data.startedByDate.size > 0,
|
||||
hasShareData: data.shareEvents > 0,
|
||||
firstEventDate: data.firstEventDate,
|
||||
generatedAt: Date.now(),
|
||||
schemaVersion: PAYLOAD_SCHEMA_VERSION
|
||||
}
|
||||
};
|
||||
|
||||
payload.signals = evaluateSignals(payload, settings);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export interface StoredSnapshot {
|
||||
date: string;
|
||||
generatedAt: Date;
|
||||
buildKind: BuildKind;
|
||||
configVersion: string;
|
||||
payload: SnapshotPayload;
|
||||
config: StatsSettings;
|
||||
playerState: PlayerState[];
|
||||
}
|
||||
|
||||
function parseRow(row: typeof statsSnapshot.$inferSelect): StoredSnapshot {
|
||||
return {
|
||||
date: row.date,
|
||||
generatedAt: row.generatedAt,
|
||||
buildKind: row.buildKind as BuildKind,
|
||||
configVersion: row.configVersion,
|
||||
payload: JSON.parse(row.payload) as SnapshotPayload,
|
||||
config: JSON.parse(row.config) as StatsSettings,
|
||||
playerState: row.playerState ? (JSON.parse(row.playerState) as PlayerState[]) : []
|
||||
};
|
||||
}
|
||||
|
||||
export async function getSnapshot(
|
||||
date: string,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<StoredSnapshot | null> {
|
||||
const [row] = await database.select().from(statsSnapshot).where(eq(statsSnapshot.date, date));
|
||||
return row ? parseRow(row) : null;
|
||||
}
|
||||
|
||||
export interface BuildResult {
|
||||
snapshot: StoredSnapshot;
|
||||
settings: StatsSettings;
|
||||
buildKind: BuildKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and persist a snapshot. `replace=false` never overwrites an existing
|
||||
* (immutable) snapshot; the caller re-reads instead.
|
||||
*/
|
||||
export async function buildSnapshot(
|
||||
asOfDate: string,
|
||||
opts: { kind: BuildKind; replace?: boolean; matureThrough?: string; persist?: boolean },
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<BuildResult> {
|
||||
const settings = await getSettings(database);
|
||||
const version = hashSettings(settings);
|
||||
const cutoff = opts.matureThrough ?? asOfDate;
|
||||
const data = await loadStatsData(cutoff, database);
|
||||
const payload = computePayload(asOfDate, settings, data, {
|
||||
matureThrough: opts.matureThrough
|
||||
});
|
||||
const playerState = computePlayerState(
|
||||
data.completions.filter((c) => c.date <= asOfDate),
|
||||
asOfDate,
|
||||
settings.active_window_days
|
||||
);
|
||||
|
||||
const row = {
|
||||
date: asOfDate,
|
||||
generatedAt: new Date(),
|
||||
buildKind: opts.kind,
|
||||
configVersion: version,
|
||||
payload: JSON.stringify(payload),
|
||||
playerState: JSON.stringify(playerState),
|
||||
config: JSON.stringify(settings)
|
||||
};
|
||||
|
||||
if (opts.persist !== false) {
|
||||
if (opts.replace) {
|
||||
await database
|
||||
.insert(statsSnapshot)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: statsSnapshot.date, set: row });
|
||||
} else {
|
||||
await database.insert(statsSnapshot).values(row).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
snapshot: {
|
||||
date: asOfDate,
|
||||
generatedAt: row.generatedAt,
|
||||
buildKind: opts.kind,
|
||||
configVersion: version,
|
||||
payload,
|
||||
config: settings,
|
||||
playerState
|
||||
},
|
||||
settings,
|
||||
buildKind: opts.kind
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the snapshot for `date`, building it lazily if missing. Race-safe via
|
||||
* `INSERT OR IGNORE` followed by a re-read.
|
||||
*/
|
||||
export async function ensureSnapshot(
|
||||
date: string,
|
||||
opts: { matureThrough?: string } = {},
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<StoredSnapshot> {
|
||||
const existing = await getSnapshot(date, database);
|
||||
if (existing && existing.payload.meta?.schemaVersion === PAYLOAD_SCHEMA_VERSION) {
|
||||
return existing;
|
||||
}
|
||||
// Missing or built with an older payload shape: (re)build deterministically
|
||||
// for this point in time. The point-in-time cutoff keeps it faithful.
|
||||
await buildSnapshot(
|
||||
date,
|
||||
{ kind: 'lazy', matureThrough: opts.matureThrough, replace: !!existing },
|
||||
database
|
||||
);
|
||||
const stored = await getSnapshot(date, database);
|
||||
if (!stored) throw new Error(`Failed to build snapshot for ${date}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/** True when a stored snapshot was built with different thresholds or shape. */
|
||||
export async function isSnapshotStale(
|
||||
date: string,
|
||||
database: StatsDb = defaultDb
|
||||
): Promise<boolean> {
|
||||
const snapshot = await getSnapshot(date, database);
|
||||
if (!snapshot) return false;
|
||||
if (snapshot.payload.meta?.schemaVersion !== PAYLOAD_SCHEMA_VERSION) return true;
|
||||
const current = hashSettings(await getSettings(database));
|
||||
return snapshot.configVersion !== current;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Shared types for the private statistics dashboard (see statistics-plan.md).
|
||||
*
|
||||
* Keep this module dependency-free (no DB / Svelte imports) so it can be used
|
||||
* from server code, Svelte components, and unit tests alike.
|
||||
*/
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dashboard thresholds. Keys are snake_case to match both the
|
||||
* `dashboard_settings` table and the `STATS_*` environment variables.
|
||||
*/
|
||||
export const SETTINGS_DEFAULTS = {
|
||||
core_plays_per_week: 4,
|
||||
churn_days: 30,
|
||||
resurrect_gap_days: 30,
|
||||
at_risk_days: 8,
|
||||
active_window_days: 180,
|
||||
d1_target: 20,
|
||||
return7_target: 25,
|
||||
completion_target: 70,
|
||||
first_try_min: 15,
|
||||
first_try_max: 35,
|
||||
share_target: 25,
|
||||
anomaly_mad_threshold: 2
|
||||
} as const;
|
||||
|
||||
export type SettingsKey = keyof typeof SETTINGS_DEFAULTS;
|
||||
|
||||
export type StatsSettings = Record<SettingsKey, number>;
|
||||
|
||||
// ── Plain rows (pure metric inputs) ──────────────────────────────────────────
|
||||
|
||||
/** The subset of a `daily_completions` row the metrics need. */
|
||||
export interface CompletionRow {
|
||||
anonymousId: string;
|
||||
date: string;
|
||||
guessCount: number;
|
||||
guesses?: string | null;
|
||||
completedAt: Date | number;
|
||||
}
|
||||
|
||||
export interface VerseRow {
|
||||
date: string;
|
||||
bookId: string;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
id: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
// ── Player state & segments ──────────────────────────────────────────────────
|
||||
|
||||
/** Compact per-player state frozen as of a snapshot date. */
|
||||
export interface PlayerState {
|
||||
id: string;
|
||||
firstDate: string;
|
||||
lastDate: string;
|
||||
totalPlays: number;
|
||||
plays7: number;
|
||||
plays30: number;
|
||||
streak: number;
|
||||
}
|
||||
|
||||
export interface SegmentSets {
|
||||
new: string[];
|
||||
core: string[];
|
||||
returning: string[];
|
||||
resurrected: string[];
|
||||
atRisk: string[];
|
||||
churned: string[];
|
||||
active: string[];
|
||||
}
|
||||
|
||||
export interface SegmentCounts {
|
||||
new: number;
|
||||
core: number;
|
||||
returning: number;
|
||||
resurrected: number;
|
||||
atRisk: number;
|
||||
churned: number;
|
||||
active: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// ── Signals ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type SignalSeverity = 'good' | 'info' | 'warn' | 'bad';
|
||||
|
||||
export interface Signal {
|
||||
id: string;
|
||||
severity: SignalSeverity;
|
||||
title: string;
|
||||
detail: string;
|
||||
action?: string;
|
||||
metric?: string;
|
||||
value?: number | null;
|
||||
target?: number | null;
|
||||
}
|
||||
|
||||
// ── Payload ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface DailyPoint {
|
||||
date: string;
|
||||
completions: number;
|
||||
newPlayers: number;
|
||||
started: number;
|
||||
}
|
||||
|
||||
export interface SeriesPoint {
|
||||
date: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface CohortRow {
|
||||
weekStart: string;
|
||||
size: number;
|
||||
/** Index 0 = week 0 (the cohort week); index k = k weeks later. */
|
||||
counts: number[];
|
||||
rates: (number | null)[];
|
||||
}
|
||||
|
||||
export interface HistogramBin {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BookStat {
|
||||
bookId: string;
|
||||
days: number;
|
||||
plays: number;
|
||||
firstTryPct: number;
|
||||
avgGuesses: number;
|
||||
d1Return: number | null;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
dau: number;
|
||||
wau: number;
|
||||
mau: number;
|
||||
core: number;
|
||||
returningShare: number | null;
|
||||
newPlayers: number;
|
||||
resurrected: number;
|
||||
churned: number;
|
||||
netGrowth: number;
|
||||
/** Core count from the snapshot ~7 days earlier (same thresholds), if available. */
|
||||
corePrevious: number | null;
|
||||
/** Same-metric values for the previous day (weekday-aligned comparison). */
|
||||
previous: {
|
||||
dau: number;
|
||||
wau: number;
|
||||
mau: number;
|
||||
core: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Kpis {
|
||||
d1Return: number | null;
|
||||
return7: number | null;
|
||||
overallReturnRate: number | null;
|
||||
completionRate: number | null;
|
||||
shareRate: number | null;
|
||||
firstTryRate: number | null;
|
||||
avgGuesses: number | null;
|
||||
}
|
||||
|
||||
export interface RetentionData {
|
||||
cohorts: CohortRow[];
|
||||
curves: {
|
||||
d1: SeriesPoint[];
|
||||
d7: SeriesPoint[];
|
||||
d30: SeriesPoint[];
|
||||
};
|
||||
timeToSecondPlay: HistogramBin[];
|
||||
segmentMix: {
|
||||
date: string;
|
||||
new: number;
|
||||
core: number;
|
||||
returning: number;
|
||||
resurrected: number;
|
||||
atRisk: number;
|
||||
churned: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface VerseMixData {
|
||||
coverage: BookStat[];
|
||||
testamentMix: { date: string; old: number; new: number }[];
|
||||
entropy: SeriesPoint[];
|
||||
repeatIntervals: HistogramBin[];
|
||||
firstTryByBook: BookStat[];
|
||||
retentionByBook: BookStat[];
|
||||
retentionByGuessBucket: { bucket: string; d1: number | null; plays: number }[];
|
||||
achievability: HistogramBin[];
|
||||
}
|
||||
|
||||
export interface HabitData {
|
||||
streakSurvival: { length: number; probability: number }[];
|
||||
streakLengths: HistogramBin[];
|
||||
calendar: { date: string; count: number }[];
|
||||
weekdayHour: number[][];
|
||||
}
|
||||
|
||||
export interface SnapshotPayload {
|
||||
asOfDate: string;
|
||||
activity: Activity;
|
||||
dailySeries: DailyPoint[];
|
||||
kpis: Kpis;
|
||||
retention: RetentionData;
|
||||
verseMix: VerseMixData;
|
||||
habit: HabitData;
|
||||
growth: {
|
||||
lifecycleFunnel: { label: string; players: number }[];
|
||||
signups: { date: string; count: number }[];
|
||||
};
|
||||
/** Safe, all-time aggregates for the public community widget. */
|
||||
totals: {
|
||||
completions: number;
|
||||
players: number;
|
||||
yesterdayCompletions: number;
|
||||
yesterdayAvgGuesses: number | null;
|
||||
};
|
||||
signals: Signal[];
|
||||
/** Metadata about data availability, so the UI can show honest em-dashes. */
|
||||
meta: {
|
||||
hasStartedData: boolean;
|
||||
hasShareData: boolean;
|
||||
firstEventDate: string | null;
|
||||
generatedAt?: number;
|
||||
/** Payload shape version; older snapshots are rebuilt lazily. */
|
||||
schemaVersion?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Analytics events ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Appendix B — allowlisted event names. */
|
||||
export const EVENT_NAMES = [
|
||||
'verse_served',
|
||||
'share',
|
||||
'share_copy',
|
||||
'first_visit',
|
||||
'donate_click',
|
||||
'discord_click'
|
||||
] as const;
|
||||
|
||||
export type EventName = (typeof EVENT_NAMES)[number];
|
||||
|
||||
export const MAX_METADATA_BYTES = 2048;
|
||||
@@ -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'))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { generateUUID } from './game';
|
||||
import { parseAcquisition, type Acquisition } from './acquisition';
|
||||
import type { EventName } from '$lib/types/stats';
|
||||
|
||||
export { parseAcquisition };
|
||||
export type { Acquisition };
|
||||
|
||||
const ANON_KEY = 'bibdle-anonymous-id';
|
||||
const FIRST_VISIT_KEY = 'bibdle-first-visit-sent';
|
||||
|
||||
/** Stable pseudonymous ID for this browser, created on first use. */
|
||||
export function getOrCreateAnonymousId(): string {
|
||||
if (!browser) return '';
|
||||
let id = localStorage.getItem(ANON_KEY);
|
||||
if (!id) {
|
||||
id = generateUUID();
|
||||
localStorage.setItem(ANON_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget event. Never throws and never blocks the UI.
|
||||
* `date` should be the game date for date-scoped events (e.g. verse_served).
|
||||
*/
|
||||
export function track(
|
||||
name: EventName,
|
||||
metadata?: Record<string, unknown> | null,
|
||||
date?: string | null
|
||||
): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
name,
|
||||
anonymousId: getOrCreateAnonymousId(),
|
||||
date: date ?? null,
|
||||
metadata: metadata ?? null
|
||||
});
|
||||
void fetch('/api/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
keepalive: true
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a single `first_visit` event per browser with referrer / UTM data.
|
||||
* Call once on mount (root layout).
|
||||
*/
|
||||
export function captureFirstVisit(): void {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (localStorage.getItem(FIRST_VISIT_KEY) === 'true') return;
|
||||
const acquisition = parseAcquisition(location.search, document.referrer ?? '');
|
||||
track('first_visit', { ...acquisition });
|
||||
localStorage.setItem(FIRST_VISIT_KEY, 'true');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import favicon from "$lib/assets/favicon.ico";
|
||||
import TitleAnimation from "$lib/components/TitleAnimation.svelte";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { captureFirstVisit } from "$lib/utils/analytics";
|
||||
|
||||
let isDev = $state(false);
|
||||
|
||||
@@ -14,6 +15,9 @@
|
||||
window.location.host === "localhost:5173" ||
|
||||
window.location.host === "test.bibdle.com";
|
||||
|
||||
// Instrumentation E: capture first-touch referrer / UTM once per browser.
|
||||
captureFirstVisit();
|
||||
|
||||
// Inject analytics script
|
||||
const script = document.createElement("script");
|
||||
script.defer = true;
|
||||
@@ -31,12 +35,6 @@
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="Bibdle RSS Feed"
|
||||
href="/feed.xml"
|
||||
/>
|
||||
<meta name="description" content="A Wordle-inspired daily Bible game" />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -18,6 +18,18 @@ export const actions: Actions = {
|
||||
const anonymousId = formData.get('anonymousId') as string;
|
||||
const date = formData.get('date') as string;
|
||||
const guessCount = parseInt(formData.get('guessCount') as string, 10);
|
||||
// Optional JSON array of book IDs — mirrors the JSON API so all guest
|
||||
// completions persist guess sequences (instrumentation A).
|
||||
const guessesRaw = formData.get('guesses') as string | null;
|
||||
let guesses: string[] | null = null;
|
||||
if (guessesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(guessesRaw);
|
||||
if (Array.isArray(parsed)) guesses = parsed.map(String);
|
||||
} catch {
|
||||
guesses = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!anonymousId || !date || isNaN(guessCount) || guessCount < 1) {
|
||||
@@ -33,6 +45,7 @@ export const actions: Actions = {
|
||||
anonymousId,
|
||||
date,
|
||||
guessCount,
|
||||
guesses: guesses && guesses.length > 0 ? JSON.stringify(guesses) : null,
|
||||
completedAt,
|
||||
});
|
||||
} catch (err: any) {
|
||||
|
||||
+3
-1
@@ -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();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { isAdmin } from '$lib/server/admin';
|
||||
import { addDays, isValidDate } from '$lib/server/stats/dates';
|
||||
import {
|
||||
buildSnapshot,
|
||||
ensureSnapshot,
|
||||
isSnapshotStale
|
||||
} from '$lib/server/stats/snapshot';
|
||||
import { deriveSegments, countSegments } from '$lib/server/stats/segments';
|
||||
import { getSettings, updateSettings, hashSettings } from '$lib/server/stats/settings';
|
||||
import { sortSignals, verdictLine } from '$lib/server/stats/signals';
|
||||
import { SETTINGS_DEFAULTS, type SettingsKey, type StatsSettings } from '$lib/types/stats';
|
||||
|
||||
function todayEst(): string {
|
||||
return new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (!isAdmin(locals.user?.email)) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const today = todayEst();
|
||||
const yesterday = addDays(today, -1);
|
||||
const dateParam = url.searchParams.get('date');
|
||||
let date = dateParam && isValidDate(dateParam) ? dateParam : yesterday;
|
||||
if (date > today) date = today;
|
||||
const matured = url.searchParams.get('mature') === '1';
|
||||
|
||||
const settings = await getSettings();
|
||||
|
||||
// Normal loads read (or lazily build) the immutable snapshot. The mature
|
||||
// toggle is the only path allowed to compute on demand from raw data.
|
||||
const snapshot = matured
|
||||
? (
|
||||
await buildSnapshot(date, {
|
||||
kind: 'lazy',
|
||||
matureThrough: today,
|
||||
persist: false
|
||||
})
|
||||
).snapshot
|
||||
: await ensureSnapshot(date);
|
||||
|
||||
const segments = deriveSegments(snapshot.playerState, date, settings);
|
||||
const stale =
|
||||
!matured && snapshot.configVersion !== hashSettings(settings);
|
||||
const topSignal = sortSignals(snapshot.payload.signals)[0] ?? null;
|
||||
|
||||
return {
|
||||
date,
|
||||
today,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
buildKind: snapshot.buildKind,
|
||||
payload: snapshot.payload,
|
||||
settings,
|
||||
segmentCounts: countSegments(segments),
|
||||
verdict: verdictLine(snapshot.payload.signals),
|
||||
topSignal,
|
||||
stale,
|
||||
matured,
|
||||
userEmail: locals.user?.email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
updateSettings: async ({ request, url, locals }) => {
|
||||
if (!isAdmin(locals.user?.email)) {
|
||||
return fail(403, { error: 'Forbidden' });
|
||||
}
|
||||
const form = await request.formData();
|
||||
const partial: Partial<StatsSettings> = {};
|
||||
for (const key of Object.keys(SETTINGS_DEFAULTS) as SettingsKey[]) {
|
||||
const raw = form.get(key);
|
||||
if (typeof raw === 'string' && raw !== '') {
|
||||
const value = Number(raw);
|
||||
if (Number.isFinite(value)) partial[key] = value;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateSettings(partial);
|
||||
} catch (err) {
|
||||
return fail(500, { error: String(err) });
|
||||
}
|
||||
// Segments are re-derived at render time, so no rebuild is needed.
|
||||
const date = url.searchParams.get('date');
|
||||
throw redirect(303, `/admin/stats${date ? `?date=${date}` : ''}`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ConsoleChrome from '$lib/components/stats/ConsoleChrome.svelte';
|
||||
import Pulse from '$lib/components/stats/Pulse.svelte';
|
||||
import GrowthLoop from '$lib/components/stats/GrowthLoop.svelte';
|
||||
import RetentionLab from '$lib/components/stats/RetentionLab.svelte';
|
||||
import VerseMixLab from '$lib/components/stats/VerseMixLab.svelte';
|
||||
import HabitLab from '$lib/components/stats/HabitLab.svelte';
|
||||
import SignalsFeed from '$lib/components/stats/SignalsFeed.svelte';
|
||||
import FeatureImpact from '$lib/components/stats/FeatureImpact.svelte';
|
||||
import Glossary from '$lib/components/stats/Glossary.svelte';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import { numOrDash } from '$lib/utils/stats-format';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const segments = $derived(data.segmentCounts);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Growth Console | Bibdle</title>
|
||||
<meta name="robots" content="noindex" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-gray-950 p-4 md:p-6">
|
||||
<div class="mx-auto flex max-w-6xl flex-col gap-8 pb-16">
|
||||
<ConsoleChrome
|
||||
date={data.date}
|
||||
today={data.today}
|
||||
generatedAt={data.generatedAt}
|
||||
buildKind={data.buildKind}
|
||||
matured={data.matured}
|
||||
stale={data.stale}
|
||||
settings={data.settings}
|
||||
/>
|
||||
|
||||
<Pulse
|
||||
payload={data.payload}
|
||||
settings={data.settings}
|
||||
verdict={data.verdict}
|
||||
topSignal={data.topSignal}
|
||||
/>
|
||||
|
||||
<section class="flex flex-col gap-3">
|
||||
<h2 class="text-lg font-bold text-gray-100">Segment mix (as of this date)</h2>
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{#each [
|
||||
{ label: 'New (7d)', value: segments.new },
|
||||
{ label: 'Core', value: segments.core },
|
||||
{ label: 'Returning', value: segments.returning },
|
||||
{ label: 'Resurrected', value: segments.resurrected },
|
||||
{ label: 'At risk', value: segments.atRisk },
|
||||
{ label: 'Churned', value: segments.churned }
|
||||
] as item (item.label)}
|
||||
<Container class="p-3 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-400">{item.label}</p>
|
||||
<p class="text-xl font-bold text-gray-100">{numOrDash(item.value)}</p>
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
Segments are re-derived from stored per-player state, so changing thresholds in Settings
|
||||
updates every date instantly. Total tracked players: {numOrDash(segments.total)}.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<GrowthLoop payload={data.payload} />
|
||||
<RetentionLab payload={data.payload} settings={data.settings} />
|
||||
<VerseMixLab payload={data.payload} />
|
||||
<HabitLab payload={data.payload} />
|
||||
<SignalsFeed signals={data.payload.signals} />
|
||||
<FeatureImpact />
|
||||
<Glossary />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { isAdmin } from '$lib/server/admin';
|
||||
import { isValidDate } from '$lib/server/stats/dates';
|
||||
import { buildSnapshot } from '$lib/server/stats/snapshot';
|
||||
|
||||
/** Admin-only manual snapshot (re)build, e.g. after a settings change. */
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!isAdmin(locals.user?.email)) {
|
||||
return json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
let body: { date?: string; mature?: boolean } = {};
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
/* fall through to validation */
|
||||
}
|
||||
|
||||
const date = body.date;
|
||||
if (!date || !isValidDate(date)) {
|
||||
return json({ error: 'A valid date (YYYY-MM-DD) is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { snapshot } = await buildSnapshot(date, {
|
||||
kind: 'reconstructed',
|
||||
replace: true
|
||||
});
|
||||
|
||||
return json({ ok: true, date, buildKind: snapshot.buildKind, generatedAt: snapshot.generatedAt });
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { addDays } from '$lib/server/stats/dates';
|
||||
import { buildSnapshot } from '$lib/server/stats/snapshot';
|
||||
import { sendDigest } from '$lib/server/stats/digest';
|
||||
|
||||
/**
|
||||
* Daily snapshot build trigger. Protected by `Authorization: Bearer $CRON_SECRET`
|
||||
* (same pattern as `/api/send-daily-verse`). Builds yesterday in `America/New_York`
|
||||
* and then sends the digest.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const cronSecret = Bun.env.CRON_SECRET;
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
const yesterday = addDays(todayEst, -1);
|
||||
|
||||
const { snapshot } = await buildSnapshot(yesterday, {
|
||||
kind: 'scheduled',
|
||||
replace: true
|
||||
});
|
||||
|
||||
// Digest failure must never fail the build.
|
||||
await sendDigest(snapshot.payload);
|
||||
|
||||
return json({ ok: true, date: yesterday, buildKind: 'scheduled' });
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { isEventName, recordEvent, encodeMetadata } from '$lib/server/stats/events';
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/**
|
||||
* Fire-and-forget client analytics sink.
|
||||
*
|
||||
* Body: `{ name, anonymousId?, date?, metadata? }`.
|
||||
* `userId` is resolved server-side from the session — never trusted from the client.
|
||||
* Always returns 204, even for invalid input, so gameplay is never blocked.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
let body: any;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const name = typeof body?.name === 'string' ? body.name : '';
|
||||
if (!isEventName(name)) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
const anonymousId =
|
||||
typeof body?.anonymousId === 'string' && body.anonymousId.length > 0
|
||||
? body.anonymousId.slice(0, 128)
|
||||
: null;
|
||||
|
||||
const date =
|
||||
typeof body?.date === 'string' && DATE_RE.test(body.date) ? body.date : null;
|
||||
|
||||
const metadata =
|
||||
body?.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata)
|
||||
? (body.metadata as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
// Ensure metadata is always serializable/capped before it reaches the DB.
|
||||
void encodeMetadata(metadata);
|
||||
|
||||
await recordEvent({
|
||||
name,
|
||||
anonymousId,
|
||||
userId: locals.user?.id ?? null,
|
||||
date,
|
||||
metadata
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
+69
-102
@@ -1,133 +1,99 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyVerses } from '$lib/server/db/schema';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
// Helper: Escape XML special characters
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Helper: Format YYYY-MM-DD to RFC 822 date string
|
||||
function formatRFC822(dateStr: string): string {
|
||||
// Parse date in America/New_York timezone (EST/EDT)
|
||||
// Assuming midnight ET
|
||||
const date = new Date(dateStr + 'T00:00:00-05:00');
|
||||
return date.toUTCString().replace('GMT', 'EST');
|
||||
}
|
||||
|
||||
// Helper: Format YYYY-MM-DD to readable date
|
||||
function formatReadableDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/New_York'
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format verse text (VerseDisplay + Imposter unbalanced punctuation handling)
|
||||
function formatVerseText(text: string): string {
|
||||
let formatted = text;
|
||||
|
||||
// Handle unbalanced opening/closing punctuation (from Imposter.svelte)
|
||||
const pairs: [string, string][] = [
|
||||
['(', ')'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
['\u201C', '\u201D'], // " "
|
||||
['\u2018', '\u2019'] // ' '
|
||||
];
|
||||
|
||||
// Check if text starts with opening punctuation without closing
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.startsWith(open) && !formatted.includes(close)) {
|
||||
formatted += '...' + close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text ends with closing punctuation without opening
|
||||
for (const [open, close] of pairs) {
|
||||
if (formatted.endsWith(close) && !formatted.includes(open)) {
|
||||
formatted = open + '...' + formatted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if text contains unbalanced opening quotes (not at start) without closing
|
||||
for (const [open, close] of pairs) {
|
||||
const openCount = (formatted.match(new RegExp(`\\${open}`, 'g')) || []).length;
|
||||
const closeCount = (formatted.match(new RegExp(`\\${close}`, 'g')) || []).length;
|
||||
if (openCount > closeCount) {
|
||||
formatted += close;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Capitalize first letter if lowercase (from VerseDisplay.svelte)
|
||||
formatted = formatted.replace(/^([a-z])/, (c) => c.toUpperCase());
|
||||
|
||||
// Replace trailing punctuation with ellipsis
|
||||
// Preserve closing quotes/brackets that may have been added
|
||||
formatted = formatted.replace(/[,:;-—]([)\]}\"\'\u201D\u2019]*)$/, '...$1');
|
||||
|
||||
return formatted;
|
||||
}
|
||||
import { desc, lte } from 'drizzle-orm';
|
||||
import {
|
||||
FEED_ITEM_LIMIT,
|
||||
cdata,
|
||||
escapeXml,
|
||||
feedFingerprint,
|
||||
formatRFC822,
|
||||
formatReadableDate,
|
||||
formatVerseText,
|
||||
latestModification,
|
||||
todayInTimeZone
|
||||
} from '$lib/server/rss';
|
||||
|
||||
export const GET: RequestHandler = async ({ request }) => {
|
||||
try {
|
||||
// Query last 30 verses, ordered by date descending
|
||||
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
||||
const FEED_URL = `${SITE_URL}/feed.xml`;
|
||||
const today = todayInTimeZone();
|
||||
|
||||
// Only publish verses whose calendar day has arrived. Pre-scheduled
|
||||
// future verses (from the submission scheduling scan) and any stray
|
||||
// future-dated rows must never leak into the feed early.
|
||||
const verses = await db
|
||||
.select()
|
||||
.from(dailyVerses)
|
||||
.where(lte(dailyVerses.date, today))
|
||||
.orderBy(desc(dailyVerses.date))
|
||||
.limit(30);
|
||||
.limit(FEED_ITEM_LIMIT);
|
||||
|
||||
// Generate ETag based on latest verse date
|
||||
const etag = verses[0]?.date ? `"bibdle-feed-${verses[0].date}"` : '"bibdle-feed-empty"';
|
||||
// Content-addressed ETag + Last-Modified let aggregators revalidate
|
||||
// cheaply with If-None-Match / If-Modified-Since.
|
||||
const etag = `"bibdle-feed-${feedFingerprint(verses)}"`;
|
||||
const lastModifiedDate = latestModification(
|
||||
verses,
|
||||
new Date(`${today}T00:00:00Z`)
|
||||
);
|
||||
const lastModified = lastModifiedDate.toUTCString();
|
||||
|
||||
// Check if client has cached version
|
||||
if (request.headers.get('If-None-Match') === etag) {
|
||||
return new Response(null, { status: 304 });
|
||||
const notModified =
|
||||
request.headers.get('If-None-Match') === etag ||
|
||||
(() => {
|
||||
const since = request.headers.get('If-Modified-Since');
|
||||
if (!since) return false;
|
||||
const sinceMs = Date.parse(since);
|
||||
return !Number.isNaN(sinceMs) && lastModifiedDate.getTime() <= sinceMs;
|
||||
})();
|
||||
|
||||
if (notModified) {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: {
|
||||
ETag: etag,
|
||||
'Last-Modified': lastModified,
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get site URL from environment or use default
|
||||
const SITE_URL = process.env.PUBLIC_SITE_URL || 'https://bibdle.com';
|
||||
|
||||
// Build RSS XML
|
||||
const lastBuildDate = verses[0] ? formatRFC822(verses[0].date) : new Date().toUTCString();
|
||||
|
||||
const items = verses
|
||||
.map(
|
||||
(verse) => `
|
||||
.map((verse) => {
|
||||
const body = formatVerseText(verse.verseText);
|
||||
const escaped = escapeXml(body);
|
||||
return `
|
||||
<item>
|
||||
<title>Bibdle verse for ${formatReadableDate(verse.date)}</title>
|
||||
<description>${escapeXml(formatVerseText(verse.verseText))}</description>
|
||||
<description>${escaped}</description>
|
||||
<content:encoded>${cdata(`<p>${escaped}</p>`)}</content:encoded>
|
||||
<link>${SITE_URL}</link>
|
||||
<guid isPermaLink="false">bibdle-verse-${verse.date}</guid>
|
||||
<pubDate>${formatRFC822(verse.date)}</pubDate>
|
||||
</item>`
|
||||
)
|
||||
</item>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0">
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>Bibdle</title>
|
||||
<link>${SITE_URL}</link>
|
||||
<description>A daily Bible game</description>
|
||||
<description>A daily Bible verse guessing game</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<ttl>720</ttl>${items}
|
||||
<pubDate>${lastBuildDate}</pubDate>
|
||||
<generator>SvelteKit</generator>
|
||||
<ttl>720</ttl>
|
||||
<atom:link href="${escapeXml(FEED_URL)}" rel="self" type="application/rss+xml" />
|
||||
<image>
|
||||
<url>${SITE_URL}/favicon.ico</url>
|
||||
<title>Bibdle</title>
|
||||
<link>${SITE_URL}</link>
|
||||
</image>${items}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
@@ -135,11 +101,12 @@ export const GET: RequestHandler = async ({ request }) => {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
|
||||
ETag: etag
|
||||
ETag: etag,
|
||||
'Last-Modified': lastModified
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('RSS feed generation error:', error);
|
||||
return new Response('Internal Server Error', { status: 500 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,484 +1,25 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { dailyCompletions, user } from '$lib/server/db/schema';
|
||||
import { eq, gte, count, countDistinct, avg, asc, min } from 'drizzle-orm';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { ensureSnapshot } from '$lib/server/stats/snapshot';
|
||||
import { addDays } from '$lib/server/stats/dates';
|
||||
|
||||
function estDateStr(daysAgo = 0): string {
|
||||
const estNow = new Date(Date.now() - 5 * 60 * 60 * 1000); // UTC-5
|
||||
estNow.setUTCDate(estNow.getUTCDate() - daysAgo);
|
||||
return estNow.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function prevDay(d: string): string {
|
||||
const dt = new Date(d + 'T00:00:00Z');
|
||||
dt.setUTCDate(dt.getUTCDate() - 1);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(d: string, n: number): string {
|
||||
const dt = new Date(d + 'T00:00:00Z');
|
||||
dt.setUTCDate(dt.getUTCDate() + n);
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim public community widget. Reads the latest daily snapshot and exposes
|
||||
* only safe aggregates (no retention, churn, signup or revenue data).
|
||||
*/
|
||||
export const load: PageServerLoad = async () => {
|
||||
const todayEst = estDateStr(0);
|
||||
const yesterdayEst = estDateStr(1);
|
||||
const sevenDaysAgo = estDateStr(7);
|
||||
|
||||
// Three weekly windows for first + second derivative calculations
|
||||
// Week A: last 7 days (indices 0–6)
|
||||
// Week B: 7–13 days ago (indices 7–13)
|
||||
// Week C: 14–20 days ago (indices 14–20)
|
||||
const weekAStart = estDateStr(6);
|
||||
const weekBEnd = estDateStr(7);
|
||||
const weekBStart = estDateStr(13);
|
||||
const weekCEnd = estDateStr(14);
|
||||
const weekCStart = estDateStr(20);
|
||||
|
||||
// ── Scalar stats ──────────────────────────────────────────────────────────
|
||||
|
||||
const [{ todayCount }] = await db
|
||||
.select({ todayCount: count() })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, todayEst));
|
||||
|
||||
const [{ totalCount }] = await db
|
||||
.select({ totalCount: count() })
|
||||
.from(dailyCompletions);
|
||||
|
||||
const [{ uniquePlayers }] = await db
|
||||
.select({ uniquePlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||
.from(dailyCompletions);
|
||||
|
||||
const [{ weeklyPlayers }] = await db
|
||||
.select({ weeklyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, sevenDaysAgo));
|
||||
|
||||
const thirtyDaysAgo = estDateStr(30);
|
||||
const [{ monthlyPlayers }] = await db
|
||||
.select({ monthlyPlayers: countDistinct(dailyCompletions.anonymousId) })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, thirtyDaysAgo));
|
||||
|
||||
const todayPlayers = await db
|
||||
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, todayEst));
|
||||
|
||||
const yesterdayPlayers = await db
|
||||
.selectDistinct({ id: dailyCompletions.anonymousId })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, yesterdayEst));
|
||||
|
||||
const todaySet = new Set(todayPlayers.map((r) => r.id));
|
||||
const activeStreaks = yesterdayPlayers.filter((r) => todaySet.has(r.id)).length;
|
||||
|
||||
const [{ avgGuessesRaw }] = await db
|
||||
.select({ avgGuessesRaw: avg(dailyCompletions.guessCount) })
|
||||
.from(dailyCompletions)
|
||||
.where(eq(dailyCompletions.date, todayEst));
|
||||
|
||||
const avgGuessesToday = avgGuessesRaw != null ? parseFloat(avgGuessesRaw) : null;
|
||||
|
||||
const [{ registeredUsers }] = await db
|
||||
.select({ registeredUsers: count() })
|
||||
.from(user);
|
||||
|
||||
const avgCompletionsPerPlayer =
|
||||
uniquePlayers > 0 ? Math.round((totalCount / uniquePlayers) * 100) / 100 : null;
|
||||
|
||||
// ── 21-day completions per day (covers all three weekly windows) ──────────
|
||||
|
||||
const rawPerDay21 = await db
|
||||
.select({ date: dailyCompletions.date, dayCount: count() })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, weekCStart))
|
||||
.groupBy(dailyCompletions.date)
|
||||
.orderBy(asc(dailyCompletions.date));
|
||||
|
||||
const counts21 = new Map(rawPerDay21.map((r) => [r.date, r.dayCount]));
|
||||
|
||||
// Build indexed array: index 0 = today, index 20 = 20 days ago
|
||||
const completionsPerDay: number[] = [];
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
completionsPerDay.push(counts21.get(estDateStr(i)) ?? 0);
|
||||
}
|
||||
|
||||
// last14Days for the trend chart (most recent first)
|
||||
const last14Days: { date: string; count: number }[] = [];
|
||||
for (let i = 0; i <= 13; i++) {
|
||||
last14Days.push({ date: estDateStr(i), count: completionsPerDay[i] });
|
||||
}
|
||||
|
||||
// Weekly totals from the indexed array
|
||||
const weekATotal = completionsPerDay.slice(0, 7).reduce((a, b) => a + b, 0);
|
||||
const weekBTotal = completionsPerDay.slice(7, 14).reduce((a, b) => a + b, 0);
|
||||
const weekCTotal = completionsPerDay.slice(14, 21).reduce((a, b) => a + b, 0);
|
||||
|
||||
// First derivative: avg daily completions change (week A vs week B)
|
||||
const completionsVelocity = Math.round(((weekATotal - weekBTotal) / 7) * 10) / 10;
|
||||
// Second derivative: is velocity itself increasing or decreasing?
|
||||
const completionsAcceleration =
|
||||
Math.round((((weekATotal - weekBTotal) - (weekBTotal - weekCTotal)) / 7) * 10) / 10;
|
||||
|
||||
// ── 90-day per-user data (reused for streaks + weekly user sets) ──────────
|
||||
|
||||
const ninetyDaysAgo = estDateStr(90);
|
||||
const recentCompletions = await db
|
||||
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, ninetyDaysAgo))
|
||||
.orderBy(asc(dailyCompletions.date));
|
||||
|
||||
// Group dates by user (ascending) and users by date
|
||||
const userDatesMap = new Map<string, string[]>();
|
||||
const dateUsersMap = new Map<string, Set<string>>();
|
||||
for (const row of recentCompletions) {
|
||||
const arr = userDatesMap.get(row.anonymousId);
|
||||
if (arr) arr.push(row.date);
|
||||
else userDatesMap.set(row.anonymousId, [row.date]);
|
||||
|
||||
let s = dateUsersMap.get(row.date);
|
||||
if (!s) { s = new Set(); dateUsersMap.set(row.date, s); }
|
||||
s.add(row.anonymousId);
|
||||
}
|
||||
|
||||
// ── Streak distribution ───────────────────────────────────────────────────
|
||||
|
||||
const streakDistribution = new Map<number, number>();
|
||||
for (const dates of userDatesMap.values()) {
|
||||
const desc = dates.slice().reverse();
|
||||
if (desc[0] !== todayEst && desc[0] !== yesterdayEst) continue;
|
||||
let streak = 1;
|
||||
let cur = desc[0];
|
||||
for (let i = 1; i < desc.length; i++) {
|
||||
if (desc[i] === prevDay(cur)) {
|
||||
streak++;
|
||||
cur = desc[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (streak >= 2) {
|
||||
streakDistribution.set(streak, (streakDistribution.get(streak) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const streakChart = Array.from(streakDistribution.entries())
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([days, userCount]) => ({ days, count: userCount }));
|
||||
|
||||
// ── Weekly user sets (for user-based velocity + churn) ───────────────────
|
||||
|
||||
const weekAUsers = new Set<string>();
|
||||
const weekBUsers = new Set<string>();
|
||||
const weekCUsers = new Set<string>();
|
||||
|
||||
for (const [userId, dates] of userDatesMap) {
|
||||
if (dates.some((d) => d >= weekAStart)) weekAUsers.add(userId);
|
||||
if (dates.some((d) => d >= weekBStart && d <= weekBEnd)) weekBUsers.add(userId);
|
||||
if (dates.some((d) => d >= weekCStart && d <= weekCEnd)) weekCUsers.add(userId);
|
||||
}
|
||||
|
||||
// First derivative: weekly unique users change
|
||||
const userVelocity = weekAUsers.size - weekBUsers.size;
|
||||
// Second derivative: is user growth speeding up or slowing down?
|
||||
const userAcceleration =
|
||||
weekAUsers.size - weekBUsers.size - (weekBUsers.size - weekCUsers.size);
|
||||
|
||||
// ── New players + churn ───────────────────────────────────────────────────
|
||||
// New players: anonymousIds whose first-ever completion falls in the last 7 days.
|
||||
// Checking against all-time data (not just the 90-day window) ensures accuracy.
|
||||
const firstDates = await db
|
||||
.select({
|
||||
anonymousId: dailyCompletions.anonymousId,
|
||||
firstDate: min(dailyCompletions.date),
|
||||
totalCompletions: count()
|
||||
})
|
||||
.from(dailyCompletions)
|
||||
.groupBy(dailyCompletions.anonymousId);
|
||||
|
||||
const newUsers7d = firstDates.filter((r) => r.firstDate != null && r.firstDate >= weekAStart).length;
|
||||
|
||||
// Churned: played in week B but not at all in week A
|
||||
const churned7d = [...weekBUsers].filter((id) => !weekAUsers.has(id)).length;
|
||||
|
||||
// Net growth = truly new arrivals minus departures
|
||||
const netGrowth7d = newUsers7d - churned7d;
|
||||
|
||||
// ── Session depth funnel ──────────────────────────────────────────────────
|
||||
// For each depth d, count players with >= d completions.
|
||||
// returnRate at depth d = (players with >= d+1) / (players with >= d).
|
||||
const depthCounts = new Map<number, number>();
|
||||
for (const r of firstDates) {
|
||||
const n = r.totalCompletions;
|
||||
for (let d = 1; d <= n; d++) {
|
||||
depthCounts.set(d, (depthCounts.get(d) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const sessionDepthCards = [2, 3, 4, 5, 7].map((d) => {
|
||||
const atD = depthCounts.get(d) ?? 0;
|
||||
const atDplus1 = depthCounts.get(d + 1) ?? 0;
|
||||
return {
|
||||
depth: d,
|
||||
players: atD,
|
||||
returnRate: atD >= 3 ? Math.round((atDplus1 / atD) * 1000) / 10 : null
|
||||
};
|
||||
});
|
||||
|
||||
// ── Return rate ───────────────────────────────────────────────────────────
|
||||
// "Return rate": % of all-time unique players who have ever played more than once.
|
||||
const playersWithReturn = firstDates.filter((r) => r.totalCompletions >= 2).length;
|
||||
const overallReturnRate =
|
||||
firstDates.length > 0
|
||||
? Math.round((playersWithReturn / firstDates.length) * 1000) / 10
|
||||
: null;
|
||||
|
||||
// Daily new-player return rate: for each day D, what % of first-time players
|
||||
// on D ever came back (i.e. totalCompletions >= 2)?
|
||||
const dailyNewPlayerReturn = new Map<string, { cohort: number; returned: number }>();
|
||||
for (const r of firstDates) {
|
||||
if (!r.firstDate) continue;
|
||||
const existing = dailyNewPlayerReturn.get(r.firstDate) ?? { cohort: 0, returned: 0 };
|
||||
existing.cohort++;
|
||||
if (r.totalCompletions >= 2) existing.returned++;
|
||||
dailyNewPlayerReturn.set(r.firstDate, existing);
|
||||
}
|
||||
|
||||
// Build chronological array of daily rates (oldest first, days 60→1 ago)
|
||||
// Days with fewer than 3 new players get rate=null to exclude from rolling avg
|
||||
const dailyReturnRates: { date: string; cohort: number; rate: number | null }[] = [];
|
||||
for (let i = 60; i >= 1; i--) {
|
||||
const dateD = estDateStr(i);
|
||||
const d = dailyNewPlayerReturn.get(dateD);
|
||||
dailyReturnRates.push({
|
||||
date: dateD,
|
||||
cohort: d?.cohort ?? 0,
|
||||
rate: d && d.cohort >= 3 ? Math.round((d.returned / d.cohort) * 1000) / 10 : null
|
||||
});
|
||||
}
|
||||
|
||||
// 7-day trailing rolling average of the daily rates
|
||||
// Index 0 = 60 days ago, index 59 = yesterday
|
||||
const newPlayerReturnSeries = dailyReturnRates.map((r, idx) => {
|
||||
const window = dailyReturnRates
|
||||
.slice(Math.max(0, idx - 6), idx + 1)
|
||||
.filter((d) => d.rate !== null);
|
||||
const avg =
|
||||
window.length > 0
|
||||
? Math.round((window.reduce((sum, d) => sum + (d.rate ?? 0), 0) / window.length) * 10) /
|
||||
10
|
||||
: null;
|
||||
return { date: r.date, cohort: r.cohort, rate: r.rate, rollingAvg: avg };
|
||||
});
|
||||
|
||||
// Velocity: avg of last 7 complete days (idx 53–59) vs prior 7 (idx 46–52)
|
||||
const recentWindow = newPlayerReturnSeries.slice(53).filter((d) => d.rate !== null);
|
||||
const priorWindow = newPlayerReturnSeries.slice(46, 53).filter((d) => d.rate !== null);
|
||||
const current7dReturnAvg =
|
||||
recentWindow.length > 0
|
||||
? Math.round(
|
||||
(recentWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / recentWindow.length) * 10
|
||||
) / 10
|
||||
: null;
|
||||
const prior7dReturnAvg =
|
||||
priorWindow.length > 0
|
||||
? Math.round(
|
||||
(priorWindow.reduce((a, d) => a + (d.rate ?? 0), 0) / priorWindow.length) * 10
|
||||
) / 10
|
||||
: null;
|
||||
const returnRateChange =
|
||||
current7dReturnAvg !== null && prior7dReturnAvg !== null
|
||||
? Math.round((current7dReturnAvg - prior7dReturnAvg) * 10) / 10
|
||||
: null;
|
||||
|
||||
// ── Retention over time ───────────────────────────────────────────────────
|
||||
// For each cohort day D, retention = % of that day's players who played
|
||||
// again within the next N days. Only compute for days where D+N is in the past.
|
||||
|
||||
function retentionSeries(
|
||||
windowDays: number,
|
||||
seriesLength: number
|
||||
): { date: string; rate: number; cohortSize: number }[] {
|
||||
// Earliest computable cohort day: today - (windowDays + 1)
|
||||
// We use index windowDays+1 through windowDays+seriesLength
|
||||
const series: { date: string; rate: number; cohortSize: number }[] = [];
|
||||
for (let i = windowDays + 1; i <= windowDays + seriesLength; i++) {
|
||||
const dateD = estDateStr(i);
|
||||
const cohort = dateUsersMap.get(dateD);
|
||||
if (!cohort || cohort.size < 3) continue; // skip tiny cohorts
|
||||
let retained = 0;
|
||||
for (const userId of cohort) {
|
||||
if (dateUsersMap.get(addDays(dateD, windowDays))?.has(userId)) {
|
||||
retained++;
|
||||
}
|
||||
}
|
||||
series.push({
|
||||
date: dateD,
|
||||
rate: Math.round((retained / cohort.size) * 1000) / 10,
|
||||
cohortSize: cohort.size
|
||||
});
|
||||
}
|
||||
return series; // newest first (loop iterates i from smallest = most recent)
|
||||
}
|
||||
|
||||
const retention7dSeries = retentionSeries(7, 30);
|
||||
const retention30dSeries = retentionSeries(30, 30);
|
||||
|
||||
// ── Weekly Active Users history (12 weeks) ────────────────────────────────
|
||||
|
||||
const wauWeeks: { weekStart: string; weekEnd: string; wau: number; changePct: number | null }[] = [];
|
||||
|
||||
for (let w = 0; w < 12; w++) {
|
||||
const weekEnd = estDateStr(w * 7);
|
||||
const weekStart = estDateStr(w * 7 + 6);
|
||||
const users = new Set<string>();
|
||||
for (const row of recentCompletions) {
|
||||
if (row.date >= weekStart && row.date <= weekEnd) {
|
||||
users.add(row.anonymousId);
|
||||
}
|
||||
}
|
||||
wauWeeks.push({ weekEnd, weekStart, wau: users.size, changePct: null });
|
||||
}
|
||||
|
||||
// Change % vs prior week (index i+1 is the older week)
|
||||
for (let i = 0; i < wauWeeks.length - 1; i++) {
|
||||
const prev = wauWeeks[i + 1].wau;
|
||||
if (prev > 0) {
|
||||
wauWeeks[i].changePct = Math.round(((wauWeeks[i].wau - prev) / prev) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
const avgWau = Math.round(wauWeeks.reduce((sum, w) => sum + w.wau, 0) / wauWeeks.length);
|
||||
|
||||
// ── Monthly Active Users history (6 months) ───────────────────────────────
|
||||
|
||||
const sixMonthsAgo = estDateStr(185);
|
||||
const mauCompletions = await db
|
||||
.select({ anonymousId: dailyCompletions.anonymousId, date: dailyCompletions.date })
|
||||
.from(dailyCompletions)
|
||||
.where(gte(dailyCompletions.date, sixMonthsAgo));
|
||||
|
||||
// Rolling 30-day windows
|
||||
const mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[] = [];
|
||||
for (let m = 0; m < 6; m++) {
|
||||
const monthEnd = estDateStr(m * 30);
|
||||
const monthStart = estDateStr(m * 30 + 29);
|
||||
const users = new Set<string>();
|
||||
for (const row of mauCompletions) {
|
||||
if (row.date >= monthStart && row.date <= monthEnd) {
|
||||
users.add(row.anonymousId);
|
||||
}
|
||||
}
|
||||
mauMonths.push({ monthStart, monthEnd, mau: users.size, changePct: null });
|
||||
}
|
||||
for (let i = 0; i < mauMonths.length - 1; i++) {
|
||||
const prev = mauMonths[i + 1].mau;
|
||||
if (prev > 0) {
|
||||
mauMonths[i].changePct = Math.round(((mauMonths[i].mau - prev) / prev) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar month windows
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const [todayYear, todayMonth, todayDay] = todayEst.split('-').map(Number);
|
||||
|
||||
const calendarMauMonths: {
|
||||
label: string;
|
||||
monthStart: string;
|
||||
monthEnd: string;
|
||||
mau: number;
|
||||
daysElapsed: number;
|
||||
daysInMonth: number;
|
||||
projectedMau: number | null;
|
||||
changePct: number | null;
|
||||
isCurrentMonth: boolean;
|
||||
}[] = [];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let mo = todayMonth - i;
|
||||
let yr = todayYear;
|
||||
if (mo <= 0) { mo += 12; yr--; }
|
||||
|
||||
// new Date(yr, mo, 0) gives last day of month mo (1-indexed) in local time
|
||||
const daysInMonth = new Date(yr, mo, 0).getDate();
|
||||
const monthStart = `${yr}-${String(mo).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${yr}-${String(mo).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||
const isCurrentMonth = i === 0;
|
||||
const daysElapsed = isCurrentMonth ? todayDay : daysInMonth;
|
||||
const queryEnd = isCurrentMonth ? todayEst : monthEnd;
|
||||
|
||||
const users = new Set<string>();
|
||||
for (const row of mauCompletions) {
|
||||
if (row.date >= monthStart && row.date <= queryEnd) {
|
||||
users.add(row.anonymousId);
|
||||
}
|
||||
}
|
||||
|
||||
const projectedMau = isCurrentMonth && daysElapsed > 0
|
||||
? Math.round(users.size * (daysInMonth / daysElapsed))
|
||||
: null;
|
||||
|
||||
calendarMauMonths.push({
|
||||
label: `${MONTH_NAMES[mo - 1]} ${yr}`,
|
||||
monthStart,
|
||||
monthEnd,
|
||||
mau: users.size,
|
||||
daysElapsed,
|
||||
daysInMonth,
|
||||
projectedMau,
|
||||
changePct: null,
|
||||
isCurrentMonth
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < calendarMauMonths.length - 1; i++) {
|
||||
const curr = calendarMauMonths[i];
|
||||
const prev = calendarMauMonths[i + 1];
|
||||
if (prev.mau > 0) {
|
||||
const compareVal = curr.projectedMau ?? curr.mau;
|
||||
curr.changePct = Math.round(((compareVal - prev.mau) / prev.mau) * 1000) / 10;
|
||||
}
|
||||
}
|
||||
const todayEst = new Date().toLocaleDateString('en-CA', { timeZone: 'America/New_York' });
|
||||
const yesterday = addDays(todayEst, -1);
|
||||
const snapshot = await ensureSnapshot(yesterday);
|
||||
const payload = snapshot.payload;
|
||||
|
||||
return {
|
||||
todayEst,
|
||||
sessionDepthCards,
|
||||
stats: {
|
||||
todayCount,
|
||||
totalCount,
|
||||
uniquePlayers,
|
||||
weeklyPlayers,
|
||||
activeStreaks,
|
||||
avgGuessesToday,
|
||||
registeredUsers,
|
||||
monthlyPlayers
|
||||
},
|
||||
growth: {
|
||||
completionsVelocity,
|
||||
completionsAcceleration,
|
||||
userVelocity,
|
||||
userAcceleration,
|
||||
newUsers7d,
|
||||
churned7d,
|
||||
netGrowth7d
|
||||
},
|
||||
last14Days,
|
||||
streakChart,
|
||||
retention7dSeries,
|
||||
retention30dSeries,
|
||||
overallReturnRate,
|
||||
newPlayerReturnSeries: newPlayerReturnSeries.slice(-30).reverse(),
|
||||
newPlayerReturnVelocity: {
|
||||
current7dAvg: current7dReturnAvg,
|
||||
prior7dAvg: prior7dReturnAvg,
|
||||
change: returnRateChange
|
||||
},
|
||||
wauWeeks,
|
||||
avgWau,
|
||||
mauMonths,
|
||||
calendarMauMonths
|
||||
asOfDate: payload.asOfDate,
|
||||
completions: payload.totals.completions,
|
||||
players: payload.totals.players,
|
||||
weeklyPlayers: payload.activity.wau,
|
||||
monthlyPlayers: payload.activity.mau,
|
||||
yesterdayCompletions: payload.totals.yesterdayCompletions,
|
||||
yesterdayAvgGuesses: payload.totals.yesterdayAvgGuesses,
|
||||
calendar: payload.habit.calendar.slice(-30)
|
||||
};
|
||||
};
|
||||
};
|
||||
+47
-618
@@ -1,627 +1,56 @@
|
||||
<script lang="ts">
|
||||
import Container from "$lib/components/Container.svelte";
|
||||
import CollapsibleTable from "$lib/components/CollapsibleTable.svelte";
|
||||
|
||||
interface Stats {
|
||||
todayCount: number;
|
||||
totalCount: number;
|
||||
uniquePlayers: number;
|
||||
weeklyPlayers: number;
|
||||
activeStreaks: number;
|
||||
avgGuessesToday: number | null;
|
||||
registeredUsers: number;
|
||||
monthlyPlayers: number;
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
todayEst: string;
|
||||
stats: Stats;
|
||||
last14Days: { date: string; count: number }[];
|
||||
streakChart: { days: number; count: number }[];
|
||||
growth: {
|
||||
completionsVelocity: number;
|
||||
completionsAcceleration: number;
|
||||
userVelocity: number;
|
||||
userAcceleration: number;
|
||||
newUsers7d: number;
|
||||
churned7d: number;
|
||||
netGrowth7d: number;
|
||||
};
|
||||
retention7dSeries: { date: string; rate: number; cohortSize: number }[];
|
||||
retention30dSeries: {
|
||||
date: string;
|
||||
rate: number;
|
||||
cohortSize: number;
|
||||
}[];
|
||||
overallReturnRate: number | null;
|
||||
newPlayerReturnSeries: {
|
||||
date: string;
|
||||
cohort: number;
|
||||
rate: number | null;
|
||||
rollingAvg: number | null;
|
||||
}[];
|
||||
newPlayerReturnVelocity: {
|
||||
current7dAvg: number | null;
|
||||
prior7dAvg: number | null;
|
||||
change: number | null;
|
||||
};
|
||||
wauWeeks: {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
wau: number;
|
||||
changePct: number | null;
|
||||
}[];
|
||||
avgWau: number;
|
||||
mauMonths: { monthStart: string; monthEnd: string; mau: number; changePct: number | null }[];
|
||||
calendarMauMonths: {
|
||||
label: string;
|
||||
monthStart: string;
|
||||
monthEnd: string;
|
||||
mau: number;
|
||||
daysElapsed: number;
|
||||
daysInMonth: number;
|
||||
projectedMau: number | null;
|
||||
changePct: number | null;
|
||||
isCurrentMonth: boolean;
|
||||
}[];
|
||||
sessionDepthCards: { depth: number; players: number; returnRate: number | null }[];
|
||||
}
|
||||
import type { PageData } from './$types';
|
||||
import Container from '$lib/components/Container.svelte';
|
||||
import CalendarHeatmap from '$lib/components/charts/CalendarHeatmap.svelte';
|
||||
import { formatDateLabel, numOrDash } from '$lib/utils/stats-format';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const {
|
||||
stats,
|
||||
last14Days,
|
||||
todayEst,
|
||||
streakChart,
|
||||
growth,
|
||||
retention7dSeries,
|
||||
retention30dSeries,
|
||||
overallReturnRate,
|
||||
newPlayerReturnSeries,
|
||||
newPlayerReturnVelocity,
|
||||
wauWeeks,
|
||||
avgWau,
|
||||
mauMonths,
|
||||
calendarMauMonths,
|
||||
sessionDepthCards,
|
||||
} = $derived(data);
|
||||
|
||||
let mauMode = $state<'rolling' | 'calendar'>('rolling');
|
||||
|
||||
function signed(n: number, unit = ""): string {
|
||||
if (n > 0) return `+${n}${unit}`;
|
||||
if (n < 0) return `${n}${unit}`;
|
||||
return `0${unit}`;
|
||||
}
|
||||
|
||||
function trendColor(n: number): string {
|
||||
if (n > 0) return "text-green-400";
|
||||
if (n < 0) return "text-red-400";
|
||||
return "text-gray-400";
|
||||
}
|
||||
|
||||
const maxCount = $derived(Math.max(1, ...last14Days.map((d) => d.count)));
|
||||
const maxWau = $derived(Math.max(1, ...wauWeeks.map((w) => w.wau)));
|
||||
const maxStreakCount = $derived(Math.max(1, ...streakChart.map((r) => r.count)));
|
||||
const maxMau = $derived(Math.max(1, ...mauMonths.map((m) => m.mau)));
|
||||
const maxCalMau = $derived(Math.max(1, ...calendarMauMonths.map((m) => m.projectedMau ?? m.mau)));
|
||||
|
||||
const statCards = $derived([
|
||||
{ label: "Completions Today", value: String(stats.todayCount) },
|
||||
{ label: "All-Time Completions", value: String(stats.totalCount) },
|
||||
{ label: "Unique Players", value: String(stats.uniquePlayers) },
|
||||
{ label: "Players This Week", value: String(stats.weeklyPlayers) },
|
||||
{ label: "Active Streaks", value: String(stats.activeStreaks) },
|
||||
{ label: "Registered Users", value: String(stats.registeredUsers) },
|
||||
{ label: "Players This Month", value: String(stats.monthlyPlayers) },
|
||||
{
|
||||
label: "Overall Return Rate",
|
||||
value: overallReturnRate != null ? `${overallReturnRate}%` : "N/A",
|
||||
},
|
||||
]);
|
||||
|
||||
const mauModes = [
|
||||
{ value: 'rolling', label: 'Rolling 30d' },
|
||||
{ value: 'calendar', label: 'Calendar' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Global Stats | Bibdle</title>
|
||||
<title>Community Stats | Bibdle</title>
|
||||
<meta name="description" content="Bibdle community statistics, as of yesterday." />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="min-h-screen bg-linear-to-br from-gray-900 via-slate-900 to-gray-900 text-gray-100"
|
||||
>
|
||||
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-1 text-gray-400 hover:text-gray-100 text-sm mb-6 transition-colors"
|
||||
>
|
||||
← Back to Game
|
||||
</a>
|
||||
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-100">Global Stats</h1>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
EST reference date: {todayEst}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-10"
|
||||
>
|
||||
{#each statCards as card (card.label)}
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>{card.label}</span
|
||||
>
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-100"
|
||||
>{card.value}</span
|
||||
>
|
||||
</Container>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Traffic & Growth <span
|
||||
class="text-xs font-normal text-gray-400"
|
||||
>(7-day windows)</span
|
||||
>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Completions Velocity</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.completionsVelocity,
|
||||
)}">{signed(growth.completionsVelocity, "/day")}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>vs prior 7 days</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Completions Accel.</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.completionsAcceleration,
|
||||
)}"
|
||||
>{signed(growth.completionsAcceleration, "/day")}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>rate of change of velocity</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>User Velocity</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.userVelocity,
|
||||
)}">{signed(growth.userVelocity)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>unique players, wk/wk</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>User Acceleration</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.userAcceleration,
|
||||
)}">{signed(growth.userAcceleration)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>rate of change of user velocity</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>New Players (7d)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.newUsers7d,
|
||||
)}">{String(growth.newUsers7d)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>first-time players</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Churned (7d)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
0,
|
||||
)}">{String(growth.churned7d)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>played wk prior, not this wk</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Net Growth (7d)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {trendColor(
|
||||
growth.netGrowth7d,
|
||||
)}">{signed(growth.netGrowth7d)}</span
|
||||
>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>new minus churned</span
|
||||
>
|
||||
</Container>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">Survival Curve</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
Of players who completed N sessions, what % came back for N+1?
|
||||
</p>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{#each sessionDepthCards as card (card.depth)}
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>After {card.depth} plays</span
|
||||
>
|
||||
<span class="text-2xl md:text-3xl font-bold text-gray-100">
|
||||
{card.returnRate != null ? `${card.returnRate}%` : "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>{card.players} players</span
|
||||
>
|
||||
</Container>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-10">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
New Player Return Rate <span
|
||||
class="text-xs font-normal text-gray-400"
|
||||
>(7-day rolling avg)</span
|
||||
>
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Return Rate (7d avg)</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
|
||||
>
|
||||
{newPlayerReturnVelocity.current7dAvg != null
|
||||
? `${newPlayerReturnVelocity.current7dAvg}%`
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>new players who came back</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Return Rate Change</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center {newPlayerReturnVelocity.change !=
|
||||
null
|
||||
? trendColor(newPlayerReturnVelocity.change)
|
||||
: 'text-gray-400'}"
|
||||
>
|
||||
{newPlayerReturnVelocity.change != null
|
||||
? signed(newPlayerReturnVelocity.change, "pp")
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>vs prior 7 days</span
|
||||
>
|
||||
</Container>
|
||||
<Container class="w-full p-5 gap-2">
|
||||
<span
|
||||
class="text-gray-400 text-xs uppercase tracking-wide text-center"
|
||||
>Prior 7d Avg</span
|
||||
>
|
||||
<span
|
||||
class="text-2xl md:text-3xl font-bold text-center text-gray-100"
|
||||
>
|
||||
{newPlayerReturnVelocity.prior7dAvg != null
|
||||
? `${newPlayerReturnVelocity.prior7dAvg}%`
|
||||
: "N/A"}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 text-center"
|
||||
>days 8–14 ago</span
|
||||
>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<CollapsibleTable
|
||||
rows={newPlayerReturnSeries}
|
||||
headers={[
|
||||
{ label: 'Date' },
|
||||
{ label: 'New Players', align: 'right' },
|
||||
{ label: 'Return Rate', align: 'right' },
|
||||
{ label: '7d Avg', align: 'right' },
|
||||
{ label: '', width: 'w-32' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohort}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400">
|
||||
{item.rate != null ? `${item.rate}%` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">
|
||||
{item.rollingAvg != null ? `${item.rollingAvg}%` : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
{#if item.rollingAvg != null}
|
||||
<div class="bg-sky-500 h-4 rounded" style="width: {item.rollingAvg}%"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||
Weekly Active Users
|
||||
</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
Unique players per 7-day window. Most recent week first. Avg
|
||||
WAU: <span class="text-gray-100 font-medium">{avgWau}</span>
|
||||
</p>
|
||||
<CollapsibleTable
|
||||
rows={wauWeeks}
|
||||
headers={[
|
||||
{ label: 'Week' },
|
||||
{ label: 'Active Users', align: 'right' },
|
||||
{ label: 'Wk/Wk Change', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.wau / maxWau) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300 text-xs">{item.weekStart} – {item.weekEnd}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.wau}</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||
: 'text-gray-500'}">
|
||||
{item.changePct != null ? signed(item.changePct, '%') : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-indigo-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">Monthly Active Users</h2>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
{mauMode === 'rolling'
|
||||
? 'Unique players per 30-day window. Most recent first.'
|
||||
: 'Unique players per calendar month. Current month projected to end of month.'}
|
||||
</p>
|
||||
|
||||
{#if mauMode === 'rolling'}
|
||||
<CollapsibleTable
|
||||
rows={mauMonths}
|
||||
headers={[
|
||||
{ label: 'Period' },
|
||||
{ label: 'Active Users', align: 'right' },
|
||||
{ label: 'Mo/Mo Change', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
modes={mauModes}
|
||||
bind:mode={mauMode}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.mau / maxMau) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300 text-xs">{item.monthStart} – {item.monthEnd}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.mau}</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||
: 'text-gray-500'}">
|
||||
{item.changePct != null ? signed(item.changePct, '%') : '—'}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-teal-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
{:else}
|
||||
<CollapsibleTable
|
||||
rows={calendarMauMonths}
|
||||
headers={[
|
||||
{ label: 'Month' },
|
||||
{ label: 'Active Users', align: 'right' },
|
||||
{ label: 'Mo/Mo Change', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
modes={mauModes}
|
||||
bind:mode={mauMode}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const displayMau = item.projectedMau ?? item.mau}
|
||||
{@const barPct = Math.round((displayMau / maxCalMau) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300">
|
||||
{item.label}
|
||||
{#if item.isCurrentMonth}
|
||||
<span class="text-xs text-gray-500 ml-1">(projected)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium {item.isCurrentMonth ? 'text-gray-400' : 'text-gray-100'}">
|
||||
{#if item.isCurrentMonth}
|
||||
<span class="text-gray-500 text-xs">{item.mau} → </span>{item.projectedMau ?? item.mau}
|
||||
{:else}
|
||||
{item.mau}
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-xs font-medium {item.changePct != null
|
||||
? item.changePct > 0 ? 'text-green-400' : item.changePct < 0 ? 'text-red-400' : 'text-gray-400'
|
||||
: 'text-gray-500'}">
|
||||
{#if item.changePct != null}
|
||||
{item.isCurrentMonth ? '~' : ''}{signed(item.changePct, '%')}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-teal-500 h-4 rounded {item.isCurrentMonth ? 'opacity-50' : ''}" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Last 14 Days — Completions
|
||||
</h2>
|
||||
<CollapsibleTable
|
||||
rows={last14Days}
|
||||
headers={[
|
||||
{ label: 'Date' },
|
||||
{ label: 'Completions', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.count / maxCount) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-amber-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-4">
|
||||
Active Streak Distribution
|
||||
</h2>
|
||||
<CollapsibleTable
|
||||
rows={streakChart}
|
||||
headers={[
|
||||
{ label: 'Days' },
|
||||
{ label: 'Players', align: 'right' },
|
||||
{ label: '', width: 'w-48' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
{@const barPct = Math.round((item.count / maxStreakCount) * 100)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.days}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.count}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-24">
|
||||
<div class="bg-blue-500 h-4 rounded" style="width: {barPct}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
{#snippet empty()}
|
||||
<p class="text-gray-400 text-sm px-4 py-6">No active streaks yet.</p>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-gray-100 mb-1">
|
||||
Retention Over Time
|
||||
</h2>
|
||||
<p class="text-gray-400 text-sm mb-6">
|
||||
% of each day's players who played again exactly 7 or 30 days later (regardless of activity in between). Cohorts with fewer than 3 players are excluded.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 7-day retention -->
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||
7-Day Retention
|
||||
</h3>
|
||||
<CollapsibleTable
|
||||
rows={retention7dSeries}
|
||||
headers={[
|
||||
{ label: 'Cohort Date' },
|
||||
{ label: 'n', align: 'right' },
|
||||
{ label: 'Ret. %', align: 'right' },
|
||||
{ label: '', width: 'w-32' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
<div class="bg-emerald-500 h-4 rounded" style="width: {item.rate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</div>
|
||||
|
||||
<!-- 30-day retention -->
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-200 mb-3">
|
||||
30-Day Retention
|
||||
</h3>
|
||||
<CollapsibleTable
|
||||
rows={retention30dSeries}
|
||||
headers={[
|
||||
{ label: 'Cohort Date' },
|
||||
{ label: 'n', align: 'right' },
|
||||
{ label: 'Ret. %', align: 'right' },
|
||||
{ label: '', width: 'w-32' },
|
||||
]}
|
||||
>
|
||||
{#snippet row(item)}
|
||||
<td class="px-4 py-3 text-gray-300">{item.date}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-400 text-xs">{item.cohortSize}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-100 font-medium">{item.rate}%</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="w-full min-w-20">
|
||||
<div class="bg-violet-500 h-4 rounded" style="width: {item.rate}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
{/snippet}
|
||||
</CollapsibleTable>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="mx-auto max-w-3xl px-4 py-8">
|
||||
<div class="mb-6 text-center">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Community Stats</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">As of {formatDateLabel(data.asOfDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time finishes</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.completions)}</p>
|
||||
</Container>
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">All-time players</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.players)}</p>
|
||||
</Container>
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Players this week</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.weeklyPlayers)}</p>
|
||||
</Container>
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Players this month</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.monthlyPlayers)}</p>
|
||||
</Container>
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Yesterday's finishes</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayCompletions)}</p>
|
||||
</Container>
|
||||
<Container class="p-4 w-full items-start text-left">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wider text-gray-600 dark:text-gray-400">Avg guesses yesterday</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{numOrDash(data.yesterdayAvgGuesses)}</p>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
<Container class="mt-3 p-4 w-full items-stretch text-left gap-2">
|
||||
<p class="font-semibold text-gray-900 dark:text-gray-100">Daily finishes (last 30 days)</p>
|
||||
<CalendarHeatmap data={data.calendar} />
|
||||
</Container>
|
||||
|
||||
<p class="mt-4 text-center text-xs text-gray-500 dark:text-gray-500">
|
||||
Updated once a day from completed games. <a class="underline hover:text-gray-300" href="/">Play today's verse →</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,369 @@
|
||||
# Bibdle Statistics Dashboard — Implementation Checklist
|
||||
|
||||
**For:** an AI coding agent (or human) implementing the plan in `statistics-plan.md`.
|
||||
**Repository:** Bibdle — SvelteKit 2 · Svelte 5 (runes) · Tailwind 4 · Bun · SQLite + Drizzle ORM.
|
||||
**Rule:** read `statistics-plan.md` and `README.md` fully before starting. This checklist is ordered and dependency-aware; do tasks top to bottom unless a task says otherwise.
|
||||
|
||||
---
|
||||
|
||||
## 0. How to use this document
|
||||
|
||||
- Each task has an **ID**, **Goal**, **Files**, **Steps**, **Acceptance**, and **Tests**.
|
||||
- Do **not** skip the Phase 0 foundations — every later phase depends on the schema and event log.
|
||||
- Prefer pure, testable functions in `src/lib/server/stats/*`; keep Svelte components presentational.
|
||||
- When a task is ambiguous, prefer the smallest reversible change and leave a `// TODO(stats):` comment rather than inventing behavior.
|
||||
|
||||
---
|
||||
|
||||
## 1. Guardrails (non-negotiable)
|
||||
|
||||
1. **Do not add Umami or Rybbit integration.** The dashboard is self-contained and durable. Remove/ignore any Rybbit references you touch.
|
||||
2. **Do not query the DB on `/admin/stats` page load** except to (a) build a missing snapshot lazily, or (b) serve a `?mature=1` view. Normal loads read the stored snapshot only.
|
||||
3. **Point-in-time correctness:** snapshot computation for date `D` may only use rows with `date <= D` (and `completed_at` before end of `D`).
|
||||
4. **Plain language only.** No "velocity/acceleration/derivative" jargon in UI labels or captions. Every metric gets a one-sentence caption and a "what to do."
|
||||
5. **Privacy:** `/admin/stats` is gated to `ADMIN_EMAIL`. Never render emails or raw anonymous IDs. The public widget must expose no retention/churn/signup/revenue data.
|
||||
6. **Do not modify existing public routes** (`/`, `/progress`, `/stats`, `/imposter`, etc.) beyond the specific instrumentation hooks in Phase 0. Do not change the public `/global` until Phase 4.
|
||||
7. **No new runtime dependency except `layerchart`.** (Email digest would require `nodemailer`; it is out of scope unless the owner approves — default to the Discord webhook.)
|
||||
8. **Follow existing code style:** Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`), Tailwind 4 utility classes, Drizzle query builder, `bun test` with `describe/test/expect`.
|
||||
9. **Migrations:** never hand-edit generated SQL. Use `bun run db:generate` and review the diff. Be aware the `drizzle/` journal references `0001_loose_kree` but that `.sql` file is absent on disk — do not "fix" this; just add new migrations on top.
|
||||
10. **Commit early and often**, one phase per logical commit set, message prefix `stats:`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Environment & commands
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev # dev server
|
||||
bun run check # svelte-check / TS
|
||||
bun test # all tests
|
||||
bun test tests/<file>.test.ts
|
||||
bun run db:generate # generate migration from schema.ts
|
||||
bun run db:migrate # apply migrations to DATABASE_URL
|
||||
```
|
||||
|
||||
- `DATABASE_URL` (e.g. `dev.db`) drives the app DB.
|
||||
- `TEST_DATABASE_URL` drives `drizzle.test.config.ts`.
|
||||
- Existing env: `CRON_SECRET`, `DISCORD_DAILY_WEBHOOK`, `ADMIN_EMAIL` (hardcoded in `src/lib/server/admin.ts`).
|
||||
- **Add** `DISCORD_ADMIN_WEBHOOK` (digest target) to `.env.example` and `.env`.
|
||||
|
||||
**Verification gate after every phase:** `bun run check` clean, `bun test` green, `bun run build` succeeds.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Foundations & instrumentation
|
||||
|
||||
### T0.1 — Add tables and column to the Drizzle schema
|
||||
- **Goal:** add `stats_snapshot`, `analytics_events`, `dashboard_settings`, and `user.created_at`.
|
||||
- **Files:** `src/lib/server/db/schema.ts`
|
||||
- **Steps:**
|
||||
1. Add `user.createdAt = integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())`. (`$defaultFn` means new rows get a timestamp automatically; existing rows stay `NULL` = "unknown".)
|
||||
2. Add `statsSnapshot` (`date` text PK; `generatedAt` integer timestamp; `buildKind` text; `configVersion` text; `payload` text; `playerState` text; `config` text).
|
||||
3. Add `analyticsEvents` (`id` text PK; `name` text; `anonymousId` text; `userId` text nullable FK → user ON DELETE SET NULL; `date` text nullable; `createdAt` integer timestamp; `metadata` text). Indexes on `(name, date)` and `(anonymousId, date)`; unique index `analytics_events_serve_unique` on `(name, anonymousId, date)`.
|
||||
4. Add `dashboardSettings` (`key` text PK; `value` text; `updatedAt` integer timestamp).
|
||||
5. Export inferred types.
|
||||
- **Steps:** run `bun run db:generate`, inspect SQL, then `bun run db:migrate`.
|
||||
- **Acceptance:** fresh migration applies cleanly to `dev.db`; `sqlite3 dev.db ".schema user"` shows `created_at`; all three tables exist.
|
||||
- **Tests:** none (schema only) — but run full suite.
|
||||
|
||||
### T0.2 — Settings module (thresholds with env defaults)
|
||||
- **Goal:** a single source of truth for configurable thresholds.
|
||||
- **Files:** new `src/lib/server/stats/settings.ts`; new `src/lib/types/stats.ts` (types).
|
||||
- **Steps:**
|
||||
1. Define keys with defaults: `core_plays_per_week` (4), `churn_days` (30), `resurrect_gap_days` (30), `at_risk_days` (8), `d1_target` (20), `return7_target` (25), `completion_target` (70), `first_try_min` (15), `first_try_max` (35), `share_target` (25), `active_window_days` (180), `anomaly_mad_threshold` (2).
|
||||
2. `getSettings()` reads `dashboard_settings` and overlays env-var defaults (e.g. `STATS_D1_TARGET`). `updateSettings(partial)` upserts rows.
|
||||
3. `configVersion()` = short stable hash of the resolved settings.
|
||||
- **Acceptance:** `getSettings()` returns defaults with an empty table; `updateSettings` persists and is reflected on re-read.
|
||||
- **Tests:** `tests/stats-settings.test.ts` — defaults, override, upsert, config hash stability.
|
||||
|
||||
### T0.3 — Event recording module + client helper
|
||||
- **Goal:** a single, safe sink for analytics events (B/D/E/F).
|
||||
- **Files:** new `src/lib/server/stats/events.ts`; new `src/routes/api/events/+server.ts`; new `src/lib/utils/analytics.ts`.
|
||||
- **Steps:**
|
||||
1. `recordEvent({ name, anonymousId, userId, date, metadata })` inserts into `analytics_events`. For `name === 'verse_served'`, use `INSERT OR IGNORE` (dedupe on unique index).
|
||||
2. `POST /api/events` validates `name` against an allowlist (`verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`), caps metadata size, resolves `userId` from `locals.user` if present, and records. Return 204. Never throw to the client callers (fire-and-forget).
|
||||
3. `src/lib/utils/analytics.ts` exports `track(name, metadata?)` that POSTs to `/api/events` with the lib's anonymous ID (read/create `bibdle-anonymous-id`), ignoring errors. Also export `getOrCreateAnonymousId()` and refactor `src/lib/stores/game-persistence.svelte.ts` to import it (avoid duplicated logic).
|
||||
- **Acceptance:** calling `track('donate_click')` writes one row; repeated `verse_served` for the same `(anon, date)` writes only once.
|
||||
- **Tests:** `tests/stats-events.test.ts` — allowlist rejection, metadata cap, verse_served dedupe, user attribution.
|
||||
|
||||
### T0.4 — Instrumentation A: guarantee guess sequences for everyone
|
||||
- **Goal:** all completions persist `guesses`.
|
||||
- **Files:** `src/routes/api/submit-completion/+server.ts`, `src/routes/+page.svelte` (verify), `src/routes/+page.server.ts` (legacy form action).
|
||||
- **Steps:** confirm the client always sends `guesses`; ensure the API stores `JSON.stringify(guesses)` whenever the array is non-empty. Update the legacy `submitCompletion` form action to accept and store `guesses` too (or delete the dead action if unused). Do not backfill historical rows.
|
||||
- **Acceptance:** a guest completion POSTed with `guesses` stores the JSON; `SELECT COUNT(*) FROM daily_completions WHERE guesses IS NOT NULL` grows for guest IDs in dev.
|
||||
- **Tests:** extend `tests/stats.test.ts` or add `tests/stats-completion.test.ts`.
|
||||
|
||||
### T0.5 — Instrumentation B: log "verse served" (game started)
|
||||
- **Goal:** measure started → completed.
|
||||
- **Files:** `src/routes/+page.ts`, `src/routes/api/daily-verse/+server.ts`, `src/lib/utils/analytics.ts`.
|
||||
- **Steps:** in `+page.ts`, read/create the anonymous ID before the fetch and include it in the `POST /api/daily-verse` body. In the endpoint, record `verse_served` (unique per anon+date). Keep the endpoint's response shape unchanged.
|
||||
- **Acceptance:** loading `/` records exactly one `verse_served` per browser per day, even after reloads.
|
||||
- **Tests:** `tests/stats-serve.test.ts` (endpoint logic with a test DB) + unit test for dedupe.
|
||||
|
||||
### T0.6 — Instrumentation D: share / copy events
|
||||
- **Goal:** measure virality.
|
||||
- **Files:** `src/lib/components/WinScreen.svelte`, `src/lib/utils/share.ts` (or call site).
|
||||
- **Steps:** fire `track('share', { snippet })` when the native-share path is used and `track('share_copy', { snippet })` on clipboard copy. Do not double-count the copy that happens inside `shareResult` fallback. Keep existing Umami `data-umami-*` attributes untouched.
|
||||
- **Acceptance:** clicking Share writes one `share` row; Copy writes one `share_copy` row; snippet flag correct.
|
||||
- **Tests:** manual + a small unit test on the helper if logic is extracted.
|
||||
|
||||
### T0.7 — Instrumentation E: first-touch referrer / UTM
|
||||
- **Goal:** acquisition source.
|
||||
- **Files:** `src/lib/utils/analytics.ts`, root layout or `+page.ts`.
|
||||
- **Steps:** on first visit per browser (guard with localStorage key), capture `document.referrer` and `?ref=` / `?utm_source=` / `?utm_medium` / `?utm_campaign`, and record a one-time `first_visit` event with that metadata.
|
||||
- **Acceptance:** first visit with `?utm_source=bluesky` records one `first_visit`; subsequent visits do not.
|
||||
- **Tests:** unit test on the parsing/guard logic.
|
||||
|
||||
### T0.8 — Instrumentation F: monetization clicks
|
||||
- **Goal:** patron funnel.
|
||||
- **Files:** `src/lib/components/WinScreen.svelte` (Donate, Discord), `src/lib/components/SocialLinks.svelte` if desired.
|
||||
- **Steps:** fire `track('donate_click')` and `track('discord_click')` on click (keep the existing Umami attributes).
|
||||
- **Acceptance:** clicks record events with the right names.
|
||||
|
||||
### T0.9 — Add `DISCORD_ADMIN_WEBHOOK` env
|
||||
- **Files:** `.env.example`, `.env` (local only), `README.md` env list.
|
||||
- **Acceptance:** documented; not required at boot (digest degrades gracefully when unset).
|
||||
|
||||
**Phase 0 exit criteria:** migrations applied; events flowing in dev; `bun run check` + `bun test` green.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Core console (snapshot + Pulse + Signals skeleton)
|
||||
|
||||
### T1.1 — Pure metric functions
|
||||
- **Goal:** tested, deterministic metrics independent of Svelte/DB reads.
|
||||
- **Files:** new `src/lib/server/stats/metrics.ts`, types in `src/lib/types/stats.ts`.
|
||||
- **Steps:** implement, each taking plain rows and a cutoff date:
|
||||
- `activePlayers(completions, end, windowDays)`
|
||||
- `newVsReturning(completions, date)` (returning = first-ever completion `< date`)
|
||||
- `netGrowth(completions, end, windowDays, settings)`
|
||||
- `d1Return(completions, cohortDate)` and `returnWithin(completions, cohortDate, n)`
|
||||
- `cohortTriangle(completions, { start, weeks })`
|
||||
- `timeToSecondPlayHistogram(completions, buckets)`
|
||||
- `guessHistogram(completions, maxBucket)` + `firstTryRate`
|
||||
- `streakSurvival(completions)`
|
||||
- `bookCoverage(verses, completions, start, end)`
|
||||
- `retentionByBook(completions, verses, minPlays)` and `retentionByGuessBucket`
|
||||
- `entropy(bookCounts)`
|
||||
- `weekdayHourHeatmap(completions)`
|
||||
- **Acceptance:** pure functions; no DB imports; correct on hand-built fixtures.
|
||||
- **Tests:** `tests/stats-metrics.test.ts` — one test per function incl. edge cases (empty, single player, day boundaries in UTC).
|
||||
|
||||
### T1.2 — Segment derivation from stored player state
|
||||
- **Goal:** render-time configurable segments.
|
||||
- **Files:** new `src/lib/server/stats/segments.ts`.
|
||||
- **Steps:** define `PlayerState = { id, firstDate, lastDate, totalPlays, plays7, plays30, streak }`. `deriveSegments(playerState[], asOfDate, settings)` returns `{ new, returning, core, atRisk, churned, resurrected }` counts/sets. Keep it pure.
|
||||
- **Acceptance:** changing `core_plays_per_week` changes segmentation without recomputation from raw completions.
|
||||
- **Tests:** `tests/stats-segments.test.ts`.
|
||||
|
||||
### T1.3 — Snapshot builder
|
||||
- **Goal:** compute and persist one snapshot for a date.
|
||||
- **Files:** new `src/lib/server/stats/snapshot.ts`.
|
||||
- **Steps:**
|
||||
1. `buildSnapshot(asOfDate, { kind })`: query completions/verses/users with `date <= asOfDate`, compute all Phase-1 (and later-phase) aggregates, build `playerState` for active players in the trailing `active_window_days`, assemble `payload` JSON + `config` + `configVersion`, and `INSERT OR REPLACE` into `stats_snapshot`.
|
||||
2. `getSnapshot(date)` reads a row. `ensureSnapshot(date)` builds with `kind: 'lazy'` if missing (guard against races: `INSERT OR IGNORE` then re-read).
|
||||
3. `isSnapshotStale(date)` compares stored `configVersion` to current `configVersion()` (used to warn in UI; do **not** auto-rebuild unless triggered).
|
||||
- **Acceptance:** building 2026-09-14 twice is idempotent; the payload only reflects data ≤ 2026-09-14.
|
||||
- **Tests:** `tests/stats-snapshot.test.ts` — cutoff enforcement, idempotency, lazy race handling.
|
||||
|
||||
### T1.4 — Snapshot endpoints
|
||||
- **Goal:** protected build trigger + admin read.
|
||||
- **Files:** new `src/routes/api/cron/build-stats/+server.ts`; optional `src/routes/api/admin/rebuild-stats/+server.ts`.
|
||||
- **Steps:** `POST /api/cron/build-stats` checks `Authorization: Bearer ${CRON_SECRET}`, builds **yesterday** (server `America/New_York`, matching `send-daily-verse`), then triggers the digest (Phase 4; no-op if not built yet). The admin rebuild accepts `{ date }` and requires `isAdmin`.
|
||||
- **Acceptance:** 401 without secret; builds yesterday with secret.
|
||||
- **Tests:** `tests/stats-endpoints.test.ts` — auth + date selection.
|
||||
|
||||
### T1.5 — Admin route shell + auth gate + date navigator
|
||||
- **Goal:** `/admin/stats` renders a snapshot and can navigate time.
|
||||
- **Files:** new `src/routes/admin/stats/+page.server.ts`, `+page.svelte`, `src/lib/components/stats/DateNavigator.svelte`, `src/lib/components/stats/ConsoleChrome.svelte`.
|
||||
- **Steps:**
|
||||
1. `+page.server.ts`: if `!isAdmin(locals.user?.email)` → 404 (mirror `/scheduled-verses` behavior). Resolve `date` from `?date=`; default = yesterday. `ensureSnapshot(date)`. Load settings + snapshot + (if `?mature=1`) a matured computation. Return `{ date, generatedAt, buildKind, payload, segments, settings, matured }`.
|
||||
2. Layout: sticky header with `DateNavigator` (prev/next/date input), "Mature this view" toggle (link with `?mature=1`), link to Settings, and an as-of banner: *"Showing {date} · snapshot built {generatedAt}"*.
|
||||
- **Acceptance:** non-admin gets 404; admin sees the banner and can move between dates; days with no snapshot get lazily built (flagged `lazy`).
|
||||
- **Tests:** endpoint/load unit test where feasible.
|
||||
|
||||
### T1.6 — Pulse lab
|
||||
- **Goal:** the 10-second read.
|
||||
- **Files:** `src/lib/components/stats/Pulse.svelte`, `src/lib/components/charts/Sparkline.svelte`, `src/lib/components/charts/StatusPill.svelte`, `src/lib/components/charts/Gauge.svelte` (LayerChart).
|
||||
- **Steps:** render KPI cards (Players today, WAU, MAU, Core, Returning share, D1 return, Net growth) with same-weekday deltas and sparklines; a verdict banner from `signals.ts`.
|
||||
- **Acceptance:** all cards render from the snapshot payload with no live DB query; numbers match a manual query for a test date.
|
||||
|
||||
### T1.7 — Signals engine skeleton
|
||||
- **Goal:** plain-language verdicts + actions.
|
||||
- **Files:** new `src/lib/server/stats/signals.ts`, `src/lib/components/stats/SignalsFeed.svelte`.
|
||||
- **Steps:** implement `evaluateSignals(payload, settings)` returning `{ id, severity, title, detail, action }[]`. Implement the six target rules from the plan plus a generic anomaly detector (trailing-28-day median + MAD, weekday-aware). Thresholds from `settings`.
|
||||
- **Acceptance:** synthesizing payloads produces expected severities; no jargon in strings.
|
||||
- **Tests:** `tests/stats-signals.test.ts`.
|
||||
|
||||
### T1.8 — Settings menu
|
||||
- **Goal:** edit thresholds; re-segment instantly.
|
||||
- **Files:** `src/lib/components/stats/SettingsMenu.svelte`, `src/routes/admin/stats/settings/+page.server.ts` (form action), or an inline panel.
|
||||
- **Steps:** form posts to `updateSettings`; on save, reload the same date (segments are re-derived from `player_state`, so no rebuild needed). Show a note: *"Changing these re-reads history; snapshots themselves are unchanged."* If `configVersion` differs from a snapshot's stored version, show an "outdated snapshot definitions" notice with a rebuild link.
|
||||
- **Acceptance:** changing `core_plays_per_week` updates Core counts across multiple past dates immediately.
|
||||
- **Tests:** settings module already covered; add a segment re-derivation test at two thresholds.
|
||||
|
||||
### T1.9 — Metric glossary panel
|
||||
- **Goal:** plain-language definitions on the page.
|
||||
- **Files:** `src/lib/components/stats/Glossary.svelte`.
|
||||
- **Steps:** render the glossary table from the plan, driven by a constant map so labels stay consistent.
|
||||
- **Acceptance:** every metric label used in charts has a glossary entry.
|
||||
|
||||
**Phase 1 exit criteria:** `/admin/stats` shows Pulse + Signals for any date, snapshot-only loads, settings work, LayerChart installed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Retention Lab & Growth Loop
|
||||
|
||||
### T2.1 — Add LayerChart
|
||||
- **Files:** `package.json` (`bun add layerchart`), lockfile.
|
||||
- **Acceptance:** `bun run build` succeeds; a trivial `<LineChart>` renders.
|
||||
|
||||
### T2.2 — Retention Lab
|
||||
- **Files:** `src/lib/components/stats/RetentionLab.svelte`, charts: `CohortHeatmap.svelte`, `Histogram.svelte`, `StackedArea.svelte`, `BoxPlot.svelte`.
|
||||
- **Steps:** cohort triangle heatmap (weekly cohorts, toggle daily), D1/D7/D30 curves as small multiples, time-to-second-play histogram, segment-mix stacked area, retention-by-segment box/grouped bars. Captions per plan; segments from `player_state`.
|
||||
- **Acceptance:** heatmap cells match a manual cohort calculation; captions plain-language.
|
||||
|
||||
### T2.3 — Growth Loop
|
||||
- **Files:** `src/lib/components/stats/GrowthLoop.svelte`, charts: `AnnotatedArea.svelte`, `Waterfall.svelte`, `Funnel.svelte`.
|
||||
- **Steps:** new-players/day area with 7-day MA; if manual annotations are added later, support marker pins (design the prop now, render none if absent); waterfall (new + resurrected − churned); lifecycle funnel (players reaching 1/2/5/10/25/50 plays); signup funnel using `user.created_at` + `verse_served`/completions.
|
||||
- **Acceptance:** waterfall components sum to net growth; funnel is monotonic non-increasing.
|
||||
|
||||
### T2.4 — Mature view
|
||||
- **Files:** `src/lib/server/stats/snapshot.ts` (add `buildMatured(date)`), `+page.server.ts`.
|
||||
- **Steps:** implement on-demand matured computation (uses all data after `date` for cohort resolution) and clearly label it. Do not store matured results as snapshots.
|
||||
- **Acceptance:** `?mature=1` resolves old D7/D30 where possible; banner says "Matured — uses data after this date."
|
||||
|
||||
**Phase 2 exit criteria:** Retention & Growth labs render from snapshots; mature toggle works.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Verse Mix Lab & Habit Lab
|
||||
|
||||
### T3.1 — Verse Mix Lab
|
||||
- **Files:** `src/lib/components/stats/VerseMixLab.svelte`, charts reuse `StackedBar`, `BubbleScatter.svelte`, `CoverageHeatmap.svelte`, `StepLine.svelte`.
|
||||
- **Steps:** mix balance (book/section/testament stack over time), 66-book coverage heatmap, freshness (repeat intervals + entropy line), retention-by-book bubble scatter, retention-by-guess-bucket, achievability band + step curve, regression alarm table, before/after curated-mix annotated comparison. Include the "giveaway detector" table (high first-try books/windows).
|
||||
- **Acceptance:** Revelation/Genesis flags as easy outliers and Micah as hard on the current data; captions explain each.
|
||||
|
||||
### T3.2 — Habit Lab
|
||||
- **Files:** `src/lib/components/stats/HabitLab.svelte`, charts: `SurvivalLine.svelte`, `Ridgeline.svelte`, `CalendarHeatmap.svelte`, `WeekdayHourHeatmap.svelte`.
|
||||
- **Steps:** streak survival curve, streak length distribution, calendar heatmap, weekday×hour heatmap (note server-time caveat in the caption).
|
||||
- **Acceptance:** survival curve is monotonic; calendar matches completion counts.
|
||||
|
||||
**Phase 3 exit criteria:** all five labs complete; page passes `bun run check`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Delivery
|
||||
|
||||
### T4.1 — Daily digest
|
||||
- **Files:** new `src/lib/server/stats/digest.ts`; call from `src/routes/api/cron/build-stats/+server.ts`.
|
||||
- **Steps:** after a successful build, compose ~6 plain lines (as-of date; DAU/WAU/MAU; new vs returning; D1; top signal; link) and POST to `DISCORD_ADMIN_WEBHOOK`. No-op with a log if unset. Never fail the build on digest failure.
|
||||
- **Acceptance:** running the cron locally posts the digest (or logs a skip); build still succeeds if the webhook errors.
|
||||
- **Tests:** unit test message composition.
|
||||
|
||||
### T4.2 — Slim public community widget
|
||||
- **Files:** rewrite `src/routes/global/+page.server.ts` and `+page.svelte` (or create a `CommunityStats.svelte` embedded on `/about`).
|
||||
- **Steps:** read the **latest snapshot** and render only: all-time completions, players this week/month, yesterday's completions + average guesses, 30-day calendar heatmap. Label "as of {date}." No sensitive metrics. Mobile-friendly.
|
||||
- **Acceptance:** public page contains no retention/churn/signup/revenue figures; loads without heavy queries (reads snapshot).
|
||||
|
||||
### T4.3 — Remove/replace any lingering `/global` references
|
||||
- **Steps:** update README route table and any nav links. Keep old `+page.server.ts` logic out of the new console.
|
||||
- **Acceptance:** README matches reality.
|
||||
|
||||
### T4.4 — Docs & env
|
||||
- **Steps:** update `README.md` (routes, API, schema, env: `DISCORD_ADMIN_WEBHOOK`), and `todo.md` if relevant.
|
||||
- **Acceptance:** a new contributor can find `/admin/stats` and the cron endpoint from the README.
|
||||
|
||||
**Phase 4 exit criteria:** digest + public widget live; docs current.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Deferred / roadmap (do not build unless approved)
|
||||
|
||||
- **C — feature-usage events:** emit `mode_enter` (imposter/practice), `hint_reveal`, `progress_view`, `submit_verse_*`; add the Feature Impact panel.
|
||||
- **Manual marketing annotations:** small table + UI pins on the Growth Loop timeline.
|
||||
- **CSV/JSON export** of snapshot payloads.
|
||||
- **Email digest** (requires `nodemailer`).
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting requirements
|
||||
|
||||
### C1 — Testing
|
||||
- All `src/lib/server/stats/*.ts` pure logic must have unit tests with fixtures (no real DB) where possible.
|
||||
- DB-touching functions tested against `TEST_DATABASE_URL`.
|
||||
- Maintain the phase verification gate: `bun run check && bun test && bun run build`.
|
||||
|
||||
### C2 — Accessibility & mobile
|
||||
- Charts keyboard-navigable where interactive; provide text/table fallbacks for color-only heatmaps.
|
||||
- Responsive: KPI grid 2-col on mobile, charts fluid width; tables horizontally scrollable.
|
||||
- Respect the existing dark theme (`bg-white/10 dark:bg-white/5`, etc.) and the `Container`/`CollapsibleTable` components.
|
||||
|
||||
### C3 — Performance
|
||||
- Snapshot build is a batch job; acceptable to scan tables. Page loads must not scan raw tables.
|
||||
- Keep `player_state` bounded (~180-day active window); JSON may be gzipped if it grows.
|
||||
|
||||
### C4 — Copy & tone
|
||||
- Every chart caption ≤ 2 short sentences.
|
||||
- No metric named "rate of change," "velocity," or "acceleration."
|
||||
- Always include a comparison and a "what to do."
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done (overall)
|
||||
|
||||
1. `/admin/stats` is admin-only and serves a once-daily, as-of-yesterday snapshot without live DB reads.
|
||||
2. Any past date can be viewed (point-in-time), with `—` where data didn't exist.
|
||||
3. "Mature this view" works and is clearly labeled.
|
||||
4. All five labs + Pulse + Signals + Glossary render from snapshots using LayerChart; layouts are mobile-friendly.
|
||||
5. Segment thresholds are editable in-app and re-derive all history instantly.
|
||||
6. Instrumentation A/B/D/E/F + `user.created_at` actively record data.
|
||||
7. Daily digest posts; slim public widget shows only safe aggregates.
|
||||
8. `bun run check`, `bun test`, `bun run build` all pass; README/env docs updated.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Snapshot `payload` shape (extend per phase)
|
||||
|
||||
```ts
|
||||
interface SnapshotPayload {
|
||||
asOfDate: string;
|
||||
activity: { dau: number; wau: number; mau: number; core: number; returningShare: number; newPlayers: number; resurrected: number; churned: number; netGrowth: number };
|
||||
dailySeries: { date: string; completions: number; newPlayers: number; started: number }[]; // last ~90d
|
||||
kpis: { d1Return: number | null; return7: number | null; overallReturnRate: number; completionRate: number | null; shareRate: number | null; firstTryRate: number; avgGuesses: number };
|
||||
retention: { cohorts: { weekStart: string; sizes: number[]; rates: (number|null)[] }[]; curves: { d1: Series; d7: Series; d30: Series }; timeToSecondPlay: Bin[]; segmentMix: { date: string; new: number; returning: number; resurrected: number; atRisk: number; churned: number }[] };
|
||||
verseMix: { coverage: { bookId: string; days: number }[]; sectionMix: Series[]; entropy: Series; repeatIntervals: number[]; firstTryByBook: { bookId: string; plays: number; firstTryPct: number; avgGuesses: number }[]; retentionByBook: { bookId: string; d1: number; plays: number }[]; retentionByGuessBucket: { bucket: string; d1: number }[] };
|
||||
habit: { streakSurvival: { length: number; p: number }[]; streakLengths: Bin[]; calendar: { date: string; count: number }[]; weekdayHour: number[][] };
|
||||
signals: Signal[];
|
||||
}
|
||||
```
|
||||
|
||||
## Appendix B — Event names (allowlisted)
|
||||
|
||||
`verse_served` · `share` · `share_copy` · `first_visit` · `donate_click` · `discord_click`
|
||||
Metadata examples: share `{ snippet: boolean }`; first_visit `{ referrer, utm_source, utm_medium, utm_campaign }`.
|
||||
|
||||
## Appendix C — Settings keys (defaults)
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `core_plays_per_week` | 4 | Core player threshold |
|
||||
| `churn_days` | 30 | Days inactive → churned |
|
||||
| `resurrect_gap_days` | 30 | Gap that makes a return "resurrected" |
|
||||
| `at_risk_days` | 8 | Days inactive → at-risk |
|
||||
| `active_window_days` | 180 | Player-state horizon stored per snapshot |
|
||||
| `d1_target` | 20 | D1 return target (%) |
|
||||
| `return7_target` | 25 | 7-day return target (%) |
|
||||
| `completion_target` | 70 | Started→completed target (%) |
|
||||
| `first_try_min` / `first_try_max` | 15 / 35 | Achievability band (%) |
|
||||
| `share_target` | 25 | Share rate target (%) |
|
||||
| `anomaly_mad_threshold` | 2 | Robust z-score cutoff |
|
||||
|
||||
## Appendix D — Signal rule template
|
||||
|
||||
```ts
|
||||
{ id: 'd1_return_low', severity: 'warn',
|
||||
title: 'New players are not coming back',
|
||||
detail: `D1 return is ${v}% (target ${t}%).`,
|
||||
action: 'Test a "come back tomorrow" prompt on the win screen.' }
|
||||
```
|
||||
@@ -0,0 +1,340 @@
|
||||
# Bibdle Statistics Dashboard — Plan
|
||||
|
||||
**Status:** design agreed with the project owner · **Supersedes:** the public `/global` dashboard
|
||||
**Deliverable:** a private, once-daily, point-in-time **Growth Console** at `/admin/stats`, plus a slim public community-stats widget.
|
||||
|
||||
> **Implementing this plan?** Use the agent-ready task list in [`statistics-implementation-checklist.md`](./statistics-implementation-checklist.md). It is ordered, dependency-aware, and includes acceptance criteria and tests per task.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why we're replacing `/global`
|
||||
|
||||
`/global` was a good start but it is now a wall of aggregates and second derivatives ("completions velocity", "user acceleration") with **no verdict, no feature data, and no content data**. It answers *"did a number move?"* but not *"why, and what should I change?"*
|
||||
|
||||
Three structural problems:
|
||||
|
||||
1. **No "so what."** Every chart is descriptive. Nothing tells you what to improve, change, or add.
|
||||
2. **No product surface.** There is no read on features (modes, hints, sharing), funnels (started → completed → shared), or content (which books/verses help or hurt retention).
|
||||
3. **Wrong delivery.** It queries the DB on every load, shows partial "today" data, and offers no time machine.
|
||||
|
||||
**Goal of the new console:** a product review you can skim in 60 seconds, ordered around the questions that matter, that ends every section with a plain-language verdict and recommended action — and that can be scrolled back to any past day.
|
||||
|
||||
### Live baseline (pulled from bibdle.com/global, as of 2026-09-15)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| All-time completions | 31,775 |
|
||||
| Unique players (all-time) | 6,589 |
|
||||
| Registered users | 98 |
|
||||
| DAU / WAU / MAU | 102 / 363 / 852 |
|
||||
| Net player growth (7d) | ~flat (−1: 175 new vs 176 churned) |
|
||||
| Overall return rate (ever played 2×) | 22.8% |
|
||||
| 7-day new-player return rate | 12.2% |
|
||||
| Survival after 2 plays → 3rd | 67.2% |
|
||||
| 7-day / 30-day retention | ~61% / ~51% |
|
||||
| Difficulty | mean 4.13 guesses, median 3, **33.7% first-try** |
|
||||
| Easiest / hardest books | Revelation 1.34 avg / 83% first-try · Micah 7.84 avg / 12% first-try |
|
||||
|
||||
**The core story:** acquisition is replacing churn but not growing. ~77% of all-time players played exactly once; the ~23% who return are very sticky. The leak is the **first session** (and possibly verse mix/achievability), not the endgame.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions locked with the owner
|
||||
|
||||
| # | Decision | Choice |
|
||||
|---|---|---|
|
||||
| 1 | Audience | **Private founder dashboard**, replacing `/global` internally. Reuse `ADMIN_EMAIL` gate (as `/scheduled-verses` does). |
|
||||
| 2 | Public presence | Keep a **slim public community-stats widget** (no sensitive/business data). |
|
||||
| 3 | Freshness | **Once-daily snapshot, "as of yesterday."** Page load never queries the DB. |
|
||||
| 4 | History | **Point-in-time by default, with a "Mature this view" toggle.** |
|
||||
| 5 | Snapshot storage | **Store one immutable snapshot per day** (fidelity + speed + no-data fallbacks). |
|
||||
| 6 | Config | Segment thresholds configurable at **render time** via a settings menu (env defaults). Requires storing compact per-player state in snapshots. |
|
||||
| 7 | Data sources | **Existing SQLite only.** No Umami, no Rybbit, self-contained and durable. |
|
||||
| 8 | North star | Lead with **DAU + MAU**, with **Core players (4+/7d)** as the durability signal. |
|
||||
| 9 | Signals | **Both:** explicit targets for ~6 core metrics + anomaly detection everywhere else. |
|
||||
| 10 | Charts | **LayerChart** (Svelte 5 native; verified `layerchart@2.5.0` peers `svelte ^5`). |
|
||||
| 11 | Tone | **Plain language.** Short factual sentences. No jargon, no paragraphs next to charts. |
|
||||
| 12 | Mobile | Must be **mobile-friendly**. |
|
||||
| 13 | Digest | **Scheduled daily digest** of the snapshot (Discord admin webhook default; email optional). |
|
||||
| 14 | Instrumentation approved | **A, B, D, E, F** + `user.created_at` (see §7). **C deferred** (roadmap). |
|
||||
|
||||
**Design principles (apply to every chart):**
|
||||
- Every visual has a **plain-English caption** and, where useful, a **one-line "what to do."**
|
||||
- Never show a number without a **comparison** (previous period, same weekday, or baseline).
|
||||
- **Weekday-align** daily comparisons so weekends don't masquerade as growth.
|
||||
- Prefer **shape** over derivatives: show "players per day" and a trend line, not "acceleration."
|
||||
|
||||
---
|
||||
|
||||
## 3. Information architecture
|
||||
|
||||
```
|
||||
/admin/stats
|
||||
├─ Global chrome: date navigator · "Mature this view" toggle · Settings · as-of banner
|
||||
├─ 1. Pulse (10-second read + verdict)
|
||||
├─ 2. Growth Loop (new · active · returning · churned · funnel)
|
||||
├─ 3. Retention Lab (cohorts, curves, time-to-return, segments)
|
||||
├─ 4. Verse Mix Lab (coverage, freshness, retention by book & difficulty)
|
||||
├─ 5. Habit Lab (streaks, calendar, timing)
|
||||
├─ 6. Signals & Actions (rule-based verdicts + recommended actions)
|
||||
├─ 7. Feature Impact (stub until C is instrumented)
|
||||
└─ 8. Notes & Glossary (plain-language metric definitions)
|
||||
```
|
||||
|
||||
The header is a **time machine**: a date picker plus prev/next arrows, always labeled e.g. *"Showing 2026-09-14 (snapshot built 2026-09-15)."* Days before a metric existed show **"—"**.
|
||||
|
||||
---
|
||||
|
||||
## 4. The labs (what you see, why, and the chart type)
|
||||
|
||||
Chart types in **bold**. Every section ends in a one-sentence verdict from the Signals engine.
|
||||
|
||||
### 4.1 Pulse
|
||||
|
||||
| Element | Plain-English caption | Chart |
|
||||
|---|---|---|
|
||||
| Players today | "People who finished yesterday's verse." + same-weekday change | **KPI card + sparkline** |
|
||||
| Players this week / month | "Distinct players in the last 7 / 30 days." | **KPI card + delta** |
|
||||
| Core players | "Players who finished 4+ times this week — your habit-holders." | **KPI card + trend** |
|
||||
| Returning share | "Of yesterday's players, how many had played before." | **KPI + mini stacked bar** |
|
||||
| D1 return rate | "Of people who played for the first time, how many came back the next day." | **KPI + gauge vs target** |
|
||||
| Net player growth (7d) | "New and returning players minus those who stopped." | **KPI + waterfall delta** |
|
||||
| Verdict banner | e.g. "Stable. Biggest leak: 88% of new players never return." | **status pill** |
|
||||
|
||||
*Decision it informs:* which lab to open; whether the week was a win.
|
||||
|
||||
### 4.2 Growth Loop
|
||||
|
||||
| Element | Plain-English caption | Chart |
|
||||
|---|---|---|
|
||||
| New players per day | "First-time players each day." Annotated with HN/Bluesky/Discord posts. | **Annotated area + marker pins** |
|
||||
| Acquisition vs churn | "New + resurrected − churned = net." | **Waterfall** |
|
||||
| Lifecycle funnel | "Of everyone who ever played: how many returned 2×, 5×, 10×, 25×, 50×." | **Funnel** |
|
||||
| Retention curves | D1 / D7 / D30 over time. | **Small-multiple line** |
|
||||
| Signup funnel | "Of first-time players, how many created an account." (needs `user.created_at` + starts) | **Funnel / line** |
|
||||
|
||||
*Decision it informs:* is the problem acquisition or activation; did a marketing post actually move retention, not just traffic.
|
||||
|
||||
### 4.3 Retention Lab
|
||||
|
||||
| Element | Plain-English caption | Chart |
|
||||
|---|---|---|
|
||||
| Cohort retention triangle | "Each row is a starting week; each column is weeks later. Darker = more came back." | **Heatmap** |
|
||||
| Time-to-second-play | "How many days until a first-timer plays again." | **Histogram** |
|
||||
| Segment mix over time | New · Returning · Resurrected · At-risk · Churned. | **Stacked area** |
|
||||
| Retention by segment | "Do account-holders / core players stick longer?" | **Box plot or grouped bars** |
|
||||
| Overall return rate | "Share of all players who ever played more than once." | **KPI + target** |
|
||||
|
||||
*Decision it informs:* whether onboarding/notification changes bend retention; which segment is healthiest.
|
||||
|
||||
### 4.4 Verse Mix Lab
|
||||
|
||||
This lab treats **verse mix and appealability** (not raw difficulty) as the retention lever. Hard *and* easy days can both drive churn — one by frustration, one by boredom.
|
||||
|
||||
| Element | Plain-English caption | Chart |
|
||||
|---|---|---|
|
||||
| Mix balance over time | "Are we spreading across all 66 books / 9 sections / 2 testaments?" | **Stacked bars / treemap** |
|
||||
| Coverage heatmap | 66 books × times featured. | **Heatmap** |
|
||||
| Freshness | "How often the same book or window repeats." (repeat interval + entropy) | **Histogram + line** |
|
||||
| Retention by book | For each book, the next-day return rate of players who got it. | **Bubble scatter** (x=plays, y=D1 return, size=first-try%) |
|
||||
| Retention by difficulty | "Do easy or hard days make people leave?" | **Line/grouped bars by guess bucket** |
|
||||
| Achievability band | First-try % and solve-by-N against a target band. | **Histogram + step line** |
|
||||
| Regression alarms | Books/windows whose cohort churns above average. | **Table with inline bars** |
|
||||
| Before/after the curated mix system | Same metrics pre- vs post-launch. | **Annotated line** |
|
||||
|
||||
*Decision it informs:* whether the new curated verse-mix system works; which books/windows to retire or feature more; submissions policy.
|
||||
|
||||
### 4.5 Habit Lab
|
||||
|
||||
| Element | Plain-English caption | Chart |
|
||||
|---|---|---|
|
||||
| Streak survival | "If you've played N days in a row, how likely are you to play day N+1." | **Survival line** |
|
||||
| Streak lengths | Distribution of current streaks. | **Ridgeline / histogram** |
|
||||
| Calendar heatmap | Daily completions, GitHub-style. | **Calendar heatmap** |
|
||||
| Weekday × hour | When people play. (Note: `completedAt` is server time, approximate.) | **2-D heatmap** |
|
||||
|
||||
*Decision it informs:* whether to invest in streaks, reminders, and Discord/content timing.
|
||||
|
||||
### 4.6 Signals & Actions
|
||||
Plain-language cards, each: **metric · current vs target · trend · interpretation · suggested action.**
|
||||
> Example: *"D1 return is 12% (target 20%). Most first-timers never see day two. → Test a 'come back tomorrow' prompt on the win screen."*
|
||||
> Example: *"Revelation averages 1.3 guesses (83% first-try). Likely self-revealing text. → Exclude or down-weight for the curated mix."*
|
||||
|
||||
### 4.7 Feature Impact (stub)
|
||||
Since feature-usage events (C) are deferred, this ships as a **"needs data"** panel listing exactly what would be shown once C is added (Imposter entries, hint reveals, progress-page visits, submissions).
|
||||
|
||||
---
|
||||
|
||||
## 5. Plain-language metric glossary (shown in the page)
|
||||
|
||||
Each metric has a friendly name, a one-sentence definition, and a "what to do."
|
||||
|
||||
| Metric | Plain definition | What to do if it's off |
|
||||
|---|---|---|
|
||||
| Players today | Finished the puzzle yesterday | Investigate content or traffic |
|
||||
| WAU / MAU | Played at least once in 7 / 30 days | Reach vs habit |
|
||||
| Core players | 4+ finishes this week | If falling, retention problem |
|
||||
| Returning share | Played before vs first time | Low = acquisition-heavy, leaky |
|
||||
| D1 return | First-timers who came back next day | The #1 activation lever |
|
||||
| Return rate (any) | Played again within N days | Onboarding quality |
|
||||
| Overall return rate | Ever played more than once | Overall stickiness |
|
||||
| Net growth | New + resurrected − churned | Is the base expanding? |
|
||||
| Completion rate | Started → finished | Friction/confusion (needs starts) |
|
||||
| Signup rate | Players who made an account | Account value |
|
||||
| First-try rate | Solved with no wrong guesses | Too high = giveaway; too low = brutal |
|
||||
| Share rate | Shares ÷ finishes | Virality (needs D) |
|
||||
| Streak survival | Chance a streak continues | Streak feature ROI |
|
||||
|
||||
---
|
||||
|
||||
## 6. Data model & architecture
|
||||
|
||||
### 6.1 New tables (all additive)
|
||||
|
||||
**`stats_snapshot`** — one immutable row per as-of day.
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `date` | text PK | as-of date `YYYY-MM-DD` |
|
||||
| `generated_at` | integer | build time |
|
||||
| `build_kind` | text | `scheduled` \| `lazy` \| `reconstructed` |
|
||||
| `config_version` | text | hash of thresholds used at build |
|
||||
| `payload` | text (JSON) | all aggregate series/lab data |
|
||||
| `player_state` | text (JSON, gzip) | compact per-player state as of that day, for render-time segmentation |
|
||||
| `config` | text (JSON) | copy of thresholds used |
|
||||
|
||||
**`analytics_events`** — generic, extensible event log (unlocks B/D/E/F now, C later).
|
||||
| Column | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | text PK | |
|
||||
| `name` | text | `verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`, … |
|
||||
| `anonymous_id` | text | pseudonymous |
|
||||
| `user_id` | text, nullable | |
|
||||
| `date` | text, nullable | game date when relevant |
|
||||
| `created_at` | integer | server timestamp |
|
||||
| `metadata` | text (JSON) | referrer/utm, snippet flag, channel… |
|
||||
|
||||
Indexes: `(name, date)`, `(anonymous_id, date)`. **Unique** on `(name, anonymous_id, date)` for `verse_served` (dedupes reloads → "players who started").
|
||||
|
||||
**`dashboard_settings`** — key/value (e.g. `core_plays_per_week`, `churn_days`, `resurrect_gap_days`, `d1_target`, `first_try_band`, `share_target`). Env vars provide defaults; the settings menu writes here.
|
||||
|
||||
**`user.created_at`** — new timestamp column (see §7).
|
||||
|
||||
### 6.2 Snapshot build
|
||||
- **Trigger:** daily cron hits a protected `POST /api/cron/build-stats` (bearer `CRON_SECRET`, same pattern as `/api/send-daily-verse`). It builds yesterday's snapshot and sends the digest.
|
||||
- **Lazy fallback:** `/admin/stats` load builds a missing snapshot on demand (guarded against races, marked `lazy`).
|
||||
- **Backfill:** an admin action/script can reconstruct a date range from raw data (marked `reconstructed`).
|
||||
- **Computation cutoff:** only data with `date <= asOfDate` (and `completedAt` before end of that day) — guarantees point-in-time.
|
||||
- **Player state (for render-time config):** per player active in the trailing ~180 days, a compact tuple `[anonId, firstDate, lastDate, totalPlays, plays7, plays30, currentStreak]`. Thresholds applied at render time, so changing them re-segments **all history instantly** while the state stays frozen as of that day.
|
||||
- **Retention/pruning:** keep all snapshots (small); optionally prune `player_state` older than a configurable horizon later.
|
||||
|
||||
### 6.3 "Mature this view"
|
||||
A toggle (off by default). When on, the selected date's metrics are recomputed on demand using data *after* that date (so old cohorts have resolved D7/D30), clearly labeled **"Matured — uses data after this date."** This is the only mode allowed to query the DB directly.
|
||||
|
||||
### 6.4 Auth & privacy
|
||||
- `/admin/stats` gated to `ADMIN_EMAIL` via existing `src/lib/server/admin.ts`.
|
||||
- Aggregate-only rendering; never display emails or raw IDs. Anonymous IDs are pseudonymous.
|
||||
- `/api/stats` name already exists (per-user stats) — the new snapshot endpoints will use distinct names (`/api/admin/stats/...`) to avoid collision.
|
||||
|
||||
### 6.5 Public community widget
|
||||
Redesigned slim `/global` (or an embeddable component) showing only: all-time completions, players this week/month, yesterday's completions and average guesses, and a 30-day calendar heatmap. **No** retention, churn, signup, or revenue data. Labeled "as of yesterday."
|
||||
|
||||
---
|
||||
|
||||
## 7. Instrumentation plan (approved)
|
||||
|
||||
| ID | What | Where it wires in | Cost |
|
||||
|---|---|---|---|
|
||||
| **A** | Store guess sequences for **all** players | `src/routes/api/submit-completion/+server.ts` already persists `guesses` when provided; confirm the client always sends them and remove any conditional nulling. Historical rows stay null (correct point-in-time). | Tiny |
|
||||
| **B** | Log "verse served" (game started) | Client generates/reads `bibdle-anonymous-id` in `src/routes/+page.ts` and sends it in the `POST /api/daily-verse` body; endpoint records a `verse_served` event, unique per `(anonymous_id, date)`. Gives start→completion funnel and bounce. | Small |
|
||||
| **D** | Share / virality events | In `src/lib/utils/share.ts` / `WinScreen.svelte`, record `share` and `share_copy` events (with `snippet` flag) via a tiny client helper. Enables share rate + viral proxy. | Small |
|
||||
| **E** | Referrer / UTM capture | On first visit (client), read `document.referrer` and `?ref=`/`?utm_source=`; store a `first_visit` event keyed to the anonymous ID. Enables acquisition source. | Small |
|
||||
| **F** | Monetization clicks | Record `donate_click` and `discord_click` events from the win-screen/footer links. Enables the patron funnel. | Tiny |
|
||||
| **G** | `user.created_at` | Add column + set on signup in `src/lib/server/auth.ts` (and OAuth paths). Existing users show "unknown." Enables signup-per-day, time-to-signup, and the visitor→signup funnel. | Small |
|
||||
|
||||
**Deferred (roadmap):** **C — feature-usage event log** (Imposter/hints/progress/submissions). The `analytics_events` table makes this a trivial follow-on; only the emit points need adding.
|
||||
|
||||
---
|
||||
|
||||
## 8. Signals engine (targets + anomaly detection)
|
||||
|
||||
**Explicit targets (defaults; editable in Settings):**
|
||||
|
||||
| Metric | Target | Red |
|
||||
|---|---|---|
|
||||
| D1 return rate | ≥ 20% | < 12% |
|
||||
| 7-day return (any) | ≥ 25% | < 15% |
|
||||
| Completion rate (started→finished) | ≥ 70% | < 55% |
|
||||
| First-try rate (achievability band) | 15–35% | > 45% or < 8% |
|
||||
| Share rate | ≥ 25% | < 15% |
|
||||
| Core players (4+/7d) | non-declining WoW | down ≥ 2 weeks |
|
||||
|
||||
*Completion-rate and share-rate targets are placeholders until 2 weeks of new data exist.*
|
||||
|
||||
**Anomaly detection (everything else):** compare each metric to its own trailing 28-day median (weekday-aware), flag moves beyond a robust threshold (e.g. > 2× MAD). Output is a plain sentence, not a chart annotation.
|
||||
|
||||
Each rule = `{ metric, comparator, target, severity, interpretation, recommended_action }`. Stored as code (not user-editable), with thresholds from settings.
|
||||
|
||||
---
|
||||
|
||||
## 9. Chart inventory & LayerChart mapping
|
||||
|
||||
| Chart | Used in | LayerChart approach |
|
||||
|---|---|---|
|
||||
| Sparkline KPI | Pulse | minimal `<LineChart>` |
|
||||
| Annotated area | Growth Loop | `<AreaChart>` + marker overlay |
|
||||
| Waterfall | Growth Loop | floating `<Bar>` segments |
|
||||
| Funnel | Growth Loop, signups | stacked `<Rect>`/`<Bar>` |
|
||||
| Cohort heatmap | Retention | `<Chart>` + grid of `<Rect>` |
|
||||
| Calendar heatmap | Habit, public widget | `<Chart>` + weekday×week `<Rect>` grid |
|
||||
| 2-D heatmap | Habit | `<Chart>` + `<Rect>` grid |
|
||||
| Bubble scatter | Verse Mix | `<ScatterChart>` with radius scale |
|
||||
| Histogram | Retention, Verse Mix, Habit | `<BarChart>` from binned data |
|
||||
| Step / survival line | Verse Mix, Habit | `<LineChart>` with step interpolation |
|
||||
| Ridgeline | Habit | stacked `<AreaChart>`/density |
|
||||
| Box plot | Retention | custom `<Rect>`+`<Line>` |
|
||||
| Stacked area / bars | Retention, Verse Mix | `<AreaChart>`/`<BarChart>` stacked |
|
||||
| Gauge / status pill | Pulse | custom `<Chart>` arc + badge |
|
||||
| Table with inline bars | Verse Mix, Signals | existing `CollapsibleTable` |
|
||||
|
||||
**Rendering model:** the page fetches the day's snapshot once (already computed), then renders charts client-side. No live DB reads in the normal path → matches the "once per day" requirement.
|
||||
|
||||
---
|
||||
|
||||
## 10. Delivery: daily digest
|
||||
|
||||
- Runs right after the snapshot build (same cron endpoint), once per day.
|
||||
- **Default channel:** a dedicated Discord admin webhook (`DISCORD_ADMIN_WEBHOOK`), reusing the existing webhook pattern. Optionally email later (note: `nodemailer` is **not** currently a dependency; adding email = adding a dependency).
|
||||
- **Content (plain, ~6 lines):** as-of date · DAU/WAU/MAU · new vs returning · D1 · the single top signal · link to `/admin/stats`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phasing
|
||||
|
||||
| Phase | Scope |
|
||||
|---|---|
|
||||
| **0 — Plumbing** | Add `stats_snapshot`, `analytics_events`, `dashboard_settings`, `user.created_at`; wire instrumentation A/B/D/E/F; client event helper. |
|
||||
| **1 — Core** | Snapshot builder + cron/lazy; date navigator; Pulse; Signals skeleton; Settings menu; plain-language glossary. |
|
||||
| **2 — Retention & Growth** | Retention Lab (cohort triangle, curves, time-to-return, segments) + Growth Loop (waterfall, funnel, annotated area, signup funnel). |
|
||||
| **3 — Content & Habit** | Verse Mix Lab (coverage, freshness, retention-by-book/difficulty, before/after) + Habit Lab (streaks, calendar, timing). |
|
||||
| **4 — Delivery** | Daily digest; slim public community widget; "Mature this view". |
|
||||
| **5 — Roadmap** | Feature-usage events (C) + Feature Impact panel; manual marketing-event annotations; CSV/JSON export; optional email digest. |
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions / to finalize during build
|
||||
|
||||
1. **Digest channel:** Discord admin webhook (default) vs. adding nodemailer for email.
|
||||
2. **Target values** in §8 — confirm after ~2 weeks of instrumented data.
|
||||
3. **Snapshot retention:** keep `player_state` forever vs. prune beyond a horizon.
|
||||
4. **Public widget placement:** standalone `/global` vs. a section on `/about` vs. both.
|
||||
5. **Manual marketing annotations** (nice-to-have, not in approved list): a tiny table to pin HN/Bluesky/Discord posts on the Growth Loop timeline.
|
||||
6. **C (feature-usage events):** decide when to add — the table is ready, only emit points remain.
|
||||
|
||||
---
|
||||
|
||||
## 13. Success criteria for this dashboard
|
||||
|
||||
- You can answer, in under 60 seconds and without jargon: *are we growing, who's returning, and what's the one thing to fix?*
|
||||
- The console tells you whether a **verse-mix** change helped retention, not just whether guesses changed.
|
||||
- You can scroll to any past day and see what was true then, with honest "—" where data didn't exist.
|
||||
- Adding a future metric (e.g. feature events) requires only emitting events — no rebuild of the dashboard architecture.
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
cdata,
|
||||
escapeXml,
|
||||
feedFingerprint,
|
||||
formatRFC822,
|
||||
formatReadableDate,
|
||||
formatVerseText,
|
||||
latestModification,
|
||||
todayInTimeZone
|
||||
} from '$lib/server/rss';
|
||||
|
||||
describe('RSS date formatting', () => {
|
||||
test('formatReadableDate uses the calendar day, independent of server TZ', () => {
|
||||
expect(formatReadableDate('2026-09-18')).toBe('September 18, 2026');
|
||||
expect(formatReadableDate('2026-01-01')).toBe('January 1, 2026');
|
||||
});
|
||||
|
||||
test('formatRFC822 emits a valid GMT timestamp', () => {
|
||||
expect(formatRFC822('2026-01-15')).toBe('Thu, 15 Jan 2026 05:00:00 GMT');
|
||||
expect(formatRFC822('2026-09-18')).toBe('Fri, 18 Sep 2026 04:00:00 GMT');
|
||||
});
|
||||
|
||||
test('formatRFC822 handles daylight saving (EST -05:00 vs EDT -04:00)', () => {
|
||||
// Winter: midnight New York is 05:00 UTC.
|
||||
expect(formatRFC822('2026-01-15')).toContain('05:00:00 GMT');
|
||||
// Summer: midnight New York is 04:00 UTC.
|
||||
expect(formatRFC822('2026-07-15')).toContain('04:00:00 GMT');
|
||||
// Never a bare, ambiguous abbreviation like EST/EDT.
|
||||
expect(formatRFC822('2026-07-15')).not.toMatch(/\bE[SD]T\b/);
|
||||
});
|
||||
|
||||
test('todayInTimeZone respects the reference timezone', () => {
|
||||
// 02:00 UTC on the 23rd is still the evening of the 22nd in New York.
|
||||
expect(
|
||||
todayInTimeZone('America/New_York', new Date('2026-09-23T02:00:00Z'))
|
||||
).toBe('2026-09-22');
|
||||
// 04:00 UTC on the 23rd is midnight in New York.
|
||||
expect(
|
||||
todayInTimeZone('America/New_York', new Date('2026-09-23T04:00:00Z'))
|
||||
).toBe('2026-09-23');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RSS content fingerprint', () => {
|
||||
const row = { date: '2026-09-18', reference: 'Leviticus 19:9-11', verseText: 'a' };
|
||||
|
||||
test('is stable for identical content', () => {
|
||||
expect(feedFingerprint([row])).toBe(feedFingerprint([{ ...row }]));
|
||||
});
|
||||
|
||||
test('changes when verse text changes', () => {
|
||||
expect(feedFingerprint([row])).not.toBe(feedFingerprint([{ ...row, verseText: 'b' }]));
|
||||
});
|
||||
|
||||
test('changes when the set of days changes', () => {
|
||||
expect(feedFingerprint([row])).not.toBe(
|
||||
feedFingerprint([row, { ...row, date: '2026-09-17' }])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestModification', () => {
|
||||
const fallback = new Date('2026-09-18T00:00:00Z');
|
||||
|
||||
test('returns the newest createdAt', () => {
|
||||
const older = new Date('2026-09-16T10:00:00Z');
|
||||
const newer = new Date('2026-09-17T10:00:00Z');
|
||||
expect(
|
||||
latestModification([{ createdAt: older }, { createdAt: newer }], fallback)
|
||||
).toEqual(newer);
|
||||
});
|
||||
|
||||
test('falls back when rows have no timestamp', () => {
|
||||
expect(latestModification([{ createdAt: null }], fallback)).toEqual(fallback);
|
||||
expect(latestModification([], fallback)).toEqual(fallback);
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML serialization helpers', () => {
|
||||
test('escapeXml escapes all predefined entities', () => {
|
||||
expect(escapeXml(`<a href="x">&'</a>`)).toBe(
|
||||
'<a href="x">&'</a>'
|
||||
);
|
||||
});
|
||||
|
||||
test('cdata splits embedded CDATA terminators', () => {
|
||||
expect(cdata('a]]>b')).toBe('<![CDATA[a]]]]><![CDATA[>b]]>');
|
||||
});
|
||||
|
||||
test('formatVerseText capitalizes and replaces trailing punctuation', () => {
|
||||
expect(formatVerseText('for God so loved the world,')).toBe('For God so loved the world...');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 —%');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { deriveSegments, countSegments } from '$lib/server/stats/segments';
|
||||
import { SETTINGS_DEFAULTS, type PlayerState, type StatsSettings } from '$lib/types/stats';
|
||||
import { addDays } from '$lib/server/stats/dates';
|
||||
|
||||
const AS_OF = '2026-09-15';
|
||||
|
||||
function settings(overrides: Partial<StatsSettings> = {}): StatsSettings {
|
||||
return { ...SETTINGS_DEFAULTS, ...overrides };
|
||||
}
|
||||
|
||||
function player(id: string, overrides: Partial<PlayerState>): PlayerState {
|
||||
return {
|
||||
id,
|
||||
firstDate: addDays(AS_OF, -100),
|
||||
lastDate: AS_OF,
|
||||
totalPlays: 10,
|
||||
plays7: 2,
|
||||
plays30: 10,
|
||||
streak: 2,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
const players: PlayerState[] = [
|
||||
player('new', { firstDate: addDays(AS_OF, -1), lastDate: AS_OF, plays7: 1 }),
|
||||
player('core', { lastDate: AS_OF, plays7: 5 }),
|
||||
player('resurrected', { lastDate: AS_OF, plays7: 2, plays30: 2 }),
|
||||
player('returning', { lastDate: AS_OF, plays7: 2, plays30: 10 }),
|
||||
player('atRisk', { lastDate: addDays(AS_OF, -10) }),
|
||||
player('churned', { lastDate: addDays(AS_OF, -40) })
|
||||
];
|
||||
|
||||
describe('deriveSegments', () => {
|
||||
test('classifies each lifecycle segment', () => {
|
||||
const sets = deriveSegments(players, AS_OF, settings());
|
||||
expect(sets.new).toEqual(['new']);
|
||||
expect(sets.core).toEqual(['core']);
|
||||
expect(sets.resurrected).toEqual(['resurrected']);
|
||||
expect(sets.returning).toEqual(['returning']);
|
||||
expect(sets.atRisk).toEqual(['atRisk']);
|
||||
expect(sets.churned).toEqual(['churned']);
|
||||
expect(sets.active).toEqual(['new', 'core', 'resurrected', 'returning']);
|
||||
});
|
||||
|
||||
test('changing the core threshold re-segments without new data', () => {
|
||||
const strict = deriveSegments(players, AS_OF, settings({ core_plays_per_week: 6 }));
|
||||
expect(strict.core).toEqual([]);
|
||||
expect(strict.returning).toContain('core');
|
||||
|
||||
const lenient = deriveSegments(players, AS_OF, settings({ core_plays_per_week: 2 }));
|
||||
expect(lenient.core).toContain('core');
|
||||
expect(lenient.core).toContain('returning');
|
||||
});
|
||||
|
||||
test('counts include every player exactly once across mutually exclusive sets', () => {
|
||||
const counts = countSegments(deriveSegments(players, AS_OF, settings()));
|
||||
const activeTotal = counts.new + counts.core + counts.returning + counts.resurrected;
|
||||
expect(activeTotal).toBe(counts.active);
|
||||
expect(counts.total).toBe(players.length);
|
||||
});
|
||||
|
||||
test('at-risk and churned boundaries follow settings', () => {
|
||||
const sets = deriveSegments(players, AS_OF, settings({ at_risk_days: 5, churn_days: 20 }));
|
||||
expect(sets.atRisk).toContain('atRisk'); // inactive 10 >= 5, < 20
|
||||
expect(sets.churned).toContain('churned'); // inactive 40 >= 20
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { evaluateSignals, detectAnomaly, sortSignals } from '$lib/server/stats/signals';
|
||||
import { SETTINGS_DEFAULTS, type SnapshotPayload } from '$lib/types/stats';
|
||||
|
||||
function makePayload(overrides: {
|
||||
kpis?: Partial<SnapshotPayload['kpis']>;
|
||||
activity?: Partial<SnapshotPayload['activity']>;
|
||||
dailySeries?: SnapshotPayload['dailySeries'];
|
||||
} = {}): SnapshotPayload {
|
||||
return {
|
||||
asOfDate: '2026-09-14',
|
||||
activity: {
|
||||
dau: 100,
|
||||
wau: 300,
|
||||
mau: 800,
|
||||
core: 50,
|
||||
returningShare: 40,
|
||||
newPlayers: 20,
|
||||
resurrected: 5,
|
||||
churned: 18,
|
||||
netGrowth: 7,
|
||||
corePrevious: 45,
|
||||
previous: { dau: 95, wau: 290, mau: 790, core: 45 },
|
||||
...overrides.activity
|
||||
},
|
||||
dailySeries: overrides.dailySeries ?? [],
|
||||
kpis: {
|
||||
d1Return: 25,
|
||||
return7: 30,
|
||||
overallReturnRate: 22,
|
||||
completionRate: 80,
|
||||
shareRate: 30,
|
||||
firstTryRate: 25,
|
||||
avgGuesses: 4.1,
|
||||
...overrides.kpis
|
||||
},
|
||||
retention: {
|
||||
cohorts: [],
|
||||
curves: { d1: [], d7: [], d30: [] },
|
||||
timeToSecondPlay: [],
|
||||
segmentMix: []
|
||||
},
|
||||
growth: { lifecycleFunnel: [], signups: [] },
|
||||
totals: { completions: 0, players: 0, yesterdayCompletions: 0, yesterdayAvgGuesses: null },
|
||||
verseMix: {
|
||||
coverage: [],
|
||||
testamentMix: [],
|
||||
entropy: [],
|
||||
repeatIntervals: [],
|
||||
firstTryByBook: [],
|
||||
retentionByBook: [],
|
||||
retentionByGuessBucket: [],
|
||||
achievability: []
|
||||
},
|
||||
habit: { streakSurvival: [], streakLengths: [], calendar: [], weekdayHour: [] },
|
||||
signals: [],
|
||||
meta: { hasStartedData: true, hasShareData: true, firstEventDate: null }
|
||||
};
|
||||
}
|
||||
|
||||
const settings = { ...SETTINGS_DEFAULTS };
|
||||
|
||||
describe('evaluateSignals', () => {
|
||||
test('all-good payload yields no warn/bad signals', () => {
|
||||
const signals = evaluateSignals(makePayload(), settings);
|
||||
expect(signals.some((s) => s.severity === 'bad' || s.severity === 'warn')).toBe(false);
|
||||
});
|
||||
|
||||
test('low D1 return is bad and recommends an action', () => {
|
||||
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 9 } }), settings);
|
||||
const d1 = signals.find((s) => s.id === 'd1_return')!;
|
||||
expect(d1.severity).toBe('bad');
|
||||
expect(d1.action).toBeTruthy();
|
||||
});
|
||||
|
||||
test('missed-but-not-red targets are warnings', () => {
|
||||
const signals = evaluateSignals(makePayload({ kpis: { d1Return: 16 } }), settings);
|
||||
expect(signals.find((s) => s.id === 'd1_return')!.severity).toBe('warn');
|
||||
});
|
||||
|
||||
test('first-try rate outside the band is flagged', () => {
|
||||
const tooEasy = evaluateSignals(makePayload({ kpis: { firstTryRate: 60 } }), settings);
|
||||
expect(tooEasy.find((s) => s.id === 'first_try')!.severity).toBe('bad');
|
||||
const tooHard = evaluateSignals(makePayload({ kpis: { firstTryRate: 5 } }), settings);
|
||||
expect(tooHard.find((s) => s.id === 'first_try')!.severity).toBe('bad');
|
||||
});
|
||||
|
||||
test('missing metrics surface as info, not failure', () => {
|
||||
const signals = evaluateSignals(
|
||||
makePayload({ kpis: { completionRate: null, shareRate: null } }),
|
||||
settings
|
||||
);
|
||||
expect(signals.find((s) => s.id === 'completion')!.severity).toBe('info');
|
||||
expect(signals.find((s) => s.id === 'share_rate')!.severity).toBe('info');
|
||||
});
|
||||
|
||||
test('declining core players is a warning', () => {
|
||||
const signals = evaluateSignals(
|
||||
makePayload({ activity: { core: 30, corePrevious: 50 } }),
|
||||
settings
|
||||
);
|
||||
expect(signals.find((s) => s.id === 'core_players')!.severity).toBe('warn');
|
||||
});
|
||||
|
||||
test('sortSignals puts bad first', () => {
|
||||
const signals = sortSignals(evaluateSignals(makePayload({ kpis: { d1Return: 5 } }), settings));
|
||||
expect(signals[0].severity).toBe('bad');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectAnomaly', () => {
|
||||
function series(values: number[], startWeekdayOffset = 0) {
|
||||
// Build consecutive days starting 2026-08-01 so weekdays repeat every 7.
|
||||
const out = values.map((value, i) => {
|
||||
const d = new Date('2026-08-01T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + i);
|
||||
return { date: d.toISOString().slice(0, 10), value };
|
||||
});
|
||||
void startWeekdayOffset;
|
||||
return out;
|
||||
}
|
||||
|
||||
test('flags a large weekday-aware spike', () => {
|
||||
// 5 weeks of varying same-weekday values, then a spike.
|
||||
const base: number[] = [];
|
||||
for (let i = 0; i < 35; i++) base.push(100 + ((i * 13) % 17));
|
||||
const spike = [...base, 500];
|
||||
const anomaly = detectAnomaly(series(spike), 2);
|
||||
expect(anomaly).not.toBeNull();
|
||||
expect(anomaly!.value).toBe(500);
|
||||
});
|
||||
|
||||
test('returns null for a flat series', () => {
|
||||
expect(detectAnomaly(series(Array(35).fill(100)), 2)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null without enough history', () => {
|
||||
expect(detectAnomaly(series([1, 2, 3]), 2)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { testDb } from '$lib/server/db/test';
|
||||
import {
|
||||
analyticsEvents,
|
||||
dailyCompletions,
|
||||
dailyVerses,
|
||||
statsSnapshot
|
||||
} from '$lib/server/db/schema';
|
||||
import {
|
||||
buildSnapshot,
|
||||
computePayload,
|
||||
computePlayerState,
|
||||
ensureSnapshot,
|
||||
PAYLOAD_SCHEMA_VERSION,
|
||||
type LoadedData
|
||||
} from '$lib/server/stats/snapshot';
|
||||
import { SETTINGS_DEFAULTS, type CompletionRow } from '$lib/types/stats';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const settings = { ...SETTINGS_DEFAULTS };
|
||||
|
||||
function c(anonymousId: string, date: string): CompletionRow {
|
||||
return { anonymousId, date, guessCount: 3, guesses: null, completedAt: new Date(date) };
|
||||
}
|
||||
|
||||
describe('computePlayerState', () => {
|
||||
test('builds streak and rolling play counts as of the date', () => {
|
||||
const completions: CompletionRow[] = [
|
||||
c('A', '2026-09-08'),
|
||||
c('A', '2026-09-09'),
|
||||
c('A', '2026-09-10'),
|
||||
c('A', '2026-08-01'),
|
||||
c('B', '2026-09-10')
|
||||
];
|
||||
const state = computePlayerState(completions, '2026-09-10', 180);
|
||||
const a = state.find((p) => p.id === 'A')!;
|
||||
expect(a.firstDate).toBe('2026-08-01');
|
||||
expect(a.lastDate).toBe('2026-09-10');
|
||||
expect(a.totalPlays).toBe(4);
|
||||
expect(a.plays7).toBe(3);
|
||||
expect(a.streak).toBe(3);
|
||||
});
|
||||
|
||||
test('drops players outside the active window', () => {
|
||||
const state = computePlayerState([c('old', '2026-01-01')], '2026-09-10', 30);
|
||||
expect(state).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computePayload point-in-time cutoff', () => {
|
||||
const data: LoadedData = {
|
||||
completions: [
|
||||
c('A', '2026-09-10'),
|
||||
c('A', '2026-09-11'), // future relative to asOfDate
|
||||
c('B', '2026-09-11') // future
|
||||
],
|
||||
verses: [{ date: '2026-09-10', bookId: 'GEN' }],
|
||||
startedByDate: new Map([['2026-09-10', 2]]),
|
||||
shareEvents: 0,
|
||||
firstEventDate: '2026-09-10',
|
||||
signupDates: []
|
||||
};
|
||||
|
||||
test('activity only reflects data up to the as-of date', () => {
|
||||
const payload = computePayload('2026-09-10', settings, data);
|
||||
expect(payload.activity.dau).toBe(1); // only A, not B (future)
|
||||
});
|
||||
|
||||
test('completion rate uses verse_served events', () => {
|
||||
const payload = computePayload('2026-09-10', settings, data);
|
||||
expect(payload.meta.hasStartedData).toBe(true);
|
||||
expect(payload.kpis.completionRate).not.toBeNull();
|
||||
});
|
||||
|
||||
test('payload has all lab sections', () => {
|
||||
const payload = computePayload('2026-09-10', settings, data);
|
||||
expect(payload.retention.cohorts.length).toBeGreaterThan(0);
|
||||
expect(payload.verseMix.coverage).toHaveLength(66);
|
||||
expect(payload.habit.calendar.length).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snapshot persistence', () => {
|
||||
beforeEach(async () => {
|
||||
await testDb.delete(statsSnapshot);
|
||||
await testDb.delete(analyticsEvents);
|
||||
await testDb.delete(dailyCompletions);
|
||||
await testDb.delete(dailyVerses);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await testDb.delete(statsSnapshot);
|
||||
await testDb.delete(analyticsEvents);
|
||||
await testDb.delete(dailyCompletions);
|
||||
await testDb.delete(dailyVerses);
|
||||
});
|
||||
|
||||
async function seed() {
|
||||
await testDb.insert(dailyCompletions).values([
|
||||
{ id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-10', guessCount: 2, completedAt: new Date('2026-01-10T12:00:00Z') },
|
||||
{ id: crypto.randomUUID(), anonymousId: 'A', date: '2026-01-11', guessCount: 3, completedAt: new Date('2026-01-11T12:00:00Z') },
|
||||
{ id: crypto.randomUUID(), anonymousId: 'B', date: '2026-01-11', guessCount: 1, completedAt: new Date('2026-01-11T13:00:00Z') }
|
||||
]);
|
||||
await testDb.insert(dailyVerses).values([
|
||||
{ id: crypto.randomUUID(), date: '2026-01-10', bookId: 'GEN', verseText: 'x', reference: 'Genesis 1:1' },
|
||||
{ id: crypto.randomUUID(), date: '2026-01-11', bookId: 'EXO', verseText: 'y', reference: 'Exodus 1:1' }
|
||||
]);
|
||||
}
|
||||
|
||||
test('building the same date twice is idempotent', async () => {
|
||||
await seed();
|
||||
const first = await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
|
||||
await buildSnapshot('2026-01-10', { kind: 'lazy' }, testDb);
|
||||
const rows = await testDb.select().from(statsSnapshot);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(first.snapshot.payload.asOfDate).toBe('2026-01-10');
|
||||
expect(first.snapshot.payload.activity.dau).toBe(1);
|
||||
});
|
||||
|
||||
test('ensureSnapshot builds when missing and reads when present', async () => {
|
||||
await seed();
|
||||
const a = await ensureSnapshot('2026-01-10', {}, testDb);
|
||||
const b = await ensureSnapshot('2026-01-10', {}, testDb);
|
||||
expect(a.generatedAt.getTime()).toBe(b.generatedAt.getTime());
|
||||
expect(a.buildKind).toBe('lazy');
|
||||
});
|
||||
|
||||
test('rebuilds snapshots written with an outdated payload shape', async () => {
|
||||
await seed();
|
||||
await testDb.insert(statsSnapshot).values({
|
||||
date: '2026-01-10',
|
||||
generatedAt: new Date(),
|
||||
buildKind: 'lazy',
|
||||
configVersion: 'outdated',
|
||||
payload: '{}',
|
||||
playerState: '[]',
|
||||
config: '{}'
|
||||
});
|
||||
const snap = await ensureSnapshot('2026-01-10', {}, testDb);
|
||||
expect(snap.payload.meta?.schemaVersion).toBe(PAYLOAD_SCHEMA_VERSION);
|
||||
});
|
||||
});
|
||||
+16
-5
@@ -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']
|
||||
}
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user