26 KiB
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)
- Do not add Umami or Rybbit integration. The dashboard is self-contained and durable. Remove/ignore any Rybbit references you touch.
- Do not query the DB on
/admin/statspage load except to (a) build a missing snapshot lazily, or (b) serve a?mature=1view. Normal loads read the stored snapshot only. - Point-in-time correctness: snapshot computation for date
Dmay only use rows withdate <= D(andcompleted_atbefore end ofD). - 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."
- Privacy:
/admin/statsis gated toADMIN_EMAIL. Never render emails or raw anonymous IDs. The public widget must expose no retention/churn/signup/revenue data. - Do not modify existing public routes (
/,/progress,/stats,/imposter, etc.) beyond the specific instrumentation hooks in Phase 0. Do not change the public/globaluntil Phase 4. - No new runtime dependency except
layerchart. (Email digest would requirenodemailer; it is out of scope unless the owner approves — default to the Discord webhook.) - Follow existing code style: Svelte 5 runes (
$state,$derived,$effect,$props,$bindable), Tailwind 4 utility classes, Drizzle query builder,bun testwithdescribe/test/expect. - Migrations: never hand-edit generated SQL. Use
bun run db:generateand review the diff. Be aware thedrizzle/journal references0001_loose_kreebut that.sqlfile is absent on disk — do not "fix" this; just add new migrations on top. - Commit early and often, one phase per logical commit set, message prefix
stats:.
2. Environment & commands
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_URLdrivesdrizzle.test.config.ts.- Existing env:
CRON_SECRET,DISCORD_DAILY_WEBHOOK,ADMIN_EMAIL(hardcoded insrc/lib/server/admin.ts). - Add
DISCORD_ADMIN_WEBHOOK(digest target) to.env.exampleand.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, anduser.created_at. - Files:
src/lib/server/db/schema.ts - Steps:
- Add
user.createdAt = integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()). ($defaultFnmeans new rows get a timestamp automatically; existing rows stayNULL= "unknown".) - Add
statsSnapshot(datetext PK;generatedAtinteger timestamp;buildKindtext;configVersiontext;payloadtext;playerStatetext;configtext). - Add
analyticsEvents(idtext PK;nametext;anonymousIdtext;userIdtext nullable FK → user ON DELETE SET NULL;datetext nullable;createdAtinteger timestamp;metadatatext). Indexes on(name, date)and(anonymousId, date); unique indexanalytics_events_serve_uniqueon(name, anonymousId, date). - Add
dashboardSettings(keytext PK;valuetext;updatedAtinteger timestamp). - Export inferred types.
- Add
- Steps: run
bun run db:generate, inspect SQL, thenbun run db:migrate. - Acceptance: fresh migration applies cleanly to
dev.db;sqlite3 dev.db ".schema user"showscreated_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; newsrc/lib/types/stats.ts(types). - Steps:
- 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). getSettings()readsdashboard_settingsand overlays env-var defaults (e.g.STATS_D1_TARGET).updateSettings(partial)upserts rows.configVersion()= short stable hash of the resolved settings.
- Define keys with defaults:
- Acceptance:
getSettings()returns defaults with an empty table;updateSettingspersists 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; newsrc/routes/api/events/+server.ts; newsrc/lib/utils/analytics.ts. - Steps:
recordEvent({ name, anonymousId, userId, date, metadata })inserts intoanalytics_events. Forname === 'verse_served', useINSERT OR IGNORE(dedupe on unique index).POST /api/eventsvalidatesnameagainst an allowlist (verse_served,share,share_copy,first_visit,donate_click,discord_click), caps metadata size, resolvesuserIdfromlocals.userif present, and records. Return 204. Never throw to the client callers (fire-and-forget).src/lib/utils/analytics.tsexportstrack(name, metadata?)that POSTs to/api/eventswith the lib's anonymous ID (read/createbibdle-anonymous-id), ignoring errors. Also exportgetOrCreateAnonymousId()and refactorsrc/lib/stores/game-persistence.svelte.tsto import it (avoid duplicated logic).
- Acceptance: calling
track('donate_click')writes one row; repeatedverse_servedfor 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 storesJSON.stringify(guesses)whenever the array is non-empty. Update the legacysubmitCompletionform action to accept and storeguessestoo (or delete the dead action if unused). Do not backfill historical rows. - Acceptance: a guest completion POSTed with
guessesstores the JSON;SELECT COUNT(*) FROM daily_completions WHERE guesses IS NOT NULLgrows for guest IDs in dev. - Tests: extend
tests/stats.test.tsor addtests/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 thePOST /api/daily-versebody. In the endpoint, recordverse_served(unique per anon+date). Keep the endpoint's response shape unchanged. - Acceptance: loading
/records exactly oneverse_servedper 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 andtrack('share_copy', { snippet })on clipboard copy. Do not double-count the copy that happens insideshareResultfallback. Keep existing Umamidata-umami-*attributes untouched. - Acceptance: clicking Share writes one
sharerow; Copy writes oneshare_copyrow; 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.referrerand?ref=/?utm_source=/?utm_medium/?utm_campaign, and record a one-timefirst_visitevent with that metadata. - Acceptance: first visit with
?utm_source=blueskyrecords onefirst_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.svelteif desired. - Steps: fire
track('donate_click')andtrack('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.mdenv 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 insrc/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)andreturnWithin(completions, cohortDate, n)cohortTriangle(completions, { start, weeks })timeToSecondPlayHistogram(completions, buckets)guessHistogram(completions, maxBucket)+firstTryRatestreakSurvival(completions)bookCoverage(verses, completions, start, end)retentionByBook(completions, verses, minPlays)andretentionByGuessBucketentropy(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_weekchanges 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:
buildSnapshot(asOfDate, { kind }): query completions/verses/users withdate <= asOfDate, compute all Phase-1 (and later-phase) aggregates, buildplayerStatefor active players in the trailingactive_window_days, assemblepayloadJSON +config+configVersion, andINSERT OR REPLACEintostats_snapshot.getSnapshot(date)reads a row.ensureSnapshot(date)builds withkind: 'lazy'if missing (guard against races:INSERT OR IGNOREthen re-read).isSnapshotStale(date)compares storedconfigVersionto currentconfigVersion()(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; optionalsrc/routes/api/admin/rebuild-stats/+server.ts. - Steps:
POST /api/cron/build-statschecksAuthorization: Bearer ${CRON_SECRET}, builds yesterday (serverAmerica/New_York, matchingsend-daily-verse), then triggers the digest (Phase 4; no-op if not built yet). The admin rebuild accepts{ date }and requiresisAdmin. - 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/statsrenders 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:
+page.server.ts: if!isAdmin(locals.user?.email)→ 404 (mirror/scheduled-versesbehavior). Resolvedatefrom?date=; default = yesterday.ensureSnapshot(date). Load settings + snapshot + (if?mature=1) a matured computation. Return{ date, generatedAt, buildKind, payload, segments, settings, matured }.- 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 fromsettings. - 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 fromplayer_state, so no rebuild needed). Show a note: "Changing these re-reads history; snapshots themselves are unchanged." IfconfigVersiondiffers from a snapshot's stored version, show an "outdated snapshot definitions" notice with a rebuild link. - Acceptance: changing
core_plays_per_weekupdates 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 buildsucceeds; 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(addbuildMatured(date)),+page.server.ts. - Steps: implement on-demand matured computation (uses all data after
datefor cohort resolution) and clearly label it. Do not store matured results as snapshots. - Acceptance:
?mature=1resolves 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 reuseStackedBar,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 fromsrc/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.tsand+page.svelte(or create aCommunityStats.svelteembedded 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.tslogic out of the new console. - Acceptance: README matches reality.
T4.4 — Docs & env
- Steps: update
README.md(routes, API, schema, env:DISCORD_ADMIN_WEBHOOK), andtodo.mdif relevant. - Acceptance: a new contributor can find
/admin/statsand 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/*.tspure 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 theContainer/CollapsibleTablecomponents.
C3 — Performance
- Snapshot build is a batch job; acceptable to scan tables. Page loads must not scan raw tables.
- Keep
player_statebounded (~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)
/admin/statsis admin-only and serves a once-daily, as-of-yesterday snapshot without live DB reads.- Any past date can be viewed (point-in-time), with
—where data didn't exist. - "Mature this view" works and is clearly labeled.
- All five labs + Pulse + Signals + Glossary render from snapshots using LayerChart; layouts are mobile-friendly.
- Segment thresholds are editable in-app and re-derive all history instantly.
- Instrumentation A/B/D/E/F +
user.created_atactively record data. - Daily digest posts; slim public widget shows only safe aggregates.
bun run check,bun test,bun run buildall pass; README/env docs updated.
Appendix A — Snapshot payload shape (extend per phase)
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
{ 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.' }