Visibility ROI Tracker
Track time, effort, and spend across traditional search + AI answer engines, then model ROI from AI SEO and AI agents.
Business inputs
Baseline performance before AI improvements.
Capture internal time cost — hours reduced with AI agents.
ROI snapshot
let S = defaultState();
// --- Helpers --- const $ = (id) => document.getElementById(id); const n = (v) => isFinite(v) ? Number(v) : 0; const clamp = (v,min,max)=>Math.min(max,Math.max(min,v)); const fmtInt = (v)=> new Intl.NumberFormat(undefined,{maximumFractionDigits:0}).format(v); const fmtPct = (v)=> (v*100).toFixed(1) + \"%\"; const fmtMoney = (v,cur)=> new Intl.NumberFormat(undefined,{style:'currency',currency:cur,maximumFractionDigits:0}).format(v);
function avgRampFactor(){ const H = Math.max(1, Math.floor(n(S.scenario.horizonMonths))); const R = Math.max(0, Math.floor(n(S.scenario.rampMonths))); if(R===0) return 1; let total=0; for(let m=1;m<=H;m++){ total += (m<=R) ? (m/R) : 1; } return total/H; } function totals(){ const spend = S.channels.reduce((a,c)=>a+n(c.monthlySpend),0); const hrs = S.channels.reduce((a,c)=>a+n(c.hoursPerMonth),0); return { spend, hrs }; }
function compute(){ const cur = S.biz.currency; const ramp = avgRampFactor(); const conservative = !!S.scenario.conservativeMode;
const leadLift = S.ai.includeAiSeo ? n(S.ai.aiSeoLiftLeads) : 0; const convLiftRel = S.ai.includeAiSeo ? n(S.ai.aiLeadQualityLift) : 0;
const leadLiftAdj = conservative ? leadLift*0.7 : leadLift; const convLiftAdj = conservative ? convLiftRel*0.7 : convLiftRel;
const baseLeads = n(S.baseline.monthlyLeads); const baseConv = n(S.baseline.leadToCustomerRate); const baseCustomers = baseLeads * baseConv; const baseRevenue = baseCustomers * n(S.baseline.averageOrderValue); const baseProfit = baseRevenue * n(S.baseline.grossMargin);
const newLeads = baseLeads * (1 + leadLiftAdj * ramp); const newConv = baseConv * (1 + convLiftAdj * ramp); const newCustomers = newLeads * newConv; const newRevenue = newCustomers * n(S.baseline.averageOrderValue); const newProfit = newRevenue * n(S.baseline.grossMargin);
const incrLeads = Math.max(0, newLeads - baseLeads); const incrCustomers = Math.max(0, newCustomers - baseCustomers); const incrRevenue = Math.max(0, newRevenue - baseRevenue); const incrProfit = Math.max(0, newProfit - baseProfit);
const t = totals(); const effortHours = n(S.effort.ownerHoursPerMonth) + n(S.effort.teamHoursPerMonth) + t.hrs;
const timeSavedPct = S.ai.includeAiAgents ? n(S.ai.timeSavedPct) : 0; const timeSavedAdj = conservative ? timeSavedPct*0.8 : timeSavedPct; const hoursSaved = effortHours * timeSavedAdj;
const blended = n(S.effort.ownerHourlyValue)*0.4 + n(S.effort.teamHourlyCost)*0.6; const timeValueSaved = hoursSaved * blended;
const externalSpend = t.spend + n(S.ai.monthlyAgencyOrContractorCost); const moneySavedPct = S.ai.includeAiAgents ? n(S.ai.moneySavedPct) : 0; const moneySavedAdj = conservative ? moneySavedPct*0.8 : moneySavedPct; const moneySaved = externalSpend * moneySavedAdj;
const aiCost = (S.ai.includeAiSeo || S.ai.includeAiAgents) ? n(S.ai.monthlyAiToolsCost) : 0;
const netMonthlyBenefit = incrProfit + timeValueSaved + moneySaved - aiCost;
const H = Math.max(1, Math.floor(n(S.scenario.horizonMonths))); const netHorizon = netMonthlyBenefit * H; const totalAiCost = aiCost * H; const roi = totalAiCost > 0 ? (netHorizon - totalAiCost) / totalAiCost : null; const payback = (netMonthlyBenefit > 0 && aiCost > 0) ? (aiCost / netMonthlyBenefit) : null;
return {cur, ramp, baseLeads, incrLeads, newLeads, incrCustomers, incrRevenue, incrProfit, effortHours, hoursSaved, timeValueSaved, moneySaved, aiCost, netMonthlyBenefit, netHorizon, roi, payback, totalSpend:t.spend}; }
// --- UI Rendering --- const currencies = ["GBP","USD","EUR","AUD","CAD","INR"];
function renderCurrencyChips(){ const wrap = $("vrt-currencyChips"); wrap.innerHTML = ""; currencies.forEach(c=>{ const b = document.createElement("button"); b.type="button"; b.className = "vrt-chip" + (S.biz.currency===c ? " is-active" : ""); b.textContent = c; b.addEventListener("click", ()=>{ S.biz.currency=c; renderCurrencyChips(); syncInputsToCurrency(); render(); }); wrap.appendChild(b); }); }
function channelRow(c){ const div = document.createElement("div"); div.className = "vrt-channel"; div.innerHTML = `
`; return div; }
function renderChannels(){ const wrap = $("vrt-channels"); wrap.innerHTML = ""; S.channels.forEach(c=>wrap.appendChild(channelRow(c))); }
function renderTotals(){ const t = totals(); $("vrt-totals").textContent = `Totals: Spend ${fmtMoney(t.spend, S.biz.currency)} / month · ${fmtInt(t.hrs)} hrs / month`; }
function renderMetrics(){ const m = compute(); $("m-incrLeads").textContent = fmtInt(m.incrLeads); $("m-leadsSub").textContent = `New: ${fmtInt(m.newLeads)} vs Baseline: ${fmtInt(m.baseLeads)}`; $("m-incrCustomers").textContent = fmtInt(m.incrCustomers); $("m-customersSub").textContent = `Ramp avg: ${fmtPct(m.ramp)}`; $("m-incrRevenue").textContent = fmtMoney(m.incrRevenue, m.cur); $("m-revSub").textContent = `AOV: ${fmtMoney(n(S.baseline.averageOrderValue), m.cur)}`; $("m-incrProfit").textContent = fmtMoney(m.incrProfit, m.cur); $("m-profitSub").textContent = `Gross margin: ${fmtPct(n(S.baseline.grossMargin))}`;
$("m-hoursSaved").textContent = fmtInt(m.hoursSaved) + " hrs"; $("m-hoursSub").textContent = `Total effort tracked: ${fmtInt(m.effortHours)} hrs/month`; $("m-timeValue").textContent = fmtMoney(m.timeValueSaved, m.cur); $("m-moneySaved").textContent = fmtMoney(m.moneySaved, m.cur); $("m-aiCost").textContent = fmtMoney(m.aiCost, m.cur);
$("m-netMonthly").textContent = fmtMoney(m.netMonthlyBenefit, m.cur); $("m-horizonTitle").textContent = `Net benefit over ${Math.max(1,Math.floor(n(S.scenario.horizonMonths)))} months`; $("m-netHorizon").textContent = fmtMoney(m.netHorizon, m.cur); $("m-rampSub").textContent = `Average ramp factor: ${fmtPct(m.ramp)}`;
$("m-roi").textContent = (m.roi===null) ? "—" : fmtPct(m.roi); $("m-payback").textContent = (m.payback===null) ? "—" : (m.payback.toFixed(1) + " months");
const netCard = $("m-netCard"); netCard.classList.remove("vrt-good","vrt-bad","vrt-warn"); if (m.netMonthlyBenefit > 0) netCard.classList.add("vrt-good"); else if (m.netMonthlyBenefit < 0) netCard.classList.add("vrt-bad"); else netCard.classList.add("vrt-warn"); const name = (S.biz.businessName||"the business").trim() || "the business"; const H = Math.max(1,Math.floor(n(S.scenario.horizonMonths))); const summary = `Based on your tracked visibility investment (~${fmtMoney(m.totalSpend,m.cur)}/month and ${fmtInt(m.effortHours)} hours/month), implementing AI SEO and AI agents for ${name} is modelled to add about ${fmtInt(m.incrLeads)} extra leads and ${fmtInt(m.incrCustomers)} extra customers per month, contributing ~${fmtMoney(m.incrRevenue,m.cur)} revenue and ~${fmtMoney(m.incrProfit,m.cur)} profit monthly. In parallel, automation is estimated to save ~${fmtInt(m.hoursSaved)} hours and ~${fmtMoney(m.moneySaved,m.cur)} in external spend per month. Net benefit is ~${fmtMoney(m.netMonthlyBenefit,m.cur)}/month, or ~${fmtMoney(m.netHorizon,m.cur)} over ${H} months.`;\n $(\"m-summary\").textContent = summary;\n }\n\n function escapeHtml(s){\n return String(s||\"\")\n .replaceAll(\"&\",\"&\")\n .replaceAll(\"<\",\"<\")\n .replaceAll(\">\",\">\")\n .replaceAll('\"',\""\")\n .replaceAll(\"'\",\"'\");\n }\n\n function syncInputsToCurrency(){\n // purely cosmetic labels already use S.biz.currency in renders\n }\n\n function render(){\n renderCurrencyChips();\n renderChannels();\n renderTotals();\n renderMetrics();\n }\n\n // --- Wire up tab switching ---\n document.querySelectorAll(\".vrt-tab\").forEach(btn=>{\n btn.addEventListener(\"click\",()=>{\n document.querySelectorAll(\".vrt-tab\").forEach(b=>b.classList.remove(\"is-active\"));\n document.querySelectorAll(\".vrt-tabpane\").forEach(p=>p.classList.remove(\"is-active\"));\n btn.classList.add(\"is-active\");\n const tab = btn.getAttribute(\"data-tab\");\n $(\"vrt-tab-\"+tab).classList.add(\"is-active\");\n });\n });\n\n // --- Input bindings ---\n function bindBasics(){\n $(\"vrt-businessName\").addEventListener(\"input\", e=>{ S.biz.businessName=e.target.value; renderMetrics(); });\n $(\"vrt-industry\").addEventListener(\"input\", e=>{ S.biz.industry=e.target.value; });\n $(\"vrt-location\").addEventListener(\"input\", e=>{ S.biz.location=e.target.value; });\n\n $(\"vrt-monthlyLeads\").addEventListener(\"input\", e=>{ S.baseline.monthlyLeads=n(e.target.value); renderMetrics(); });\n $(\"vrt-convPct\").addEventListener(\"input\", e=>{ S.baseline.leadToCustomerRate=clamp(n(e.target.value)/100,0,1); renderMetrics(); });\n $(\"vrt-aov\").addEventListener(\"input\", e=>{ S.baseline.averageOrderValue=n(e.target.value); renderMetrics(); });\n $(\"vrt-gmPct\").addEventListener(\"input\", e=>{ S.baseline.grossMargin=clamp(n(e.target.value)/100,0,1); renderMetrics(); });\n\n $(\"vrt-ownerHourly\").addEventListener(\"input\", e=>{ S.effort.ownerHourlyValue=n(e.target.value); renderMetrics(); });\n $(\"vrt-teamHourly\").addEventListener(\"input\", e=>{ S.effort.teamHourlyCost=n(e.target.value); renderMetrics(); });\n $(\"vrt-ownerHours\").addEventListener(\"input\", e=>{ S.effort.ownerHoursPerMonth=n(e.target.value); renderMetrics(); });\n $(\"vrt-teamHours\").addEventListener(\"input\", e=>{ S.effort.teamHoursPerMonth=n(e.target.value); renderMetrics(); });\n\n $(\"vrt-aiSeoOn\").addEventListener(\"change\", e=>{ S.ai.includeAiSeo=!!e.target.checked; renderMetrics(); });\n $(\"vrt-leadLiftPct\").addEventListener(\"input\", e=>{ S.ai.aiSeoLiftLeads=clamp(n(e.target.value)/100,0,2); renderMetrics(); });\n $(\"vrt-qualityLiftPct\").addEventListener(\"input\", e=>{ S.ai.aiLeadQualityLift=clamp(n(e.target.value)/100,0,0.6); renderMetrics(); });\n\n $(\"vrt-aiAgentsOn\").addEventListener(\"change\", e=>{ S.ai.includeAiAgents=!!e.target.checked; renderMetrics(); });\n $(\"vrt-timeSavedPct\").addEventListener(\"input\", e=>{ S.ai.timeSavedPct=clamp(n(e.target.value)/100,0,0.8); renderMetrics(); });\n $(\"vrt-moneySavedPct\").addEventListener(\"input\", e=>{ S.ai.moneySavedPct=clamp(n(e.target.value)/100,0,0.5); renderMetrics(); });\n $(\"vrt-aiToolsCost\").addEventListener(\"input\", e=>{ S.ai.monthlyAiToolsCost=n(e.target.value); renderMetrics(); });\n $(\"vrt-agencyCost\").addEventListener(\"input\", e=>{ S.ai.monthlyAgencyOrContractorCost=n(e.target.value); renderMetrics(); });\n\n $(\"vrt-conservative\").addEventListener(\"change\", e=>{ S.scenario.conservativeMode=!!e.target.checked; renderMetrics(); });\n $(\"vrt-horizon\").addEventListener(\"input\", e=>{ S.scenario.horizonMonths=clamp(Math.round(n(e.target.value)),1,60); renderMetrics(); });\n $(\"vrt-ramp\").addEventListener(\"input\", e=>{ S.scenario.rampMonths=clamp(Math.round(n(e.target.value)),0,24); renderMetrics(); });\n\n $(\"vrt-addChannel\").addEventListener(\"click\", ()=>{\n S.channels.push({ id:\"custom-\"+Math.random().toString(16).slice(2), name:\"New Channel\", type:\"Traditional\", monthlySpend:0, hoursPerMonth:0 });\n render();\n });\n\n $(\"vrt-reset\").addEventListener(\"click\", ()=>{\n S = defaultState();\n syncAllInputsFromState();\n render();\n });\n }\n\n function bindChannelEvents(){\n // Delegate events from channels container\n $(\"vrt-channels\").addEventListener(\"input\", (e)=>{\n const el = e.target;\n const id = el.getAttribute(\"data-id\");\n const k = el.getAttribute(\"data-k\");\n if(!id || !k) return;\n const idx = S.channels.findIndex(c=>c.id===id);\n if(idx<0) return;\n if(k===\"name\") S.channels[idx].name = el.value;\n else S.channels[idx][k] = n(el.value);\n renderTotals();\n renderMetrics();\n });\n\n $(\"vrt-channels\").addEventListener(\"click\", (e)=>{\n const el = e.target;\n const rid = el.getAttribute(\"data-remove\");\n if(rid){\n S.channels = S.channels.filter(c=>c.id!==rid);\n render();\n return;\n }\n const type = el.getAttribute(\"data-type\");\n const id = el.getAttribute(\"data-id\");\n if(type && id){\n const c = S.channels.find(x=>x.id===id);\n if(c){ c.type = type; render(); }\n }\n });\n }\n\n function syncAllInputsFromState(){\n $(\"vrt-businessName\").value = S.biz.businessName;\n $(\"vrt-industry\").value = S.biz.industry;\n $(\"vrt-location\").value = S.biz.location;\n\n $(\"vrt-monthlyLeads\").value = S.baseline.monthlyLeads;\n $(\"vrt-convPct\").value = (S.baseline.leadToCustomerRate*100).toFixed(1);\n $(\"vrt-aov\").value = S.baseline.averageOrderValue;\n $(\"vrt-gmPct\").value = (S.baseline.grossMargin*100).toFixed(1);\n\n $(\"vrt-ownerHourly\").value = S.effort.ownerHourlyValue;\n $(\"vrt-teamHourly\").value = S.effort.teamHourlyCost;\n $(\"vrt-ownerHours\").value = S.effort.ownerHoursPerMonth;\n $(\"vrt-teamHours\").value = S.effort.teamHoursPerMonth;\n\n $(\"vrt-aiSeoOn\").checked = !!S.ai.includeAiSeo;\n $(\"vrt-leadLiftPct\").value = Math.round(S.ai.aiSeoLiftLeads*100);\n $(\"vrt-qualityLiftPct\").value = Math.round(S.ai.aiLeadQualityLift*100);\n\n $(\"vrt-aiAgentsOn\").checked = !!S.ai.includeAiAgents;\n $(\"vrt-timeSavedPct\").value = Math.round(S.ai.timeSavedPct*100);\n $(\"vrt-moneySavedPct\").value = Math.round(S.ai.moneySavedPct*100);\n $(\"vrt-aiToolsCost\").value = S.ai.monthlyAiToolsCost;\n $(\"vrt-agencyCost\").value = S.ai.monthlyAgencyOrContractorCost;\n\n $(\"vrt-conservative\").checked = !!S.scenario.conservativeMode;\n $(\"vrt-horizon\").value = S.scenario.horizonMonths;\n $(\"vrt-ramp\").value = S.scenario.rampMonths;\n }\n\n // --- Init ---\n syncAllInputsFromState();\n bindBasics();\n bindChannelEvents();\n render();\n\n })();\n \n
