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
It's at a scale transition
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
- Retain every build. Zero feature loss. Consumer and internal both keep working at every step.
- No big-bang rewrite. No React migration, no framework. Mechanical, reversible moves only.
- Single source of truth. Every consumer-vs-internal difference lives in one declarative place. Never a hostname check, never a branch edit.
- Never break a build. One change per PR, verified on a deploy-preview, behavior identical before merge.
- Highest-leverage, lowest-risk first. Kill the scariest risk (prod blast radius) and the structural issue (segregation) before cosmetic cleanup.
The plan at a glance
| # | Workstream | Why | Effort |
|---|---|---|---|
| WS1 | Fix build segregation the issue he flagged | Kill the drifting internal branch; one config is the only source of truth | M |
| WS2 | Stop main = prod scariest risk | A bad push currently charges real cards. Add a staging gate. | S |
| WS3 | Minimal CI smoke tests | Make the promote-to-prod step trustworthy | S |
| WS4 | Split the 20k-line file | Two people can work different files; navigable | M |
| WS5 | Cleanup & env-as-code | Delete confirmed dead code; onboard a 2nd dev without tribal knowledge | S |
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:
- Runtime (good):
index.html:5206setsIS_CONSUMERby hostname and builds aBUILD_FLAVORcapability object (~13 flags:tours,accountChrome,aiRouting,analysisPrompts, etc.). Its header comment literally states: "the ONLY place host-divergent features are decided... a feature missing a flag is a bug." - Git branch (the problem): a long-lived
internalbranch that differs frommainin exactly 11 files (strips Stripe checkout + the secret coupon, removes the Pro bot-image endpoint, hardcodes "Ask Claude", swaps the live-transcript engine to Fireflies). Internal hosts deploy frominternal; consumer hosts auto-deploy frommain.
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 diff | Becomes |
|---|---|
| "Ask Claude" hardcoded in index/tour/demo | read flavor.askLabel (these should never have been branch edits) |
| checkout strips TEAMDAXOS coupon | keep code on main; gate on flavor.secretCoupons (internal has no billing UI to reach it anyway) |
| Fireflies live transcript vs Recall | branch on flavor.liveEngine; keep both code paths |
| Pro bot-image endpoint deleted | keep it; it already self-gates on isProProfile (inert for internal users) |
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.
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
| Site | Host | FLAVOR | Deploys from |
|---|---|---|---|
| consumer-analyst | callanalyst.app | consumer | release (WS2) |
| consumer-testing | consumer-testing.netlify.app | consumer | main |
| daxos-analyst | analyst.daxos.us | internal | main |
| call-analyst / -testing | *.netlify.app | internal | main |
One fix on main reaches both flavors on next deploy. No merge, no remembering.
Migration (never breaks either build, reversible)
- Extract
flavors.jscapturing today's consumer behavior verbatim. Pure refactor; deploy to consumer-testing, confirm byte-identical. - Fold each of the 11 internal diffs into a flag/module on
mainbehind the internal flavor. Verify on an internal preview (call-analyst-testing) that it matchesanalyst.daxos.usexactly. One small revertible commit each. - Set
FLAVOR=internalon the three internal sites and repoint them to deploymain. Verify each against current live behavior. - Collapse the duplicate
lib/files to one shared source. - Delete the
internalbranch last, only after parity is proven for a full deploy cycle. Until then it's the rollback.
Guardrails against future drift
- CI grep gate: fail the build if
index.html/tour.jscontain a rawlocation.hostnamecheck or a bareIS_CONSUMER ? …outsideflavors.js. - Completeness test: assert
CONSUMERandINTERNALhave the identical key set (no flag defined for one and silently missing on the other). - Lib-parity check: CI asserts shared
lib/files are identical across the two runtimes (or that only one copy exists). - One-screen flavor matrix doc beside
flavors.js, kept honest by the completeness test. - Rule: one long-lived branch (
main) only. Variants are config, never git history.
WS2 · Stop main = prod do first · an afternoon
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
main= integration → auto-deploys ONLY toconsumer-testing.release= protected →consumer-analyst(callanalyst.app) builds ONLY from this.- Promote = a single fast-forward
main → release(or Netlify's "Publish deploy" button).
Concrete Netlify settings
- consumer-analyst → Build & deploy → Production branch =
release. Branch deploys off. This alone seversmainfrom prod. - consumer-analyst → enable manual publishing (a
releasepush stages but doesn't go live until you click Publish). Belt-and-suspenders for live Stripe. - consumer-testing → Production branch =
main(auto-deploy stays on for fast feedback). - 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:
| Gate | What it does |
|---|---|
| a · Static check | node --check every function file + deno check the edge functions. Catches syntax breaks across all ~41 functions. |
| b · Boots clean | Playwright 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 endpoints | Against 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.
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)
| Suspect | Reality |
|---|---|
startMonitoring() | Truly orphaned — zero callers (boot polls via checkMeetings() now). Safe to delete. |
Duplicate lib/ files | Real problem — bot-branding.mjs + _r2.mjs byte-identical across both trees. Collapse to one shared source. |
/api/recall/scheduled | Not a route at all — "scheduled" is only a DB status string. Nothing to delete. (Corrects an earlier note.) |
aiProv radios | Already gone — only a live state key remains. Leave it. |
| Consumer file-drop | Half-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
Set consumer-analyst production branch to
release + manual publish. Zero code. Removes the live-Stripe blast radius immediately.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.Makes the promote-to-prod step trustworthy and lets the second dev stand up sites alone.
CSS out first, then leaf modules, core last. One module per PR. Now genuinely safe because the staging gate + smoke tests catch mistakes.
Delete the two confirmed-dead things; collapse duplicate libs; carefully reconcile the drifted
shared.mjs.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
Mark + AI lead
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
- Don't rewrite in React/TypeScript now. It optimizes for the dev's comfort over the runway. The plan above gets ~80% of the maintainability for ~5% of the effort and keeps shipping. Revisit a framework only after the file is split and segregation is fixed.
- Don't add a build step just to fix segregation. Runtime config solves it with zero build. A bundler is a Phase-2 maintainability choice, not a segregation prerequisite.
- Don't delete the half-wired file-drop. It's the R2 upload seam. Flag it.
- Don't keep a product-variant branch. The whole point is that variants are config. No new long-lived branches.
- Don't move and refactor in the same PR. Move verbatim, refactor later. It keeps every step reversible.
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:
| File | What 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.mjs | removes the TEAMDAXOS secret-coupon path |
functions/ask.mjs | refusal message "The AI declined" → "Claude declined" |
edge/recall-bot-create.mjs | botVideoOutput(profile) → static (no Pro custom image) |
functions/calendar-poll.mjs · calendar-poll-now.mjs | same static bot image; drops bot_image_b64 from the select |
functions/lib/bot-branding.mjs · edge/lib/bot-branding.mjs | deletes isProProfile() + botVideoOutput() (in both duplicated copies) |
edge/profile-bot-image.mjs | deletes 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 file | From index.html lines | Risk |
|---|---|---|
styles.css | 40–4952 (CSS) | S |
js/01-flavor.js | 5197–5290 (extract first — leaf) | S |
js/02–05 (paywall-ui, bot-image, calendar, org-byok) | 5058–6942 (self-contained IIFEs) | S |
js/10-core.js | 7284–13094 — extract LAST, intact | L |
js/20-auth.js | 12633–12930 (async IIFE) | S |
js/30-projects-hub.js | 13097–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 |
cus-commits/call-analyst and the live Netlify topology. Companion: the build docs. Behind the Daxos password gate. Questions → Mark.