// 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) => (
))}
);
}
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) => (
setMode(m.k)}
style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 700, padding: '5px 13px', borderRadius: 'var(--radius-sm)', border: 'none', cursor: 'pointer', background: mode === m.k ? 'var(--surface-card)' : 'transparent', color: mode === m.k ? 'var(--text-primary)' : 'var(--text-muted)', boxShadow: mode === m.k ? 'var(--shadow-card)' : 'none' }}>{m.l}
))}
);
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]) => (
setSegAttr(k)}
style={{ fontFamily: 'var(--font-sans)', fontSize: 12.5, fontWeight: 700, padding: '5px 13px', borderRadius: 'var(--radius-sm)', border: 'none', cursor: 'pointer', background: segAttr === k ? 'var(--surface-card)' : 'transparent', color: segAttr === k ? 'var(--text-primary)' : 'var(--text-muted)', boxShadow: segAttr === k ? 'var(--shadow-card)' : 'none' }}>{cfg.label}
))}
);
return (
{/* KPI row */}
{/* 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
);
})}
)}
{/* 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;
})();