Call Analyst — Refactor & Build-Segregation Plan

How to organize this codebase into something two people can maintain, fix the build segregation properly, and keep every existing feature working. No rewrite. Incremental, reversible, ground-truthed against the actual repo.

Repo: cus-commits/call-analyst · App: call-analyst-pwa/index.html (~20,500 lines) · Companion: the build docs explain how it works today.

The premise (let's be honest)

The codebase is a 20k-line single-file PWA with no build step, two products served from one file, and a second segregation mechanism (a forked git branch) that drifts. A senior dev looks at that and says "nightmare," and on team-scale standards that's fair. But two things are also true:

It works and it ships

The single-file, no-build design is why it shipped this fast solo. That was the right trade for one person. We are not throwing that away.

It's at a scale transition

The moment a second human works in it, "no module boundaries" and "two ways to segregate builds that drift" become real costs. That's now. That's what this plan fixes.

So this is not "you built it wrong, rewrite it." It's "it's graduating from a solo prototype that prints money into something a team maintains." Different phase, different rules. The plan below buys ~80% of the maintainability for ~5% of the effort, and never stops shipping.

Guiding principles

The plan at a glance

#WorkstreamWhyEffort
WS1Fix build segregation the issue he flaggedKill the drifting internal branch; one config is the only source of truthM
WS2Stop main = prod scariest riskA bad push currently charges real cards. Add a staging gate.S
WS3Minimal CI smoke testsMake the promote-to-prod step trustworthyS
WS4Split the 20k-line fileTwo people can work different files; navigableM
WS5Cleanup & env-as-codeDelete confirmed dead code; onboard a 2nd dev without tribal knowledgeS
The encouraging finding from reading the repo: the right design is mostly already there. main's BUILD_FLAVOR object was built to be the single source of truth (its own header comment says so). The internal branch is a redundant second mechanism. The fix is to finish what main started and delete the branch, not invent something new.

WS1 · Fix build segregation priority

What's broken (verified in the repo)

Consumer (callanalyst.app) vs internal (analyst.daxos.us) is enforced two ways at once:

Why it drifts: internal is currently 9 commits behind main and 5 commits ahead — drift in both directions, right now. A shared fix lands on main and reaches internal only if a human remembers to merge before the next CLI deploy. This already caused a real bug (a live-calls fix that had to be hand-carried into internal). Two mechanisms for one split is the root cause.

Target: one source of truth, no divergent branch

Kill the internal branch. All code on main. Promote BUILD_FLAVOR into a dedicated flavors.js exporting two config objects:

// call-analyst-pwa/flavors.js  — the ONLY place builds differ
export const CONSUMER = {
  brand:'Call Analyst', askLabel:'Ask AI', aiRouting:'proxy',
  liveEngine:'recall', billing:true, secretCoupons:true,
  proBotImage:true, tours:true, accountChrome:true, /* ...all flags... */
};
export const INTERNAL = {
  brand:'Daxos Analyst', askLabel:'Ask Claude', aiRouting:'direct',
  liveEngine:'fireflies', billing:false, secretCoupons:false,
  proBotImage:false, tours:false, passwordGate:true, /* ...all flags... */
};

Each of the 11 branch-diffs becomes a flag, a brand string, or a conditionally-loaded module — never a branch edit:

Today's branch diffBecomes
"Ask Claude" hardcoded in index/tour/demoread flavor.askLabel (these should never have been branch edits)
checkout strips TEAMDAXOS couponkeep code on main; gate on flavor.secretCoupons (internal has no billing UI to reach it anyway)
Fireflies live transcript vs Recallbranch on flavor.liveEngine; keep both code paths
Pro bot-image endpoint deletedkeep it; it already self-gates on isProProfile (inert for internal users)
Runtime, not build-time. There's no bundler today, so a build-time FLAVOR stamp would mean adding a build step just to strip a few hundred lines. Not worth it. Select the config by hostname at load (status quo). It's forward-compatible: if a bundler is added later (WS4 Phase 2), the same flavors.js can be read at build time to tree-shake.

The serverless side

Functions differ by flavor too (Stripe = consumer only). Replace branch edits with one env var per site: FLAVOR=consumer|internal. Functions read process.env.FLAVOR (edge: Deno.env.get) and branch. Most "internal" differences are just the absence of a caller (internal has no billing UI), so the code can live on main unconditionally and stay inert.

Latent landmine found: lib/bot-branding.mjs and _r2.mjs are byte-identical duplicates across functions/lib/ and edge-functions/lib/ — a fix to one silently misses the other (the exact bug class we're killing). _shared.mjs vs lib/shared.mjs have already drifted (4,148 vs 6,876 bytes). Reconcile to one shared source (WS5).

Deploy topology after the fix

SiteHostFLAVORDeploys from
consumer-analystcallanalyst.appconsumerrelease (WS2)
consumer-testingconsumer-testing.netlify.appconsumermain
daxos-analystanalyst.daxos.usinternalmain
call-analyst / -testing*.netlify.appinternalmain

One fix on main reaches both flavors on next deploy. No merge, no remembering.

Migration (never breaks either build, reversible)

  1. Extract flavors.js capturing today's consumer behavior verbatim. Pure refactor; deploy to consumer-testing, confirm byte-identical.
  2. Fold each of the 11 internal diffs into a flag/module on main behind the internal flavor. Verify on an internal preview (call-analyst-testing) that it matches analyst.daxos.us exactly. One small revertible commit each.
  3. Set FLAVOR=internal on the three internal sites and repoint them to deploy main. Verify each against current live behavior.
  4. Collapse the duplicate lib/ files to one shared source.
  5. Delete the internal branch last, only after parity is proven for a full deploy cycle. Until then it's the rollback.

Guardrails against future drift

WS2 · Stop main = prod do first · an afternoon

The risk: main auto-publishes to both consumer-testing and production (callanalyst.app, live Stripe) at the same instant. There is no staging gate. A bad push charges real cards.

Target: one integration branch, one promote step

Concrete Netlify settings

  1. consumer-analyst → Build & deploy → Production branch = release. Branch deploys off. This alone severs main from prod.
  2. consumer-analyst → enable manual publishing (a release push stages but doesn't go live until you click Publish). Belt-and-suspenders for live Stripe.
  3. consumer-testing → Production branch = main (auto-deploy stays on for fast feedback).
  4. GitHub → branch protection on release: require PR + green CI, no force-push.

Rollback: Netlify keeps every prior deploy immutable. consumer-analyst → Deploys → pick last-good → Publish. Instant, no rebuild. Record the last-good deploy ID in each release PR.

WS3 · Minimal CI smoke tests

Not coverage. The three gates that actually predict "did I break prod," on every PR:

GateWhat it does
a · Static checknode --check every function file + deno check the edge functions. Catches syntax breaks across all ~41 functions.
b · Boots cleanPlaywright headless loads /app, waits for #cvHome, asserts zero console errors. On a 20k-line single file, a parse error or undefined-on-boot is the most common prod-breaker.
c · Critical endpointsAgainst the deploy-preview: GET /app → 200; POST /api/ask unauth → expect 401 (proves the auth gate is intact); POST /api/checkout with TEST keys → returns a session (billing path compiles without touching live keys).

Total < 3 min. Make it a required check on release.

WS4 · Split the 20k-line file

Reading the file, it already partitions cleanly: one ~4,900-line CSS block, one ~5,800-line bare-global core (index.html:7284–13094 — holds store, getSessions, getKeys, MODEL_INFO, renderContent), and ~12 already-IIFE-wrapped consumer blocks (Projects Hub, calls list, recall live, settings-v2…) that talk to the core through a handful of named globals. tour.js already proves external files work.

Phase 1 — ordered <script src> files, no build step low risk

Move each block verbatim into call-analyst-pwa/js/, loaded in order (leaf → core → consumers). Classic (non-module) scripts share one global scope, so behavior is byte-identical to today. Keep globals global for now — converting to a namespace is a later refactor, not part of the move.

call-analyst-pwa/
  index.html            # HTML + <link styles.css> + ordered <script src> tags
  styles.css            # the 4,900-line <style> block  (extract FIRST)
  js/ 01-flavor.js  02-paywall-ui.js  03-bot-image.js  04-calendar.js
      05-org-byok.js  10-core.js      20-auth.js        30-projects-hub.js
      40-calls-list.js 41-recall-live.js 50-app-shell.js 51-home-wiring.js
      60-settings-v2.js 61-plan-picker.js 62-credits-nudge.js 63-autocreate.js
  flavors.js  tour.js  sw.js

The one invariant is load order: 01-flavor first, 10-core before any consumer, and the files that decorate window.renderContent (home-wiring, calls-list, settings-v2) after core. Extract leaves first, 10-core.js last and all at once — everything reads it by bare name, so it's the riskiest move.

Phase 2 — esbuild + ES modules + incremental TypeScript only when a 2nd dev is steady

Add a one-command esbuild bundle (bundles 20k lines in tens of ms, so deploys stay effectively instant — the velocity advantage is kept). Convert globals to real import/export one leaf at a time. Add types progressively: // @ts-check + JSDoc on leaf files first, then rename clean leaves to .ts. Never touch 10-core.js until its dependencies are typed.

Effort/risk per module: CSS, leaf IIFEs, auth = S low. App-shell, home-wiring = M med (load-order sensitive, patch renderContent). 10-core.js = L high (the render loop + getSessions; move intact, last). Rule set: one module per PR, no refactor while moving, deploy-preview every PR, behavior-identical is the only acceptance test.

WS5 · Cleanup & env-as-code

Dead code — what's actually dead (we checked, not assumed)

SuspectReality
startMonitoring()Truly orphaned — zero callers (boot polls via checkMeetings() now). Safe to delete.
Duplicate lib/ filesReal problembot-branding.mjs + _r2.mjs byte-identical across both trees. Collapse to one shared source.
/api/recall/scheduledNot a route at all — "scheduled" is only a DB status string. Nothing to delete. (Corrects an earlier note.)
aiProv radiosAlready gone — only a live state key remains. Leave it.
Consumer file-dropHalf-wired but load-bearing — it's the seam for the planned R2 upload (projects-upload-url.mjs has no client caller yet). Flag with a TODO, don't delete. Either finish the R2 wiring or hide the storage meter for consumers.

Safe-removal process for any deletion: grep for all references → delete on a branch → deploy-preview + smoke → bake on consumer-testing 24–48h → watch /analytics.html js_error events before promoting.

Env / secrets as code

The CLI env:set --site silently fails; the working pattern is DELETE-then-POST via the Netlify API. Encode it: a checked-in infra/env-manifest.json (var names per site, no values) + a small infra/sync-env.mjs that reads the manifest and a local untracked .env.<site> and syncs via the API. A second dev runs node infra/sync-env.mjs consumer-testing and a site stands up with no tribal knowledge. Hygiene (CI-enforced): grep-deny hardcoded secret fallbacks (process.env.X || 'sk-…'); keep GitHub push-protection on.

Sequenced roadmap

0
Staging gate (WS2) an afternoon do first
Set consumer-analyst production branch to release + manual publish. Zero code. Removes the live-Stripe blast radius immediately.
1
Build segregation (WS1) ~1 week the structural fix
Extract flavors.js, fold the 11 internal diffs into flags, repoint internal sites to main + FLAVOR env, delete the internal branch last. Add the drift guardrails.
2
CI smoke + env-as-code (WS3 + WS5 env) 2–3 days
Makes the promote-to-prod step trustworthy and lets the second dev stand up sites alone.
3
Split the monolith — Phase 1 (WS4) ~1 week, incremental
CSS out first, then leaf modules, core last. One module per PR. Now genuinely safe because the staging gate + smoke tests catch mistakes.
4
Cleanup + dup-lib reconcile (WS5) 2–3 days
Delete the two confirmed-dead things; collapse duplicate libs; carefully reconcile the drifted shared.mjs.
5
Optional: esbuild + TS — Phase 2 (WS4) ongoing
Only once the file is split and a second dev is steady. Incremental, never blocks shipping.

How two people split the work

Friend (senior dev) leads

WS1 segregation (the architecture call), WS2/WS3 release infra + CI, the dup-lib reconcile. These are the judgment-heavy, "set it up right once" pieces.

Mark + AI lead

WS4 module extraction (mechanical, AI-friendly — one block per PR), WS5 dead-code + env manifest, and continuing feature work (the 7-feature roadmap) on branches off main.

The file-split (WS4) is what makes this division possible: once Projects Hub, billing, and the live-call engine are separate files, the two of you stop colliding in one 20k-line file.

What NOT to do

Appendix · the 11-file internal branch diff

From git diff --stat origin/main origin/internal — this is the exact punch-list to fold into flavors.js:

FileWhat internal changes
index.html (~1131 lines)"Ask Claude" hardcoded; Fireflies live-transcript path; strips Pro bot-image UI + &code= coupon param; keys-first settings order
tour.js"Ask AI" → "Ask Claude" across all 6 tour variants
demo.html"ASK AI" → "ASK CLAUDE", "AI is thinking" → "Claude is thinking"
functions/checkout.mjsremoves the TEAMDAXOS secret-coupon path
functions/ask.mjsrefusal message "The AI declined" → "Claude declined"
edge/recall-bot-create.mjsbotVideoOutput(profile) → static (no Pro custom image)
functions/calendar-poll.mjs · calendar-poll-now.mjssame static bot image; drops bot_image_b64 from the select
functions/lib/bot-branding.mjs · edge/lib/bot-branding.mjsdeletes isProProfile() + botVideoOutput() (in both duplicated copies)
edge/profile-bot-image.mjsdeletes the whole Pro bot-image endpoint

Every one of these is a brand string, a feature flag, or a conditional module on main — none of them need to be a branch edit.

Appendix · the file-split map

New fileFrom index.html linesRisk
styles.css40–4952 (CSS)S
js/01-flavor.js5197–5290 (extract first — leaf)S
js/02–05 (paywall-ui, bot-image, calendar, org-byok)5058–6942 (self-contained IIFEs)S
js/10-core.js7284–13094 — extract LAST, intactL
js/20-auth.js12633–12930 (async IIFE)S
js/30-projects-hub.js13097–15745 (capDrLayer + aap2)S
js/40–63 (calls-list, recall-live, app-shell, home-wiring, settings-v2…)15801–20511 (already IIFEs; mind renderContent patch order)M
Plan synthesized 2026-06-17 from three repo-grounded design passes (decomposition · segregation · release-safety), each verified against a fresh clone of cus-commits/call-analyst and the live Netlify topology. Companion: the build docs. Behind the Daxos password gate. Questions → Mark.