// Florio Analytics — Onboarding tab (real data via window.FlorioAPI). // Mirrors the data logic of js/pages/onboarding.js (funnel steps, paywall A/B, // avg onboarding time, avg plant-scan time, notification permissions, // completions trend) but renders it in the new design-kit visual language. (function () { const NS = window.HyperChartsDesignSystem_e51a95; const { LineChart, Badge, TrendChip } = NS; const I = window.HyperIcons; const { useState, useEffect } = React; const rangeToDays = (r) => ({ Day: 1, Week: 7, Month: 30, Year: 365 }[r] || 30); // ── Onboarding step maps (copied 1:1 from js/pages/onboarding.js) ────────────── // The flow was rewritten on 2026-06-15; step numbers are positions in // SCREEN_FLOW so their meaning changed. Use the new map when the range includes // any on/after-cutover date, else the old map. const NEW_FLOW_CUTOVER = '2026-06-15'; const OLD_STEPS = [ { step: 1, label: 'Intro 1' }, { step: 2, label: 'Intro 2' }, { step: 3, label: 'Intro 3' }, { step: 4, label: 'Name' }, { step: 5, label: 'Age' }, { step: 6, label: 'Plants killed' }, { step: 7, label: 'Spending' }, { step: 8, label: 'Money wasted' }, { step: 9, label: 'Used car' }, { step: 10, label: 'Good news' }, { step: 11, label: 'Social proof' }, { step: 12, label: 'Honesty' }, { step: 13, label: 'Relationship' }, { step: 14, label: 'Barriers' }, { step: 15, label: 'Deeper patterns' }, { step: 16, label: 'Goals' }, { step: 17, label: 'Personal meaning' }, { step: 18, label: 'Reflection' }, { step: 19, label: 'Location' }, { step: 20, label: 'Care time' }, { step: 21, label: 'Profile loading' }, { step: 22, label: 'Journey' }, { step: 23, label: 'Plant scan' }, { step: 24, label: 'Plant watered' }, { step: 25, label: 'Plant name' }, { step: 26, label: 'Plant character' }, { step: 27, label: 'Commitment' }, { step: 28, label: 'Social proof final' }, { step: 29, label: 'Paywall' }, { step: 30, label: 'Auth' }, ]; const NEW_STEPS = [ { step: 1, label: 'Intro 1' }, { step: 2, label: 'Intro 2' }, { step: 3, label: 'Name' }, { step: 4, label: 'Age' }, { step: 5, label: 'Plant count' }, { step: 6, label: 'Biggest struggle' }, { step: 7, label: 'Forget frequency' }, { step: 8, label: 'Plants doing' }, { step: 9, label: 'Goals' }, { step: 10, label: 'Reflection' }, { step: 11, label: 'Social proof' }, { step: 12, label: 'Location' }, { step: 13, label: 'Care time' }, { step: 14, label: 'Profile loading' }, { step: 15, label: 'Plant scan' }, // Sub-steps inside the single plant-scan screen (string keys written by // onboardingAnalytics.markStep): chose a method → loader shown → avatar revealed. { step: 'plant_calculating', label: 'Calculating' }, { step: 'plant_avatar', label: 'Plant ready' }, { step: 16, label: 'Name plant' }, { step: 17, label: 'Avatar reveal' }, { step: 18, label: 'First watering' }, { step: 19, label: 'Widget' }, { step: 20, label: 'Commitment' }, { step: 21, label: 'Trial reminder' }, { step: 22, label: 'Paywall' }, { step: 23, label: 'Auth' }, ]; function getStepsForDates(dates) { const hasNew = (dates || []).some((d) => d >= NEW_FLOW_CUTOVER); return hasNew ? NEW_STEPS : OLD_STEPS; } // Per-step counts — identical logic to getStepCounts() in js/pages/onboarding.js. function getStepCounts(data, steps, API) { const starts = API.sumDailyField(data, 'onboardingStarted'); const completions = API.sumDailyField(data, 'onboardingCompleted'); const stepsMap = API.mergeDailyMaps(data, 'onboardingSteps'); const counts = steps.map((s) => { if (s.step === 1) return starts; if (s.label === 'Paywall') return completions; // Paywall = onboarding complete return stepsMap['step_' + s.step] || stepsMap[s.step] || 0; }); return { starts, completions, counts }; } // ── Avg onboarding time (start→complete). New dates: per-user onbDurations. // Old dates: reconstruct from session start/complete event pairs. ─────────── async function computeAvgOnboardingTime(dates, data, API) { const durations = []; const { oldDates, newDates } = API.splitOnboardingDates(dates); for (const d of newDates) { const arr = data[d] && data[d].onbDurations; if (Array.isArray(arr)) durations.push(...arr); } if (oldDates.length) { try { const events = await API.fetchRawEvents(oldDates[0], oldDates[oldDates.length - 1]); const sessions = {}; for (const e of events) { if (e.event === 'onboarding_start' || e.event === 'onboarding_complete') { if (!sessions[e.sessionId]) sessions[e.sessionId] = {}; const ts = e.timestamp && e.timestamp.toDate ? e.timestamp.toDate() : (e.timestamp && e.timestamp.seconds ? new Date(e.timestamp.seconds * 1000) : null); if (ts) sessions[e.sessionId][e.event] = ts; } } for (const s of Object.values(sessions)) { if (s['onboarding_start'] && s['onboarding_complete']) { const dur = Math.floor((s['onboarding_complete'] - s['onboarding_start']) / 1000); if (dur > 0 && dur < 3600) durations.push(dur); } } } catch (e) { console.error('[Onboarding] avg-time old events failed:', e); } } if (durations.length === 0) return null; const avg = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length); return { sec: avg, label: `${Math.floor(avg / 60)}m ${avg % 60}s` }; } // ── Avg plant scan time (ms per user, scan → avatar ready) ───────────────────── function computeAvgScanTime(data) { const times = []; for (const d of Object.keys(data)) { const arr = data[d] && data[d].plantScanTimes; if (Array.isArray(arr)) times.push(...arr); } if (times.length === 0) return null; const avgMs = times.reduce((a, b) => a + b, 0) / times.length; return { n: times.length, label: avgMs >= 1000 ? `${(avgMs / 1000).toFixed(1)}s` : `${Math.round(avgMs)}ms`, }; } // ── Paywall A/B (hard vs soft): sum per-date buckets into one totals object. ──── function sumPaywall(data) { const out = { hard: { viewed: 0, trial: 0, free: 0 }, soft: { viewed: 0, trial: 0, free: 0 } }; for (const date of Object.keys(data)) { const pw = data[date] && data[date].paywall; if (!pw) continue; for (const v of ['hard', 'soft']) { if (!pw[v]) continue; out[v].viewed += pw[v].viewed || 0; out[v].trial += pw[v].trial || 0; out[v].free += pw[v].free || 0; } } return out; } // ── Cohort segmentation by survey answer (Age / Plant count) ────────────────── // Attributes you can break the key metrics down by. `order` controls row order; // any answer value not listed still shows (appended, alphabetical). const SEGMENT_ATTRS = { ageRange: { label: 'Age', order: ['Under 18', '18-24', '25-34', '35-44', '45-54', '55+'], }, plantsKilled: { label: 'Plant count', order: ['1 plant', '2–3 plants', '4–6 plants', '7–10 plants', "10+ plants (it's a jungle)"], }, }; // Group per-user rows by their answer to `attrKey`, tallying the key metrics. function computeSegments(users, attrKey, order) { const groups = {}; for (const u of users || []) { const raw = u.answers && u.answers[attrKey]; if (raw === undefined || raw === null || raw === '') continue; const key = String(raw); if (!groups[key]) groups[key] = { value: key, starts: 0, completed: 0, trial: 0, auth: 0 }; const g = groups[key]; g.starts += 1; // every doc with this answer is a user who started onboarding if (u.completed) g.completed += 1; if (u.trial) g.trial += 1; if (u.auth) g.auth += 1; } const ordered = (order || []).filter((k) => groups[k]); const extra = Object.keys(groups).filter((k) => !(order || []).includes(k)).sort(); return [...ordered, ...extra].map((k) => groups[k]); } // One metric cell: big rate %, a subtle proportional fill bar, raw count beneath. function RateCell({ rate, count, accent }) { return (
{rate.toFixed(1)}%
{API_FMT(count)}
); } const shortDate = (s) => { const d = new Date(s + 'T00:00:00'); return `${d.getDate()}/${d.getMonth() + 1}`; }; const safePct = (n, d) => (d > 0 ? (n / d) * 100 : 0); function API_FMT(n) { return (window.FlorioAPI && window.FlorioAPI.formatNumber) ? window.FlorioAPI.formatNumber(n) : String(n ?? 0); } // ── Paywall A/B variant panel (kit visual language) ──────────────────────────── function PaywallVariant({ name, t, leading }) { const trialRate = safePct(t.trial, t.viewed); const freeRate = safePct(t.free, t.viewed); return (
{name} {leading && Leading}
{trialRate.toFixed(1)}% trial conversion
{[ { v: API_FMT(t.viewed), l: 'viewers' }, { v: API_FMT(t.trial), l: 'trials started' }, { v: freeRate.toFixed(1) + '%', l: 'chose free' }, ].map((s, i) => (
{s.v}
{s.l}
))}
); } function Onboarding({ range }) { const P = window.PA; const [d, setD] = useState(null); const [mode, setMode] = useState('number'); // funnel pills default to numbers ('number' | 'percent') const [segAttr, setSegAttr] = useState('ageRange'); // attribute for the by-attribute breakdown useEffect(() => { let alive = true; setD(null); (async () => { await window.FlorioReady; const API = window.FlorioAPI; const dates = (range && range.start && range.end) ? API.getDateRangeBetween(range.start, range.end) : API.getDateRange(rangeToDays(range && range.key ? range.key : range)); // Merge old aggregates (< cutover) with the new per-user onboarding rollups // (≥ cutover). Same fetcher + logic as js/pages/onboarding.js. const data = await API.fetchMergedOnboarding(dates).catch((e) => { console.error('[Onboarding] fetchMergedOnboarding', e); return {}; }); if (!alive) return; // Per-user rows (new-flow docs) for the by-attribute key-metric breakdown. const users = await API.fetchOnboardingUsers(dates).catch((e) => { console.error('[Onboarding] fetchOnboardingUsers', e); return []; }); if (!alive) return; const steps = getStepsForDates(dates); const { starts, completions, counts } = getStepCounts(data, steps, API); const notifAllowed = API.sumDailyField(data, 'notificationsAllowed'); const notifDenied = API.sumDailyField(data, 'notificationsDenied'); // newUsers / newPlants live only on new-flow dates; sum is safe (0 on old). const accountsCreated = API.sumDailyField(data, 'newUsers'); const plantsAdded = API.sumDailyField(data, 'newPlants'); const paywall = sumPaywall(data); const trialsStarted = paywall.hard.trial + paywall.soft.trial; const paywallViews = paywall.hard.viewed + paywall.soft.viewed; const freeChosen = paywall.hard.free + paywall.soft.free; const avgTime = await computeAvgOnboardingTime(dates, data, API); if (!alive) return; const avgScan = computeAvgScanTime(data); // Completions-per-day trend series. const trend = dates.map((x) => (data[x] && data[x].onboardingCompleted) || 0); // Biggest step-to-step losses (for the DropList). const drops = []; for (let i = 0; i < steps.length - 1; i++) { const lost = Math.max(counts[i] - counts[i + 1], 0); if (lost > 0) drops.push({ label: steps[i + 1].label, value: lost }); } drops.sort((a, b) => b.value - a.value); setD({ dates, labels: dates.map(shortDate), steps, counts, starts, completions, notifAllowed, notifDenied, accountsCreated, plantsAdded, paywall, trialsStarted, paywallViews, freeChosen, avgTime, avgScan, trend, drops: drops.slice(0, 6), users, }); })(); return () => { alive = false; }; }, [range]); if (!d) { return
; } const fmt = API_FMT; const rk = (range && range.key === 'custom') ? (range.start + '_' + range.end) : String((range && range.key) || range); // stable redraw key per range const completionRate = safePct(d.completions, d.starts); const notifTotal = d.notifAllowed + d.notifDenied; const notifAllowedPct = notifTotal > 0 ? Math.round((d.notifAllowed / notifTotal) * 100) : 0; // Paywall leader (only when both variants have viewers and rates differ). const hardRate = safePct(d.paywall.hard.trial, d.paywall.hard.viewed); const softRate = safePct(d.paywall.soft.trial, d.paywall.soft.viewed); let leader = null; if (d.paywall.hard.viewed > 0 && d.paywall.soft.viewed > 0 && hardRate !== softRate) { leader = hardRate > softRate ? 'hard' : 'soft'; } // KPI tiles (StatTiles). const kpis = [ { icon: I.sprout({ size: 18 }), label: 'Onboarding starts', value: fmt(d.starts), sub: 'In the selected range', accent: 'var(--app-accent)' }, { icon: I.flame({ size: 18 }), label: 'Completions', value: fmt(d.completions), sub: `${completionRate.toFixed(1)}% completion rate`, accent: '#21A65A' }, { icon: I.users({ size: 18 }), label: 'Accounts created', value: fmt(d.accountsCreated), sub: 'sign_up during onboarding', accent: '#B771F3' }, { icon: I.rocket({ size: 18 }), label: 'Trials started', value: fmt(d.trialsStarted), sub: `${safePct(d.trialsStarted, d.paywallViews).toFixed(1)}% of paywall views`, accent: '#5B8DEF' }, { icon: I.clock({ size: 18 }), label: 'Avg. onboarding time', value: d.avgTime ? d.avgTime.label : '--', sub: d.avgTime ? 'start → complete' : 'No completions yet', accent: '#2BB0A6' }, ]; // Conversion-rate grid (key ratios). const ratios = [ { label: 'Start → Complete', v: completionRate }, { label: 'Paywall → Trial', v: safePct(d.trialsStarted, d.paywallViews) }, { label: 'Start → Account', v: safePct(d.accountsCreated, d.starts) }, { label: 'Start → Trial', v: safePct(d.trialsStarted, d.starts) }, { label: 'Notif. allowed', v: notifTotal > 0 ? notifAllowedPct : 0 }, { label: 'Paywall → Free', v: safePct(d.freeChosen, d.paywallViews) }, ]; // Funnel steps in the kit widget's shape ({ name, value }). const funnelSteps = d.steps.map((s, i) => ({ name: s.label, value: d.counts[i] })); const funnelEmpty = d.counts.every((c) => c === 0); const modeToggle = (
{[{ k: 'number', l: '#' }, { k: 'percent', l: '%' }].map((m) => ( ))}
); const trendTotal = d.trend.reduce((a, b) => a + b, 0); // By-attribute breakdown (Age / Plant count) — recomputed on toggle, no refetch. const segCfg = SEGMENT_ATTRS[segAttr]; const segments = computeSegments(d.users, segAttr, segCfg.order); const segTotal = segments.reduce((a, s) => a + s.starts, 0); const segToggle = (
{Object.entries(SEGMENT_ATTRS).map(([k, cfg]) => ( ))}
); return (
{/* KPI row */}
{kpis.map((kp, i) => )}
{/* Key metrics by attribute (Age / Plant count) */} {segments.length === 0 ? (
No {segCfg.label.toLowerCase()} answers in this range
) : (
{/* header row */}
{[segCfg.label, 'Started', 'Completed', 'Trial', 'Account'].map((h, i) => (
{h}
))}
{/* one row per answer value */}
{segments.map((s) => { const compRate = safePct(s.completed, s.starts); const trialRate = safePct(s.trial, s.starts); const authRate = safePct(s.auth, s.starts); const share = safePct(s.starts, segTotal); return (
{s.value}
{share.toFixed(0)}% of cohort
{fmt(s.starts)}
users
); })}
)}
{/* Big step-by-step funnel — kit Funnel widget */} {completionRate.toFixed(1)}% end-to-end{modeToggle}
} > {funnelEmpty ? (
No onboarding data yet
) : ( )} {/* Paywall A/B test (hard vs soft) */} 0 ? {fmt(d.paywallViews)} saw a paywall : null} > {d.paywallViews === 0 ? (
No paywall data yet
) : (
)}
{/* Start → Trial → Paid sub-funnel (real data) */} {d.starts === 0 ? (
No data in range
) : ( )}
{/* Conversion rates grid */}
{ratios.map((c, i) => (
{c.label}
{c.v.toFixed(1)}%
))}
{/* Notification permission + avg scan time */} {notifTotal === 0 ? (
No prompt data yet
) : ( )}
{I.sparkle({ size: 26 })} {d.avgScan ? d.avgScan.label : '--'} {d.avgScan ? `Across ${fmt(d.avgScan.n)} scans` : 'No plant scans yet'}
{/* Where users drop */} {d.drops.length === 0 ? (
No drop-off yet
) : ( )}
{/* Completions per day */}
{fmt(trendTotal)}
total
} > {trendTotal === 0 ? (
No completions in range
) : ( )}
); } // Register under the Florio-owned namespace. NOTE: _ds_bundle.js itself defines // a MOCK window.PAOnboarding — we must NOT key off it or the tab would render // the bundle's fake data. (window.FlorioTabs = window.FlorioTabs || {}).onboarding = Onboarding; })();