// Analytics — sources, interests, activity trend, and pipeline funnel. // Wired to /analytics/sources, /analytics/interests, /analytics/activity, // /analytics/funnel (cohort, period-windowed), and /pipeline-counts (lifetime // snapshot used only for the hot/cold KPI tiles). function BarRow({ label, value, max, accent, total }) { const pct = max > 0 ? (value / max) * 100 : 0; return (
{label}
{value}{total ? · {((value/total)*100).toFixed(0)}% : ''}
); } function MiniArea({ buckets, width=800, height=220, accent='var(--ink)' }) { if (!buckets || buckets.length < 2) { return
no data yet
; } const max = Math.max(1, ...buckets.map(b => b.count || 0)); const step = width / (buckets.length - 1); const pts = buckets.map((b, i) => [i*step, height - ((b.count || 0) / max) * (height - 24) - 12]); const path = pts.map((p,i) => (i===0?'M':'L') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); const areaPath = path + ` L ${width} ${height-6} L 0 ${height-6} Z`; return ( {pts.map((p, i) => ( ))} {/* x-axis labels */} {buckets.map((b, i) => ( {b.label} ))} ); } function AnalyticsView() { const [days, setDays] = React.useState(30); const [period, setPeriod] = React.useState('wtd'); const sources = useAsync(() => api.get('/api/v2/analytics/sources?days=' + days), [days]); const interests = useAsync(() => api.get('/api/v2/analytics/interests?days=' + days), [days]); const activity = useAsync(() => api.get('/api/v2/analytics/activity?period=' + period), [period]); const funnelData = useAsync(() => api.get('/api/v2/analytics/funnel?days=' + days), [days]); const pipeline = useAsync(() => api.get('/api/v2/pipeline-counts'), []); const tommy = useAsync(() => api.get('/api/v2/tommy/stats'), []); const srcList = sources.data?.sources || []; const intList = interests.data?.interests || (interests.data?.items || []); const srcMax = Math.max(1, ...srcList.map(s => s.value)); const intMax = Math.max(1, ...intList.map(s => s.value)); const srcTot = srcList.reduce((s, x) => s + (x.value || 0), 0); const buckets = activity.data?.buckets || activity.data?.data || []; // Funnel: cohort of contacts created in the last `days` days. Each step // counts cohort members at-or-beyond that step (monotonically non-increasing). // "vs prev" compares the same cohort definition against the immediately // preceding window of equal length. const cur = funnelData.data?.current || { new_contacts: 0, booked: 0, showed: 0, joined: 0 }; const prev = funnelData.data?.previous || { new_contacts: 0, booked: 0, showed: 0, joined: 0 }; const membersTotal = funnelData.data?.members_total ?? 0; const funnel = [ { key: 'new_contacts', label: 'New contacts', value: cur.new_contacts, prev: prev.new_contacts }, { key: 'booked', label: 'Booked day pass', value: cur.booked, prev: prev.booked }, { key: 'showed', label: 'Showed', value: cur.showed, prev: prev.showed }, { key: 'joined', label: 'Joined', value: cur.joined, prev: prev.joined }, ]; const funnelMax = Math.max(1, ...funnel.map(f => f.value)); const cohortTotal = cur.new_contacts; return (
— Last {days} days

Analytics

Where contacts come from, what they ask for, who converts.
{/* Segmented control: do NOT use .btn.primary for the active state — that class is reserved for primary CTAs (e.g. "+ New lead") and the mobile rule forces full-width on it. Inline-style the active look like home.jsx:340-348 does. */} {[7, 30, 90].map(d => ( ))}
Activity · contact volume
{['today','yesterday','wtd','mtd'].map(p => ( ))}
Funnel · cohort, last {days}d
{cohortTotal} new · {membersTotal} members lifetime
{funnelData.error && (
Funnel unavailable.
)} {funnel.map((step, i) => { const pct = (step.value / funnelMax) * 100; // Conversion from previous step in current period (monotonic by construction). const conv = i > 0 && funnel[i-1].value > 0 ? (step.value / funnel[i-1].value) : null; // Real period-over-period delta vs the immediately preceding window. let deltaPct = null; if (step.prev > 0) deltaPct = ((step.value - step.prev) / step.prev) * 100; else if (step.value > 0) deltaPct = null; // no baseline — show "new" const deltaColor = deltaPct == null ? 'var(--muted-2)' : deltaPct >= 0 ? 'var(--signal)' : 'var(--muted)'; return (
{step.label} {step.value} {conv != null && {(conv*100).toFixed(0)}% of prev step} {deltaPct != null && ( {deltaPct >= 0 ? '+' : ''}{deltaPct.toFixed(0)}% vs prev {days}d )} {deltaPct == null && step.value > 0 && step.prev === 0 && ( new )}
); })}
Cohort = contacts created in the last {days}d. Each step counts members who reached at-or-beyond that step. Showed is heuristic (no event log for SHOWED transitions yet). Members lifetime is shown separately and not mixed into Joined.
Sources
{srcTot} new · {days}d
{srcList.length === 0 && !sources.loading &&
No source data in range.
} {srcList.map((s, i) => ( ))}
Interests
{intList.length === 0 && !interests.loading &&
No tag data in range.
} {intList.map((s, i) => ( ))}
Tommy · sent today
{tommy.data?.messages_sent_today ?? '—'}
lifetime {tommy.data?.messages_sent_total ?? 0}
Active convos · 7d
{tommy.data?.convos_active ?? '—'}
distinct contacts
Hot leads · now
{pipeline.data?.hot_leads ?? '—'}
engaged + fresh
Cold leads · 30d+
{pipeline.data?.cold_leads ?? '—'}
never contacted
); } Object.assign(window, { AnalyticsView });