Fullstack TypeScript runtime for configurable multi-step funnels, pinned configuration versions, A/B experiments, batched events, analytics, synthetic traffic, and rollback. The application uses Next.js, React, Bun, and SQLite without external services.
Requirements: Bun 1.4.0 and a Chromium installation for Playwright.
cp .env.example .env
bun install --frozen-lockfile
bun run migrate
bun run devOpen http://localhost:3000 for the funnel and http://localhost:3000/admin/login for administration. Migration 0002_seed_initial_funnel.sql publishes the initial funnel in an empty database.
| Variable | Purpose |
|---|---|
SQLITE_PATH |
Persistent SQLite file; defaults to data/app.sqlite |
APP_URL |
External application URL |
ADMIN_PASSWORD |
Password for the internal pages |
ADMIN_SIGNING_SECRET |
HMAC secret for the admin session cookie |
BASE_PATH |
Optional URL prefix for production deployment and server-side path helpers |
NEXT_PUBLIC_BASE_PATH |
Optional client-side URL prefix; falls back to BASE_PATH when unset |
LOG_LEVEL |
Optional production log verbosity; see .github/workflows/scripts/production.env.example |
DATA_RETENTION_* |
Optional production retention schedule; see .github/workflows/scripts/production.env.example |
| Command | Purpose |
|---|---|
bun run dev |
Development server |
bun run build |
Production build |
bun run start |
Production server |
bun run typecheck |
Generate Next route types and run TypeScript |
bun run fmt |
Format the repository |
bun run fmt:check |
Check formatting |
bun run migrate |
Apply immutable SQL migrations and seed an empty database |
bun test |
Unit and integration tests |
bun run test:e2e |
Playwright tests against an isolated production build |
bun run generate:traffic -- --sessions 120 --seed 42 |
Generate deterministic traffic for the active version |
bun run generate:traffic -- --version-id <id> --sessions 120 --seed 42 --date 2026-09-01 |
Generate traffic for a selected version and event date |
The generator requires at least 100 sessions. It creates both variants, several UTM campaigns and branches, drop-offs, Back/repeated views with different IDs, duplicate batch delivery, and reversed event delivery. It prints the resulting summary so the dashboard can be checked against the generated database.
system/funnel/config.schema.ts validates JSON before publication. A config contains:
- at least six base steps;
- at least one conditional transition (
when); single-select,multi-select,number, andinfostep types;- answer constraints and conditional transitions;
- a result screen and primary CTA;
- variant A/B overrides for text, exact step order, excluded steps, and result content;
- optional version-scoped custom event names.
When stepOrder is present, it defines the effective default sequence. Conditional transitions whose targets remain in the effective variant are preserved; transitions to omitted steps are removed and use the ordered default path. Publication rejects empty or duplicate variant orders and dangling effective transitions.
The frontend renders the effective backend config and contains no funnel-specific screens. The backend validates every mutation and persists answers, current step, history, and progress.
SQLite migrations are in migrations/:
funnel_versions: immutable JSON snapshots;funnel_activation_history: append-only publication and rollback ledger; the latest row is active;sessions: pinned version, pinned A/B variant, initial UTM values, answers, current state, history, and the durablesession_startedevent ID;session_transitions: immutable forward transitions used to validate completion facts;events: analytics facts keyed by uniqueevent_idand enriched with trusted session attribution.
Publishing creates a new immutable version and activation in one transaction. Rollback appends an activation for an existing version and never deletes versions, sessions, or events. Existing cookies restore their pinned version and variant after refresh, publication, or rollback; only new sessions use the current active version. ?variant=A and ?variant=B override assignment only when a new session is created.
POST /api/events accepts { "events": [...] }. Standard events are:
session_started;step_viewed;answer_submitted;step_completed;back_clicked;result_viewed;cta_clicked.
Each item supplies eventId, eventName, sessionId, clientTimestamp, optional stepId, optional transitionId, and safe event properties. The server adds server timestamp, pinned version/variant, and UTM values. step_completed must reference the transition created by the corresponding state mutation. Raw answer keys are rejected recursively.
Every item is parsed and processed independently. A malformed neighbor does not prevent valid items from being accepted. Reusing an accepted eventId returns duplicate, including retries of step_completed, and creates no additional fact. Browser intents survive uncertain delivery in sessionStorage, are retried with their original IDs, and are replayed on the next page load if both immediate attempts fail.
Custom events are scoped to the pinned config. The seeded v1 config accepts benefit_highlight_viewed; fixtures/funnels/iteration-2.json adds premium_interest_signal for wellness-quiz-v2 while keeping benefit_highlight_viewed. Sending a custom event outside the pinned config is rejected without affecting that session.
All user counts use distinct session IDs rather than event counts:
- started: unique sessions with
session_started; - primary conversion: unique sessions with
cta_clickeddivided by started sessions; - result reach: unique sessions with
result_vieweddivided by started sessions; - CTA CTR: unique sessions with
cta_clickeddivided by sessions that viewed the result; - edge conversion: unique sessions completing a concrete transition divided by unique sessions viewing its source step;
- step drop-off: viewed sessions without any completion from that source step.
Metrics are grouped by pinned version and variant. Filters support UTM campaign, version, variant, and client event date. Distinct aggregation and immutable transition facts prevent duplicate IDs, repeated views, Back navigation, and out-of-order arrival from inflating totals. Empty denominators produce no rate instead of division by zero.
Hypothesis: variant B's shorter copy, low-friction ordered sequence, and concise result reduce abandonment and increase CTA-from-start conversion relative to variant A.
Primary metric: unique sessions with cta_clicked / unique sessions with session_started for each variant within the same version, date range, and campaign mix.
Result reach and per-step drop-off are diagnostic metrics. CTA CTR separates result-screen effectiveness from upstream funnel friction. A higher raw rate is not evidence of a winner by itself: compare equivalent traffic slices and wait for an adequate sample before drawing a statistical conclusion.
- First iteration: dynamic funnel, server-persisted state, immutable versions, A/B assignment, events, analytics, admin pages, traffic generation, and automated tests.
- Second iteration:
fixtures/funnels/iteration-2.jsonadds the premium branch andpremium_interest_signal, removes steps from variant B, publishes through the same admin API without a schema change, verifies old/new session compatibility, and rolls back while retaining analytics.
.github/workflows/ci.yml checks frozen installation, formatting, types, tests, build, and Playwright. .github/workflows/deploy.yml builds an archive and deploys it to a configured host. .github/workflows/scripts/run-archive.sh applies migrations, swaps releases, checks /api/health, and restores the previous release if deployment fails. SQLite remains outside the release directory.
- Bun is the server runtime, an intentional deviation from the assignment's literal Node.js wording.
- SQLite and the process-local connection model target a single application instance.
- Admin authentication is a shared password with a signed, expiring HTTP-only cookie.
- The visual config editor is intentionally out of scope.
- Runtime retention can delete old session analytics when explicitly enabled in production.
