# Bibdle Statistics Dashboard — Implementation Checklist **For:** an AI coding agent (or human) implementing the plan in `statistics-plan.md`. **Repository:** Bibdle — SvelteKit 2 · Svelte 5 (runes) · Tailwind 4 · Bun · SQLite + Drizzle ORM. **Rule:** read `statistics-plan.md` and `README.md` fully before starting. This checklist is ordered and dependency-aware; do tasks top to bottom unless a task says otherwise. --- ## 0. How to use this document - Each task has an **ID**, **Goal**, **Files**, **Steps**, **Acceptance**, and **Tests**. - Do **not** skip the Phase 0 foundations — every later phase depends on the schema and event log. - Prefer pure, testable functions in `src/lib/server/stats/*`; keep Svelte components presentational. - When a task is ambiguous, prefer the smallest reversible change and leave a `// TODO(stats):` comment rather than inventing behavior. --- ## 1. Guardrails (non-negotiable) 1. **Do not add Umami or Rybbit integration.** The dashboard is self-contained and durable. Remove/ignore any Rybbit references you touch. 2. **Do not query the DB on `/admin/stats` page load** except to (a) build a missing snapshot lazily, or (b) serve a `?mature=1` view. Normal loads read the stored snapshot only. 3. **Point-in-time correctness:** snapshot computation for date `D` may only use rows with `date <= D` (and `completed_at` before end of `D`). 4. **Plain language only.** No "velocity/acceleration/derivative" jargon in UI labels or captions. Every metric gets a one-sentence caption and a "what to do." 5. **Privacy:** `/admin/stats` is gated to `ADMIN_EMAIL`. Never render emails or raw anonymous IDs. The public widget must expose no retention/churn/signup/revenue data. 6. **Do not modify existing public routes** (`/`, `/progress`, `/stats`, `/imposter`, etc.) beyond the specific instrumentation hooks in Phase 0. Do not change the public `/global` until Phase 4. 7. **No new runtime dependency except `layerchart`.** (Email digest would require `nodemailer`; it is out of scope unless the owner approves — default to the Discord webhook.) 8. **Follow existing code style:** Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`, `$bindable`), Tailwind 4 utility classes, Drizzle query builder, `bun test` with `describe/test/expect`. 9. **Migrations:** never hand-edit generated SQL. Use `bun run db:generate` and review the diff. Be aware the `drizzle/` journal references `0001_loose_kree` but that `.sql` file is absent on disk — do not "fix" this; just add new migrations on top. 10. **Commit early and often**, one phase per logical commit set, message prefix `stats:`. --- ## 2. Environment & commands ```bash bun install bun run dev # dev server bun run check # svelte-check / TS bun test # all tests bun test tests/.test.ts bun run db:generate # generate migration from schema.ts bun run db:migrate # apply migrations to DATABASE_URL ``` - `DATABASE_URL` (e.g. `dev.db`) drives the app DB. - `TEST_DATABASE_URL` drives `drizzle.test.config.ts`. - Existing env: `CRON_SECRET`, `DISCORD_DAILY_WEBHOOK`, `ADMIN_EMAIL` (hardcoded in `src/lib/server/admin.ts`). - **Add** `DISCORD_ADMIN_WEBHOOK` (digest target) to `.env.example` and `.env`. **Verification gate after every phase:** `bun run check` clean, `bun test` green, `bun run build` succeeds. --- ## Phase 0 — Foundations & instrumentation ### T0.1 — Add tables and column to the Drizzle schema - **Goal:** add `stats_snapshot`, `analytics_events`, `dashboard_settings`, and `user.created_at`. - **Files:** `src/lib/server/db/schema.ts` - **Steps:** 1. Add `user.createdAt = integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date())`. (`$defaultFn` means new rows get a timestamp automatically; existing rows stay `NULL` = "unknown".) 2. Add `statsSnapshot` (`date` text PK; `generatedAt` integer timestamp; `buildKind` text; `configVersion` text; `payload` text; `playerState` text; `config` text). 3. Add `analyticsEvents` (`id` text PK; `name` text; `anonymousId` text; `userId` text nullable FK → user ON DELETE SET NULL; `date` text nullable; `createdAt` integer timestamp; `metadata` text). Indexes on `(name, date)` and `(anonymousId, date)`; unique index `analytics_events_serve_unique` on `(name, anonymousId, date)`. 4. Add `dashboardSettings` (`key` text PK; `value` text; `updatedAt` integer timestamp). 5. Export inferred types. - **Steps:** run `bun run db:generate`, inspect SQL, then `bun run db:migrate`. - **Acceptance:** fresh migration applies cleanly to `dev.db`; `sqlite3 dev.db ".schema user"` shows `created_at`; all three tables exist. - **Tests:** none (schema only) — but run full suite. ### T0.2 — Settings module (thresholds with env defaults) - **Goal:** a single source of truth for configurable thresholds. - **Files:** new `src/lib/server/stats/settings.ts`; new `src/lib/types/stats.ts` (types). - **Steps:** 1. Define keys with defaults: `core_plays_per_week` (4), `churn_days` (30), `resurrect_gap_days` (30), `at_risk_days` (8), `d1_target` (20), `return7_target` (25), `completion_target` (70), `first_try_min` (15), `first_try_max` (35), `share_target` (25), `active_window_days` (180), `anomaly_mad_threshold` (2). 2. `getSettings()` reads `dashboard_settings` and overlays env-var defaults (e.g. `STATS_D1_TARGET`). `updateSettings(partial)` upserts rows. 3. `configVersion()` = short stable hash of the resolved settings. - **Acceptance:** `getSettings()` returns defaults with an empty table; `updateSettings` persists and is reflected on re-read. - **Tests:** `tests/stats-settings.test.ts` — defaults, override, upsert, config hash stability. ### T0.3 — Event recording module + client helper - **Goal:** a single, safe sink for analytics events (B/D/E/F). - **Files:** new `src/lib/server/stats/events.ts`; new `src/routes/api/events/+server.ts`; new `src/lib/utils/analytics.ts`. - **Steps:** 1. `recordEvent({ name, anonymousId, userId, date, metadata })` inserts into `analytics_events`. For `name === 'verse_served'`, use `INSERT OR IGNORE` (dedupe on unique index). 2. `POST /api/events` validates `name` against an allowlist (`verse_served`, `share`, `share_copy`, `first_visit`, `donate_click`, `discord_click`), caps metadata size, resolves `userId` from `locals.user` if present, and records. Return 204. Never throw to the client callers (fire-and-forget). 3. `src/lib/utils/analytics.ts` exports `track(name, metadata?)` that POSTs to `/api/events` with the lib's anonymous ID (read/create `bibdle-anonymous-id`), ignoring errors. Also export `getOrCreateAnonymousId()` and refactor `src/lib/stores/game-persistence.svelte.ts` to import it (avoid duplicated logic). - **Acceptance:** calling `track('donate_click')` writes one row; repeated `verse_served` for the same `(anon, date)` writes only once. - **Tests:** `tests/stats-events.test.ts` — allowlist rejection, metadata cap, verse_served dedupe, user attribution. ### T0.4 — Instrumentation A: guarantee guess sequences for everyone - **Goal:** all completions persist `guesses`. - **Files:** `src/routes/api/submit-completion/+server.ts`, `src/routes/+page.svelte` (verify), `src/routes/+page.server.ts` (legacy form action). - **Steps:** confirm the client always sends `guesses`; ensure the API stores `JSON.stringify(guesses)` whenever the array is non-empty. Update the legacy `submitCompletion` form action to accept and store `guesses` too (or delete the dead action if unused). Do not backfill historical rows. - **Acceptance:** a guest completion POSTed with `guesses` stores the JSON; `SELECT COUNT(*) FROM daily_completions WHERE guesses IS NOT NULL` grows for guest IDs in dev. - **Tests:** extend `tests/stats.test.ts` or add `tests/stats-completion.test.ts`. ### T0.5 — Instrumentation B: log "verse served" (game started) - **Goal:** measure started → completed. - **Files:** `src/routes/+page.ts`, `src/routes/api/daily-verse/+server.ts`, `src/lib/utils/analytics.ts`. - **Steps:** in `+page.ts`, read/create the anonymous ID before the fetch and include it in the `POST /api/daily-verse` body. In the endpoint, record `verse_served` (unique per anon+date). Keep the endpoint's response shape unchanged. - **Acceptance:** loading `/` records exactly one `verse_served` per browser per day, even after reloads. - **Tests:** `tests/stats-serve.test.ts` (endpoint logic with a test DB) + unit test for dedupe. ### T0.6 — Instrumentation D: share / copy events - **Goal:** measure virality. - **Files:** `src/lib/components/WinScreen.svelte`, `src/lib/utils/share.ts` (or call site). - **Steps:** fire `track('share', { snippet })` when the native-share path is used and `track('share_copy', { snippet })` on clipboard copy. Do not double-count the copy that happens inside `shareResult` fallback. Keep existing Umami `data-umami-*` attributes untouched. - **Acceptance:** clicking Share writes one `share` row; Copy writes one `share_copy` row; snippet flag correct. - **Tests:** manual + a small unit test on the helper if logic is extracted. ### T0.7 — Instrumentation E: first-touch referrer / UTM - **Goal:** acquisition source. - **Files:** `src/lib/utils/analytics.ts`, root layout or `+page.ts`. - **Steps:** on first visit per browser (guard with localStorage key), capture `document.referrer` and `?ref=` / `?utm_source=` / `?utm_medium` / `?utm_campaign`, and record a one-time `first_visit` event with that metadata. - **Acceptance:** first visit with `?utm_source=bluesky` records one `first_visit`; subsequent visits do not. - **Tests:** unit test on the parsing/guard logic. ### T0.8 — Instrumentation F: monetization clicks - **Goal:** patron funnel. - **Files:** `src/lib/components/WinScreen.svelte` (Donate, Discord), `src/lib/components/SocialLinks.svelte` if desired. - **Steps:** fire `track('donate_click')` and `track('discord_click')` on click (keep the existing Umami attributes). - **Acceptance:** clicks record events with the right names. ### T0.9 — Add `DISCORD_ADMIN_WEBHOOK` env - **Files:** `.env.example`, `.env` (local only), `README.md` env list. - **Acceptance:** documented; not required at boot (digest degrades gracefully when unset). **Phase 0 exit criteria:** migrations applied; events flowing in dev; `bun run check` + `bun test` green. --- ## Phase 1 — Core console (snapshot + Pulse + Signals skeleton) ### T1.1 — Pure metric functions - **Goal:** tested, deterministic metrics independent of Svelte/DB reads. - **Files:** new `src/lib/server/stats/metrics.ts`, types in `src/lib/types/stats.ts`. - **Steps:** implement, each taking plain rows and a cutoff date: - `activePlayers(completions, end, windowDays)` - `newVsReturning(completions, date)` (returning = first-ever completion `< date`) - `netGrowth(completions, end, windowDays, settings)` - `d1Return(completions, cohortDate)` and `returnWithin(completions, cohortDate, n)` - `cohortTriangle(completions, { start, weeks })` - `timeToSecondPlayHistogram(completions, buckets)` - `guessHistogram(completions, maxBucket)` + `firstTryRate` - `streakSurvival(completions)` - `bookCoverage(verses, completions, start, end)` - `retentionByBook(completions, verses, minPlays)` and `retentionByGuessBucket` - `entropy(bookCounts)` - `weekdayHourHeatmap(completions)` - **Acceptance:** pure functions; no DB imports; correct on hand-built fixtures. - **Tests:** `tests/stats-metrics.test.ts` — one test per function incl. edge cases (empty, single player, day boundaries in UTC). ### T1.2 — Segment derivation from stored player state - **Goal:** render-time configurable segments. - **Files:** new `src/lib/server/stats/segments.ts`. - **Steps:** define `PlayerState = { id, firstDate, lastDate, totalPlays, plays7, plays30, streak }`. `deriveSegments(playerState[], asOfDate, settings)` returns `{ new, returning, core, atRisk, churned, resurrected }` counts/sets. Keep it pure. - **Acceptance:** changing `core_plays_per_week` changes segmentation without recomputation from raw completions. - **Tests:** `tests/stats-segments.test.ts`. ### T1.3 — Snapshot builder - **Goal:** compute and persist one snapshot for a date. - **Files:** new `src/lib/server/stats/snapshot.ts`. - **Steps:** 1. `buildSnapshot(asOfDate, { kind })`: query completions/verses/users with `date <= asOfDate`, compute all Phase-1 (and later-phase) aggregates, build `playerState` for active players in the trailing `active_window_days`, assemble `payload` JSON + `config` + `configVersion`, and `INSERT OR REPLACE` into `stats_snapshot`. 2. `getSnapshot(date)` reads a row. `ensureSnapshot(date)` builds with `kind: 'lazy'` if missing (guard against races: `INSERT OR IGNORE` then re-read). 3. `isSnapshotStale(date)` compares stored `configVersion` to current `configVersion()` (used to warn in UI; do **not** auto-rebuild unless triggered). - **Acceptance:** building 2026-09-14 twice is idempotent; the payload only reflects data ≤ 2026-09-14. - **Tests:** `tests/stats-snapshot.test.ts` — cutoff enforcement, idempotency, lazy race handling. ### T1.4 — Snapshot endpoints - **Goal:** protected build trigger + admin read. - **Files:** new `src/routes/api/cron/build-stats/+server.ts`; optional `src/routes/api/admin/rebuild-stats/+server.ts`. - **Steps:** `POST /api/cron/build-stats` checks `Authorization: Bearer ${CRON_SECRET}`, builds **yesterday** (server `America/New_York`, matching `send-daily-verse`), then triggers the digest (Phase 4; no-op if not built yet). The admin rebuild accepts `{ date }` and requires `isAdmin`. - **Acceptance:** 401 without secret; builds yesterday with secret. - **Tests:** `tests/stats-endpoints.test.ts` — auth + date selection. ### T1.5 — Admin route shell + auth gate + date navigator - **Goal:** `/admin/stats` renders a snapshot and can navigate time. - **Files:** new `src/routes/admin/stats/+page.server.ts`, `+page.svelte`, `src/lib/components/stats/DateNavigator.svelte`, `src/lib/components/stats/ConsoleChrome.svelte`. - **Steps:** 1. `+page.server.ts`: if `!isAdmin(locals.user?.email)` → 404 (mirror `/scheduled-verses` behavior). Resolve `date` from `?date=`; default = yesterday. `ensureSnapshot(date)`. Load settings + snapshot + (if `?mature=1`) a matured computation. Return `{ date, generatedAt, buildKind, payload, segments, settings, matured }`. 2. Layout: sticky header with `DateNavigator` (prev/next/date input), "Mature this view" toggle (link with `?mature=1`), link to Settings, and an as-of banner: *"Showing {date} · snapshot built {generatedAt}"*. - **Acceptance:** non-admin gets 404; admin sees the banner and can move between dates; days with no snapshot get lazily built (flagged `lazy`). - **Tests:** endpoint/load unit test where feasible. ### T1.6 — Pulse lab - **Goal:** the 10-second read. - **Files:** `src/lib/components/stats/Pulse.svelte`, `src/lib/components/charts/Sparkline.svelte`, `src/lib/components/charts/StatusPill.svelte`, `src/lib/components/charts/Gauge.svelte` (LayerChart). - **Steps:** render KPI cards (Players today, WAU, MAU, Core, Returning share, D1 return, Net growth) with same-weekday deltas and sparklines; a verdict banner from `signals.ts`. - **Acceptance:** all cards render from the snapshot payload with no live DB query; numbers match a manual query for a test date. ### T1.7 — Signals engine skeleton - **Goal:** plain-language verdicts + actions. - **Files:** new `src/lib/server/stats/signals.ts`, `src/lib/components/stats/SignalsFeed.svelte`. - **Steps:** implement `evaluateSignals(payload, settings)` returning `{ id, severity, title, detail, action }[]`. Implement the six target rules from the plan plus a generic anomaly detector (trailing-28-day median + MAD, weekday-aware). Thresholds from `settings`. - **Acceptance:** synthesizing payloads produces expected severities; no jargon in strings. - **Tests:** `tests/stats-signals.test.ts`. ### T1.8 — Settings menu - **Goal:** edit thresholds; re-segment instantly. - **Files:** `src/lib/components/stats/SettingsMenu.svelte`, `src/routes/admin/stats/settings/+page.server.ts` (form action), or an inline panel. - **Steps:** form posts to `updateSettings`; on save, reload the same date (segments are re-derived from `player_state`, so no rebuild needed). Show a note: *"Changing these re-reads history; snapshots themselves are unchanged."* If `configVersion` differs from a snapshot's stored version, show an "outdated snapshot definitions" notice with a rebuild link. - **Acceptance:** changing `core_plays_per_week` updates Core counts across multiple past dates immediately. - **Tests:** settings module already covered; add a segment re-derivation test at two thresholds. ### T1.9 — Metric glossary panel - **Goal:** plain-language definitions on the page. - **Files:** `src/lib/components/stats/Glossary.svelte`. - **Steps:** render the glossary table from the plan, driven by a constant map so labels stay consistent. - **Acceptance:** every metric label used in charts has a glossary entry. **Phase 1 exit criteria:** `/admin/stats` shows Pulse + Signals for any date, snapshot-only loads, settings work, LayerChart installed. --- ## Phase 2 — Retention Lab & Growth Loop ### T2.1 — Add LayerChart - **Files:** `package.json` (`bun add layerchart`), lockfile. - **Acceptance:** `bun run build` succeeds; a trivial `` renders. ### T2.2 — Retention Lab - **Files:** `src/lib/components/stats/RetentionLab.svelte`, charts: `CohortHeatmap.svelte`, `Histogram.svelte`, `StackedArea.svelte`, `BoxPlot.svelte`. - **Steps:** cohort triangle heatmap (weekly cohorts, toggle daily), D1/D7/D30 curves as small multiples, time-to-second-play histogram, segment-mix stacked area, retention-by-segment box/grouped bars. Captions per plan; segments from `player_state`. - **Acceptance:** heatmap cells match a manual cohort calculation; captions plain-language. ### T2.3 — Growth Loop - **Files:** `src/lib/components/stats/GrowthLoop.svelte`, charts: `AnnotatedArea.svelte`, `Waterfall.svelte`, `Funnel.svelte`. - **Steps:** new-players/day area with 7-day MA; if manual annotations are added later, support marker pins (design the prop now, render none if absent); waterfall (new + resurrected − churned); lifecycle funnel (players reaching 1/2/5/10/25/50 plays); signup funnel using `user.created_at` + `verse_served`/completions. - **Acceptance:** waterfall components sum to net growth; funnel is monotonic non-increasing. ### T2.4 — Mature view - **Files:** `src/lib/server/stats/snapshot.ts` (add `buildMatured(date)`), `+page.server.ts`. - **Steps:** implement on-demand matured computation (uses all data after `date` for cohort resolution) and clearly label it. Do not store matured results as snapshots. - **Acceptance:** `?mature=1` resolves old D7/D30 where possible; banner says "Matured — uses data after this date." **Phase 2 exit criteria:** Retention & Growth labs render from snapshots; mature toggle works. --- ## Phase 3 — Verse Mix Lab & Habit Lab ### T3.1 — Verse Mix Lab - **Files:** `src/lib/components/stats/VerseMixLab.svelte`, charts reuse `StackedBar`, `BubbleScatter.svelte`, `CoverageHeatmap.svelte`, `StepLine.svelte`. - **Steps:** mix balance (book/section/testament stack over time), 66-book coverage heatmap, freshness (repeat intervals + entropy line), retention-by-book bubble scatter, retention-by-guess-bucket, achievability band + step curve, regression alarm table, before/after curated-mix annotated comparison. Include the "giveaway detector" table (high first-try books/windows). - **Acceptance:** Revelation/Genesis flags as easy outliers and Micah as hard on the current data; captions explain each. ### T3.2 — Habit Lab - **Files:** `src/lib/components/stats/HabitLab.svelte`, charts: `SurvivalLine.svelte`, `Ridgeline.svelte`, `CalendarHeatmap.svelte`, `WeekdayHourHeatmap.svelte`. - **Steps:** streak survival curve, streak length distribution, calendar heatmap, weekday×hour heatmap (note server-time caveat in the caption). - **Acceptance:** survival curve is monotonic; calendar matches completion counts. **Phase 3 exit criteria:** all five labs complete; page passes `bun run check`. --- ## Phase 4 — Delivery ### T4.1 — Daily digest - **Files:** new `src/lib/server/stats/digest.ts`; call from `src/routes/api/cron/build-stats/+server.ts`. - **Steps:** after a successful build, compose ~6 plain lines (as-of date; DAU/WAU/MAU; new vs returning; D1; top signal; link) and POST to `DISCORD_ADMIN_WEBHOOK`. No-op with a log if unset. Never fail the build on digest failure. - **Acceptance:** running the cron locally posts the digest (or logs a skip); build still succeeds if the webhook errors. - **Tests:** unit test message composition. ### T4.2 — Slim public community widget - **Files:** rewrite `src/routes/global/+page.server.ts` and `+page.svelte` (or create a `CommunityStats.svelte` embedded on `/about`). - **Steps:** read the **latest snapshot** and render only: all-time completions, players this week/month, yesterday's completions + average guesses, 30-day calendar heatmap. Label "as of {date}." No sensitive metrics. Mobile-friendly. - **Acceptance:** public page contains no retention/churn/signup/revenue figures; loads without heavy queries (reads snapshot). ### T4.3 — Remove/replace any lingering `/global` references - **Steps:** update README route table and any nav links. Keep old `+page.server.ts` logic out of the new console. - **Acceptance:** README matches reality. ### T4.4 — Docs & env - **Steps:** update `README.md` (routes, API, schema, env: `DISCORD_ADMIN_WEBHOOK`), and `todo.md` if relevant. - **Acceptance:** a new contributor can find `/admin/stats` and the cron endpoint from the README. **Phase 4 exit criteria:** digest + public widget live; docs current. --- ## Phase 5 — Deferred / roadmap (do not build unless approved) - **C — feature-usage events:** emit `mode_enter` (imposter/practice), `hint_reveal`, `progress_view`, `submit_verse_*`; add the Feature Impact panel. - **Manual marketing annotations:** small table + UI pins on the Growth Loop timeline. - **CSV/JSON export** of snapshot payloads. - **Email digest** (requires `nodemailer`). --- ## Cross-cutting requirements ### C1 — Testing - All `src/lib/server/stats/*.ts` pure logic must have unit tests with fixtures (no real DB) where possible. - DB-touching functions tested against `TEST_DATABASE_URL`. - Maintain the phase verification gate: `bun run check && bun test && bun run build`. ### C2 — Accessibility & mobile - Charts keyboard-navigable where interactive; provide text/table fallbacks for color-only heatmaps. - Responsive: KPI grid 2-col on mobile, charts fluid width; tables horizontally scrollable. - Respect the existing dark theme (`bg-white/10 dark:bg-white/5`, etc.) and the `Container`/`CollapsibleTable` components. ### C3 — Performance - Snapshot build is a batch job; acceptable to scan tables. Page loads must not scan raw tables. - Keep `player_state` bounded (~180-day active window); JSON may be gzipped if it grows. ### C4 — Copy & tone - Every chart caption ≤ 2 short sentences. - No metric named "rate of change," "velocity," or "acceleration." - Always include a comparison and a "what to do." --- ## Definition of Done (overall) 1. `/admin/stats` is admin-only and serves a once-daily, as-of-yesterday snapshot without live DB reads. 2. Any past date can be viewed (point-in-time), with `—` where data didn't exist. 3. "Mature this view" works and is clearly labeled. 4. All five labs + Pulse + Signals + Glossary render from snapshots using LayerChart; layouts are mobile-friendly. 5. Segment thresholds are editable in-app and re-derive all history instantly. 6. Instrumentation A/B/D/E/F + `user.created_at` actively record data. 7. Daily digest posts; slim public widget shows only safe aggregates. 8. `bun run check`, `bun test`, `bun run build` all pass; README/env docs updated. --- ## Appendix A — Snapshot `payload` shape (extend per phase) ```ts interface SnapshotPayload { asOfDate: string; activity: { dau: number; wau: number; mau: number; core: number; returningShare: number; newPlayers: number; resurrected: number; churned: number; netGrowth: number }; dailySeries: { date: string; completions: number; newPlayers: number; started: number }[]; // last ~90d kpis: { d1Return: number | null; return7: number | null; overallReturnRate: number; completionRate: number | null; shareRate: number | null; firstTryRate: number; avgGuesses: number }; retention: { cohorts: { weekStart: string; sizes: number[]; rates: (number|null)[] }[]; curves: { d1: Series; d7: Series; d30: Series }; timeToSecondPlay: Bin[]; segmentMix: { date: string; new: number; returning: number; resurrected: number; atRisk: number; churned: number }[] }; verseMix: { coverage: { bookId: string; days: number }[]; sectionMix: Series[]; entropy: Series; repeatIntervals: number[]; firstTryByBook: { bookId: string; plays: number; firstTryPct: number; avgGuesses: number }[]; retentionByBook: { bookId: string; d1: number; plays: number }[]; retentionByGuessBucket: { bucket: string; d1: number }[] }; habit: { streakSurvival: { length: number; p: number }[]; streakLengths: Bin[]; calendar: { date: string; count: number }[]; weekdayHour: number[][] }; signals: Signal[]; } ``` ## Appendix B — Event names (allowlisted) `verse_served` · `share` · `share_copy` · `first_visit` · `donate_click` · `discord_click` Metadata examples: share `{ snippet: boolean }`; first_visit `{ referrer, utm_source, utm_medium, utm_campaign }`. ## Appendix C — Settings keys (defaults) | Key | Default | Meaning | |---|---|---| | `core_plays_per_week` | 4 | Core player threshold | | `churn_days` | 30 | Days inactive → churned | | `resurrect_gap_days` | 30 | Gap that makes a return "resurrected" | | `at_risk_days` | 8 | Days inactive → at-risk | | `active_window_days` | 180 | Player-state horizon stored per snapshot | | `d1_target` | 20 | D1 return target (%) | | `return7_target` | 25 | 7-day return target (%) | | `completion_target` | 70 | Started→completed target (%) | | `first_try_min` / `first_try_max` | 15 / 35 | Achievability band (%) | | `share_target` | 25 | Share rate target (%) | | `anomaly_mad_threshold` | 2 | Robust z-score cutoff | ## Appendix D — Signal rule template ```ts { id: 'd1_return_low', severity: 'warn', title: 'New players are not coming back', detail: `D1 return is ${v}% (target ${t}%).`, action: 'Test a "come back tomorrow" prompt on the win screen.' } ```