A1C Quiz Funnel — Technical Context

Purpose

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.

Live Flow

  1. FB ad (men 50+) clicks to the quiz.
  2. Quiz runs inside a GoHighLevel funnel step. Funnel 1 at 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.
  3. On every screen, the quiz fires a first-party sendBeacon to the tracking API (unblockable; GA4/Meta are heavily blocked for this audience).
  4. On submit, the quiz: (a) POSTs answers+email+first_name+utm to the quiz API, which saves them in Supabase under a random 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).
  5. Sales page reads rid, fetches answers+first_name from the API, assembles a personalized breakdown. The rest of the sales page sits below, with first-party engagement tracking (section dwell + scroll depth) firing to /api/engagement.

Privacy Architecture (core design)

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.

Infrastructure

The /api/track Endpoint

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.

The /api/quiz-result Endpoint

The /api/engagement Endpoint

Bot / Traffic Notes

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.

Funnel Tagging

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.

Quiz Answer Keys & Codes

Step order (internal step_name, counts insight/testimonial screens too):

1_age, 2_a1c (slider + "I don't know"), 3_goal, 4_fat, 5_insight, 6_muscle, 7_insight, 8_years, 9_insight, 10_meds, 11_testimonial(Ray), 12_tried(tried before), 13_obstacle, 14_testimonial(DJ), 15_why(multi), 16_concern(multi), 17_vision(future self), 18_analyzing, 19_form.

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:

age[40s,50s,60s,65plus]; goal[a1c,weight,meds,energy]; fat[belly,liver,slim,none]; muscle[strong,softer,weaker,unsure]; years[new,fewyears,5to10,10plus]; meds[doseup,samedose,reduce,nomeds]; tried[worksthencreeps,stress,disciplinednomove,nothingworks]; obstacle[food,time,energy,norestults(typo is intentional/live),notsure]; why[family,familyroad,meds,independence] comma-joined; concern[toolate,fragile,serious,retirement,climbanyway] comma-joined; vision[below56,lighterstronger,offmeds,feelnormal]; a1c = the number or "0" when "I don't know".

Dashboards

Session Log

2026-06-13 · cap bug, hardening, engagement rollup

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).

Standing Preferences (for whoever picks this up)

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.