// Florio Analytics β€” Overview tab (real data via window.FlorioAPI). (function () { const NS = window.HyperChartsDesignSystem_e51a95; const { LineChart, ProgressRing, Badge, TrendChip } = NS; const I = window.HyperIcons; const { useState, useEffect } = React; const rangeToDays = (r) => ({ Day: 1, Week: 7, Month: 30, Year: 365 }[r] || 30); const HOURS = ['00', '02', '04', '06', '08', '10', '12', '14', '16', '18', '20', '22']; const COUNTRY_NAMES = { US: 'United States', GB: 'United Kingdom', NL: 'Netherlands', DE: 'Germany', FR: 'France', ES: 'Spain', IT: 'Italy', BR: 'Brazil', CA: 'Canada', AU: 'Australia', IN: 'India', MX: 'Mexico', JP: 'Japan', KR: 'South Korea', CN: 'China', RU: 'Russia', ZA: 'South Africa', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland', PT: 'Portugal', BE: 'Belgium', AT: 'Austria', CH: 'Switzerland', IE: 'Ireland', NZ: 'New Zealand', AR: 'Argentina', CL: 'Chile', CO: 'Colombia', PE: 'Peru', TR: 'Turkey', SA: 'Saudi Arabia', AE: 'UAE', EG: 'Egypt', NG: 'Nigeria', KE: 'Kenya', TH: 'Thailand', PH: 'Philippines', ID: 'Indonesia', MY: 'Malaysia', SG: 'Singapore', VN: 'Vietnam', TW: 'Taiwan', HK: 'Hong Kong', IL: 'Israel', GR: 'Greece', CZ: 'Czech Republic', RO: 'Romania', HU: 'Hungary', UA: 'Ukraine', SK: 'Slovakia', BG: 'Bulgaria', }; const flagEmoji = (code) => { if (!code || code.length !== 2) return '🏳️'; return code.toUpperCase().replace(/./g, (c) => String.fromCodePoint(127397 + c.charCodeAt(0))); }; // ── range-independent fetch cache (survives range changes; no failure cached) ── const _cache = {}; async function cached(key, fn, fallback) { if (key in _cache) return _cache[key]; try { const v = await fn(); _cache[key] = v; return v; } catch (e) { console.error('[Overview] ' + key + ' failed:', e); return fallback; } } const shortDate = (s) => { const d = new Date(s + 'T00:00:00'); return `${d.getDate()}/${d.getMonth() + 1}`; }; function buildHeat(dates, data, field) { const grid = Array.from({ length: 7 }, () => Array(12).fill(0)); for (const date of dates) { const dow = new Date(date + 'T00:00:00').getDay(); // 0=Sun..6=Sat const row = (dow + 6) % 7; // Mon=0 .. Sun=6 const hourMap = (data[date] && data[date][field]) || {}; HOURS.forEach((h, ci) => { grid[row][ci] += (hourMap[h] || 0); }); } return grid; } // Day-of-week + hour for a Date in a given IANA timezone (falls back to viewer tz). function dowHourInTz(date, tz) { try { const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz || undefined, weekday: 'short', hour: '2-digit', hour12: false }).formatToParts(date); const wd = (parts.find((p) => p.type === 'weekday') || {}).value; const hh = parseInt((parts.find((p) => p.type === 'hour') || {}).value, 10); const map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; const dow = map[wd]; if (dow == null || !Number.isFinite(hh)) throw new Error('bad'); return { dow, hour: hh % 24 }; } catch (e) { return { dow: date.getDay(), hour: date.getHours() }; } } // Onboarding-completions heatmap (dow Γ— 2h) built from each user's REAL // completion timestamp (analytics_onboarding.completeAt), in the user's tz. function buildHeatFromTimes(times) { const grid = Array.from({ length: 7 }, () => Array(12).fill(0)); for (const t of (times || [])) { if (!t || !t.at) continue; const { dow, hour } = dowHourInTz(t.at, t.tz); const row = (dow + 6) % 7; // Mon=0..Sun=6 const col = Math.floor((hour % 24) / 2); if (grid[row] && grid[row][col] != null) grid[row][col] += 1; } return grid; } function countNewUsersByDate(users, dates) { const counts = {}; for (const d of dates) counts[d] = 0; for (const u of users) { const ts = u.createdAt; if (!ts) continue; const d = ts.toDate ? ts.toDate() : new Date(ts); const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (key in counts) counts[key]++; } return dates.map((d) => counts[d]); } const lastDelta = (series) => { const a = series.filter((v) => typeof v === 'number'); if (a.length < 2 || !a[a.length - 2]) return null; return Number(((a[a.length - 1] - a[a.length - 2]) / a[a.length - 2] * 100).toFixed(1)); }; // ── Cohort table (preserves the existing D1/D7/D14/D30 milestone data) ── function CohortTable({ cohort }) { const [busy, setBusy] = useState(false); const [msg, setMsg] = useState('Backfill'); const milestones = [{ key: 'd1', label: 'D1', days: 1 }, { key: 'd7', label: 'D7', days: 7 }, { key: 'd14', label: 'D14', days: 14 }, { key: 'd30', label: 'D30', days: 30 }]; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const label = (s) => { const d = new Date(s + 'T00:00:00Z'); return `${months[d.getUTCMonth()]} ${d.getUTCDate()}`; }; const age = (s) => Math.floor((Date.now() - new Date(s + 'T00:00:00Z').getTime()) / 86400000); const backfill = async () => { if (busy || !confirm('Recompute all cohort retention data with daily grouping? This overwrites existing data.')) return; setBusy(true); setMsg('Running…'); try { const token = await window.FlorioAuth.auth.currentUser.getIdToken(); const res = await fetch('https://us-central1-florio-a6f48.cloudfunctions.net/backfillCohortRetention', { headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); setMsg(`Done β€” ${data.cohortDays} cohorts, ${data.dailyDocsUpdated} docs`); } catch (e) { setMsg('Failed'); alert('Backfill failed: ' + e.message); } finally { setBusy(false); } }; const action = ( ); const days = cohort ? Object.keys(cohort).sort().slice(-30) : []; return ( {!cohort || days.length === 0 ? (
No cohort data yet. Data accumulates after the next daily aggregate runs.
) : (
{milestones.map((m) => )} {days.map((day) => { const c = cohort[day]; const a = age(day); return ( {milestones.map((m) => { if (a < m.days) return ; const ratio = c.size > 0 ? (c[m.key] || 0) / c.size : 0; const pct = Math.round(ratio * 100); const alpha = 0.12 + ratio * 0.88; return ; })} ); })}
Cohort Users{m.label}
{label(day)} {(c.size || 0).toLocaleString()}β€” 0.55 ? '#fff' : 'var(--text-secondary)', background: `color-mix(in srgb, var(--app-accent) ${Math.round(alpha * 100)}%, #fff)`, borderRadius: 6, padding: '8px 4px' }}>{pct}%
)}
); } // Clearly-marked placeholder for data that arrives with the App Store Connect API. function PendingCard({ title, eyebrow, span, note }) { return ( Pending API}>
{I.dollar({ size: 26 })} {note || 'Connects once the App Store Connect API is wired up.'}
); } function Overview({ range }) { const P = window.PA; const [d, setD] = useState(null); 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)); const days = dates.length; const [agg, onbTimes, users, recent, health, count] = await Promise.all([ API.fetchDailyAggregates(dates).catch((e) => { console.error('[Overview] aggregates', e); return {}; }), API.fetchOnboardingCompletionTimes(dates).catch((e) => { console.error('[Overview] onboarding times', e); return []; }), cached('users', () => API.fetchUsers(10000), []), cached('recent', () => API.fetchRecentUsers(30), []), cached('health', () => API.fetchPlantHealthSnapshot(), { happy: 0, normal: 0, recovering: 0, struggling: 0 }), cached('count', () => API.fetchUserCount(), 0), ]); const today = API.getTodayStr(); const todayAgg = await cached('today', async () => (await API.fetchDailyAggregates([today]))[today] || {}, {}); const cohortAgg = await cached('cohort', async () => { const x = await API.fetchDailyAggregates([API.daysAgo(1), API.daysAgo(2)]); return x[API.daysAgo(1)] || x[API.daysAgo(2)] || null; }, null); if (!alive) return; // KPIs const totalDur = API.sumDailyField(agg, 'totalSessionDuration'); const totalSess = API.sumDailyField(agg, 'sessions'); let avg = totalSess > 0 ? Math.round(totalDur / totalSess) : 0; if (avg === 0) { try { avg = await API.computeAvgSessionDuration(dates[0], dates[dates.length - 1]); } catch {} } if (!alive) return; const wateringsTotal = API.sumDailyField(agg, 'waterings'); // time series const dau = dates.map((x) => agg[x]?.uniqueActiveUsers ?? 0); const sessions = dates.map((x) => agg[x]?.sessions ?? 0); const waterings = dates.map((x) => agg[x]?.waterings ?? 0); const newUsers = countNewUsersByDate(users, dates); // OS let android = 0, ios = 0; for (const u of recent) { if (u.os === 'ios') ios++; else if (u.os === 'android') android++; } // add-plant method const addScan = API.sumDailyField(agg, 'plantsAddedByScan'); const addSearch = API.sumDailyField(agg, 'plantsAddedBySearch'); const obScan = API.sumDailyField(agg, 'plantsAddedByOnboardingScan'); const obSearch = API.sumDailyField(agg, 'plantsAddedByOnboardingSearch'); const obLegacy = API.sumDailyField(agg, 'plantsAddedByOnboarding'); const obSearchAdj = obSearch + (obScan === 0 && obSearch === 0 ? obLegacy : 0); // page traffic (today) const PAGE_MAP = { home: 'Home', index: 'Home', '(tabs)': 'Home', '(tabs)/index': 'Home', friends: 'Friends', '(tabs)/friends': 'Friends', plants: 'Garden', '(tabs)/plants': 'Garden', 'garden-v2': 'Garden', '(tabs)/garden-v2': 'Garden', settings: 'Settings', '(tabs)/settings': 'Settings', feed: 'Feed', '(tabs)/feed': 'Feed', 'plant/[id]': 'Plant Detail', 'add-plant': 'Add Plant', 'water-confirm': 'Water Confirm', marketplace: 'Marketplace', '(auth)/onboarding': 'Onboarding' }; const sv = todayAgg.screenViews || {}; const trafficAgg = {}; for (const [key, c] of Object.entries(sv)) { const name = PAGE_MAP[key] || PAGE_MAP[key.replace(/^\//, '')] || key; trafficAgg[name] = (trafficAgg[name] || 0) + c; } for (const n of ['Home', 'Garden', 'Friends', 'Feed', 'Settings']) if (!(n in trafficAgg)) trafficAgg[n] = 0; const traffic = Object.entries(trafficAgg).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([label, value]) => ({ label, value })); // country const cc = {}; for (const u of users) { const code = u.country; if (code && typeof code === 'string' && code.length === 2) cc[code.toUpperCase()] = (cc[code.toUpperCase()] || 0) + 1; } const ccTotal = Object.values(cc).reduce((a, b) => a + b, 0); const top = Object.entries(cc).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([code, n]) => ({ name: COUNTRY_NAMES[code] || code, flag: flagEmoji(code), pct: ccTotal ? Number((n / ccTotal * 100).toFixed(1)) : 0 })); // retention let retained = 0, totalSnap = 0; for (let i = dates.length - 1; i >= 0; i--) { const x = agg[dates[i]]; if (x && x.totalUsersSnapshot) { totalSnap = x.totalUsersSnapshot; retained = days <= 7 ? (x.retentionWau || x.retentionDau || 0) : days <= 14 ? (x.retentionWau || 0) : (x.retentionMau || 0); break; } } const retRate = totalSnap > 0 ? Number((retained / totalSnap * 100).toFixed(1)) : 0; setD({ dates, labels: dates.map(shortDate), totalUsers: count, avgSession: `${Math.floor(avg / 60)}m ${avg % 60}s`, wateringsTotal, dau, sessions, waterings, newUsers, os: { android, ios }, addScan, addSearch, obScan, obSearch: obSearchAdj, traffic, country: { values: cc, top, total: ccTotal }, health, retRate, retained, totalSnap, heatSessions: buildHeat(dates, agg, 'sessionsByHour'), heatOnboarding: buildHeatFromTimes(onbTimes), cohort: cohortAgg ? cohortAgg.cohortRetention : null, }); })(); return () => { alive = false; }; }, [range]); if (!d) { return
; } const fmt = API_FMT; const osTotal = d.os.android + d.os.ios; const healthTotal = (d.health.happy || 0) + (d.health.normal || 0) + (d.health.recovering || 0) + (d.health.struggling || 0); const hp = (v) => healthTotal ? Math.round(v / healthTotal * 100) : 0; const op = (v) => osTotal ? Math.round(v / osTotal * 100) : 0; const rk = (range && range.key === 'custom') ? (range.start + '_' + range.end) : String((range && range.key) || range); // stable redraw key per range const lineAction = (val, series, sub) => (
{fmt(val)}
{lastDelta(series) != null && } {sub && {sub}}
); return (
a + b, 0), d.newUsers, 'total')}> {Object.keys(d.country.values).length} countries}> {d.country.total === 0 ?
No country data yet.
: }
{osTotal === 0 ?
No OS data yet.
: }
{healthTotal === 0 ?
No plant data yet.
: }
Active
{fmt(d.retained)}
Total
{fmt(d.totalSnap)}
{(d.addScan + d.addSearch) === 0 ?
No data in range.
: }
{(d.obScan + d.obSearch) === 0 ?
No data in range.
: }
); } // formatNumber from the data layer (window.FlorioAPI may not be ready at definition time β†’ lazy). function API_FMT(n) { return (window.FlorioAPI && window.FlorioAPI.formatNumber) ? window.FlorioAPI.formatNumber(n) : String(n ?? 0); } // Register under a Florio-owned namespace. NOTE: _ds_bundle.js itself defines // window.PAOverview/PAOnboarding/PAGamification (mock kit tabs), so we must NOT // key off those β€” an unbuilt tab would render the bundle's fake-data mock. (window.FlorioTabs = window.FlorioTabs || {}).overview = Overview; })();