A personalized quiz funnel for "The A1C Diet System," a low-ticket diabetes-coaching offer for men 50+ wanting to lower their A1C, lose weight, and reduce/stop medication (always "with your doctor's approval"). Flow: Facebook ad → quiz → privacy-safe backend → personalized sales page → GHL CRM automation. Two ad accounts run into ONE shared funnel domain, distinguished by a hardcoded funnel tag.
a1creversalprogram.com/quiz, Funnel 2 at a1creversalprogram.com/quizz. ~20 screens: questions + "Did you know?" insight cards + 2 testimonials (Ray, DJ) + a scripted fake "analyzing" animation + a name/email form.sendBeacon to the tracking API (unblockable; GA4/Meta are heavily blocked for this audience).rid and returns that rid; (b) fires the GHL webhook with first_name, email, all answer codes, utm, funnel tag, and rid; (c) redirects to the sales page with ONLY ?rid=...&utm... in the URL (no health data, no PII)./api/engagement.Identifiable health data never goes in the URL (Facebook pixel liability). Answers are saved to the API under a random rid; the URL carries only that meaningless token. The sales page fetches answers by rid. Facebook only ever sees ?rid=... Email is stored in a separate table from health answers and is never returned by the public GET. This is the BetterMe/Noom pattern.
a1creversalprogram.com.https://api.a1creversalprogram.com . Endpoints: /api/quiz-result, /api/track, /api/engagement. GitHub repo officeronan-cyber/a1c-quiz-api (private), local path (Mac) ~/Documents/a1c-quiz-api, auto-deploys on git push. Secrets (Supabase URL, service role key, STATS_KEY) live in Vercel env vars.a1c-hub. Tables: quiz_answers (rid PK, answers jsonb, utm jsonb, first_name, created_at) read by the sales page; quiz_leads (rid PK, email, first_name, created_at) private email backup, never returned by the API; quiz_events (id PK, sid, step_index, step_name, utm jsonb, ua, referer, created_at) first-party funnel tracking; engagement_events (sid, funnel, page, type, section, ms, pct, rid, views, tabaways, ua, created_at) sales-page engagement; engagement_daily rollup summary (see Session Log 2026-06-13). Supabase "Max rows" is set to 100,000.{ sid, step_index, step_name, utm } → inserts one quiz_events row, captures ua + referer server-side. Returns { ok:true }. Obvious bots (bad/blank UA) are accepted-and-ignored at write time so they never hit the table.?key=KEY&format=raw → raw events, NEWEST-FIRST, capped at 100,000 (used by the funnel dashboard). Response also returns cap + capped for the capacity gauge.?key=KEY&leads=1 → form submits with utm merged from quiz_answers (used by the dashboard leads table). Cap 50,000.?key=KEY&answers=1 → answer rows for the Response Stats page. Cap 50,000.?key=KEY → aggregated funnel summary JSON.CORS is locked to the funnel domain; the key-protected GET is opened to any origin (for the local/hosted dashboard). A critical past bug: sendBeacon always sends credentials, so preflights require Access-Control-Allow-Credentials: true — without it every beacon after step 1 was silently CORS-blocked. That header is now set.
{ answers, utm, email, first_name } → saves answers+utm (and first_name) to quiz_answers under a new rid, saves email separately to quiz_leads, returns { rid }.?rid=xxx → returns { answers, utm, first_name } only. Never returns email.{ sid, funnel, page, type, section, ms, pct, rid, views, tabaways } → inserts one engagement_events row, captures ua server-side. Obvious bots are accepted-and-ignored at write time. Numbers are NaN-guarded.?key=KEY&format=raw → raw engagement rows, newest-first, capped at 100,000 (used by the Engagement dashboard).tk tk-<name> classes.The first beacon (1_age) fires on page render, so any non-human that executes JS (Meta ad-review crawlers especially, right after ads go live/edit) creates a one-event "session" and inflates counts. Both tracking APIs now drop obvious bots at WRITE time (bad/blank UA), and the dashboards also filter at read time (per-panel "Include bots/crawlers" toggle, default OFF = humans only). A session counts as bot if any event has a bot-like or blank UA. Sessions that reached step 2+ or became leads are always treated human. Note: Meta link-clicks vs first-party sessions measure different moments and will not match; the gap is crawlers + own testing + Meta undercounting, not a bug.
Each quiz variant hardcodes the funnel tag at load: "BHM Ad account 1" (funnel 1) and "Ad account 2" (funnel 2). NOTE: the live funnel-2 file currently also carries "BHM Ad account 1" with the -7381 redirect; the owner manages the tag and it should not be "corrected" without asking. The tag survives even when Meta strips URL params, and rides on every event, every saved answer row, and the GHL webhook payload, so funnels can be compared and CAPI can be routed to the correct single pixel per lead. LinkedIn traffic uses the same funnel tag and is distinguished by utm_source.
Step order (internal step_name, counts insight/testimonial screens too):
NOTE: pre-2026-06-13 data has legacy step_names 12_choice / 16_multi / 17_choice for the three questions that had no key before; dashboards alias these to the same labels with "(old)".
Answer codes:
/index.html : funnel dashboard, 4 independent comparison panels each with own date range + funnel/utm filters + include-bots toggle, plus a leads table and a data-capacity gauge. Step names show readable question/insight labels. All aggregation is client-side from the raw endpoint./stats.html : response distribution across all 12 questions (Q1-Q12 with full question text), with funnel + utm_source + date filters./engagement.html : section dwell + scroll depth. Section table shows in page order (not sorted) with a red-to-green heatmap per column. Has its own capacity gauge.Root-caused and fixed the "today shows no data" dashboard bug. The events query pulled OLDEST-first and hit Supabase's 1000-row response cap, so once the table passed 1000 rows the newest days (today) fell off the end. Fix: query NEWEST-first (order=created_at.desc) in /api/track, and raised Supabase "Max rows" from 1000 to 100,000. The desc change alone un-breaks "today" even before the setting; the setting bump restores full history.
Added capacity gauges to the funnel and engagement dashboards. They read cap/capped from the API and show "X / 100,000 loaded" (green <70%, amber 70-90%, red/capped). When red, that is the signal to do the database-aggregate rebuild (counts via SQL instead of pulling raw rows). At ~100 leads/day this gives long runway.
Captured 3 previously-dropped questions. tried-before, time-ticking concern, and 1-year vision had no answer key, so buildParams() skipped them (never saved, never sent to GHL). Added key + ANSWER_CODES + webhook fields (tried, concern, vision) to the quiz, and created matching GHL custom fields. New data flows from the moment the updated quiz is pasted into GHL; old rows do not backfill. Funnel-1 twin of the quiz still needs the same 3 additions (only redirect differs: -8301, no -7381).
Relabeled dashboards to readable text: funnel steps show "Q1 · ..." / "Insight · ..." / "Testimonial · ..."; Response Stats shows Q1-Q12 with full question text.
Hardened both tracking APIs: write-side bot filter (drop bad/blank UA before insert) on /api/track and /api/engagement; NaN guards on engagement numbers; engagement read cap raised to 100,000.
Added utm_source filter to Response Stats (for LinkedIn vs Facebook etc.).
Engagement growth control (rollup + auto-prune). Engagement is the highest-volume table. Created engagement_daily summary table + roll_up_engagement(cutoff_days) function + a pg_cron job ("roll_up_engagement_daily", 04:10 UTC) that rolls rows OLDER THAN 30 days into the daily summary (per day x funnel x section x type) and deletes the raw rows. Recent 30 days stay raw for the detailed dashboard. Upsert-based, safe to re-run. The engagement dashboard does NOT yet read engagement_daily, so "Lifetime" there effectively means the last 30 days of detail until a future dashboard tweak; older history is preserved in the summary.
Decision: chose the small-fix + visible-gauge path over a full server-side aggregate rebuild. The rebuild (DB-side COUNT/GROUP BY, server-side date filtering) is the permanent answer at very large scale; do it when a gauge goes red. The sales-page beacon snippet was reviewed and left as-is (already well-throttled). The fetch-fallback credentials:"include" quirk was left alone (sendBeacon path unaffected; not worth risking a live edit).
No em-dashes. Direct, plain, full-sentence voice. All health claims substantiated; medication language always "with your doctor's approval." Owner is on a Mac (Terminal, not PowerShell). Deploys: download the shared file, cp ~/Downloads/<file> <dest>, verify with grep, then git add/commit/push (Vercel auto-deploys). Quiz HTML does NOT go through git; it is pasted into the GHL code editor. STATS_KEY is Freedom43! (with the exclamation mark; single-quote URLs in zsh to avoid "event not found"). When editing files, copy from the file, not from chat.