VibesOS · Funnel Analytics

Showing last 14 days
/join Visits
No data
Subscribes
No data
Notify-Me
No data
Sequence Completed
No data
Daily volume per funnel stage
/join Visits
Subscribes
Notify-Me
Email Opens
Email Clicks
Exit-Intent
Load data to see chart
A/B variant breakdown
Stage A (Join Free) Rate B (Start My Free Plan) Rate Δ B−A
/join Visit00
Email Capture00
Notify-Me00
Email 1 Open00
Email 1 Click00
Exit-Intent00
No A/B traffic yet — capture from /join to see it here.
Source breakdown
Source Subscribes Email 1 Opened Email 1 Clicked
No tagged traffic yet — share an outreach link with ?utm_source=... to see it here.
function toDayKey(d) { if (typeof d === 'string') return d; return d.toISOString().slice(0, 10); } function buildDayIndex(byEventType, byDay) { const daySet = new Set(); const perEventType = {}; for (const r of byEventType) { const key = toDayKey(r.day); daySet.add(key); if (!perEventType[r.event_type]) perEventType[r.event_type] = {}; perEventType[r.event_type][key] = r.count; } const perMetric = {}; for (const r of byDay) { const dayKey = toDayKey(r.day); daySet.add(dayKey); perMetric[dayKey] = r; } const dates = Array.from(daySet).sort(); return { dates, perEventType, perMetric }; } function stageValue(stage, day, ctx) { if (stage.metric && ctx.perMetric[day]) return ctx.perMetric[day][stage.metric] || 0; if (stage.eventTypes) { let total = 0; for (const t of stage.eventTypes) { if (ctx.perEventType[t] && ctx.perEventType[t][day]) total += ctx.perEventType[t][day]; } return total; } return 0; } function renderChart(data) { const container = document.getElementById('chart-area'); const ctx = buildDayIndex(data.byEventType || [], (data.byDay || []).map(r => ({ ...r, day: typeof r.day === 'string' ? r.day : new Date(r.day).toISOString().slice(0, 10) }))); const dates = ctx.dates; if (dates.length === 0) { container.innerHTML = '
No funnel data yet
'; return; } const w = container.offsetWidth || 800; const rowH = 36; const padL = 140, padR = 16, padT = 20, padB = 24; const chartW = w - padL - padR; const h = padT + STAGES.length * rowH + padB; let svg = ``; // X axis baseline svg += ``; svg += ``; // X axis date labels (sparse) const labelStep = Math.max(1, Math.ceil(dates.length / 8)); dates.forEach((d, i) => { if (i % labelStep === 0) { const x = padL + (i + 0.5) * (chartW / dates.length); svg += `${d.slice(5)}`; } }); STAGES.forEach((stage, rowIdx) => { const y = padT + rowIdx * rowH; // Stage label svg += `${stage.label}`; // Compute per-row max for normalization let rowMax = 0; const values = dates.map(day => stageValue(stage, day, ctx)); for (const v of values) if (v > rowMax) rowMax = v; // Bar per day const barW = chartW / dates.length; values.forEach((v, i) => { if (v <= 0) return; const ratio = rowMax > 0 ? v / rowMax : 0; const barH = ratio * (rowH - 8); const x = padL + i * barW + 1; const by = y + (rowH - 8 - barH); svg += ``; }); // Row max tick on right if (rowMax > 0) { svg += `${formatNum(rowMax)}`; } }); svg += ''; container.innerHTML = svg; } function fmtPct(x) { if (x === null) return '—'; if (!isFinite(x)) return '—'; return (x * 100).toFixed(1) + '%'; } function fmtDelta(x) { if (x === null || !isFinite(x)) return '—'; if (x === 0) return '0.0 pp'; const sign = x > 0 ? '+' : ''; return sign + (x * 100).toFixed(1) + ' pp'; } function renderVariantTable(data) { const rows = (data.byVariant || []).reduce((acc, row) => { if (!acc[row.stage]) acc[row.stage] = {}; acc[row.stage][row.variant] = row.count; return acc; }, {}); // Map dashboard stage → {num stage, denom stage} for rate calculation. const stageRate = { capture: { num: 'capture', denom: 'visit' }, notify_me_capture: { num: 'notify_me_capture', denom: 'visit' }, email_open: { num: 'email_open', denom: 'capture' }, email_click: { num: 'email_click', denom: 'email_open' }, exit_intent: { num: 'exit_intent', denom: 'visit' }, }; function rateFor(stage, variant) { const def = stageRate[stage]; if (!def) return null; const num = (rows[def.num] && rows[def.num][variant]) || 0; const den = (rows[def.denom] && rows[def.denom][variant]) || 0; if (!den) return null; return num / den; } let totalCount = 0; const stageIds = ['visit', 'capture', 'notify_me_capture', 'email_open', 'email_click', 'exit_intent']; stageIds.forEach(stage => { const cell = rows[stage] || {}; const a = cell.A || 0; const b = cell.B || 0; totalCount += a + b; const rateA = rateFor(stage, 'A'); const rateB = rateFor(stage, 'B'); const tr = document.querySelector(`tr[data-stage="${stage}"]`); if (!tr) return; tr.querySelector('[data-a]').textContent = a.toLocaleString(); tr.querySelector('[data-b]').textContent = b.toLocaleString(); tr.querySelector('[data-ar]').textContent = stage === 'visit' ? '—' : fmtPct(rateA); tr.querySelector('[data-br]').textContent = stage === 'visit' ? '—' : fmtPct(rateB); const deltaCell = tr.querySelector('[data-d]'); let delta = null; if (stage !== 'visit' && rateA !== null && rateB !== null) { delta = rateB - rateA; } deltaCell.textContent = fmtDelta(delta); deltaCell.classList.remove('delta-pos', 'delta-neg', 'delta-zero'); if (delta === null || !isFinite(delta) || delta === 0) deltaCell.classList.add('delta-zero'); else if (delta > 0) deltaCell.classList.add('delta-pos'); else deltaCell.classList.add('delta-neg'); }); const empty = document.getElementById('variant-empty-row'); if (empty) empty.style.display = totalCount === 0 ? '' : 'none'; } function renderSourceTable(data) { const tbody = document.getElementById('source-table-body'); const empty = document.getElementById('source-empty-row'); if (!tbody) return; const rows = (data.bySource || []).slice().sort((a, b) => b.subscribes - a.subscribes); if (rows.length === 0) { tbody.innerHTML = ''; if (empty) empty.style.display = ''; return; } if (empty) empty.style.display = 'none'; tbody.innerHTML = rows.map((r) => { const label = String(r.source || 'direct'); return ( '' + `${escapeHtml(label)}` + `${(r.subscribes || 0).toLocaleString()}` + `${(r.email1_opened || 0).toLocaleString()}` + `${(r.email1_clicked || 0).toLocaleString()}` + '' ); }).join(''); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } loadFunnel(); setInterval(loadFunnel, 60000);