// Florio Analytics — Users tab. // Surfaces three at-risk / churned cohorts so we can reach out: // 1. Inactive > 7 days — lastActiveDate older than a week (from users/{uid}). // 2. Trial cancelled — cancelled during the free trial // (userSecure/{uid}.subscription.status === 'trial_cancelled'). // 3. Subscription cancelled — cancelled a paid subscription // (subscription.status === 'cancelled'). // Data is joined client-side from `users` (name, email, lastActiveDate) and // `userSecure` (subscription state). The subscription.status field is written // going forward by the RevenueCat webhook and backfilled for existing users by // the backfillSubscriptionStatus Cloud Function. (function () { const I = window.HyperIcons; const { useState, useEffect, useMemo, useCallback } = React; const BACKFILL_URL = 'https://us-central1-florio-a6f48.cloudfunctions.net/backfillSubscriptionStatus'; // ── Date helpers (lastActiveDate is a 'YYYY-MM-DD' local date string) ── const pad = (n) => String(n).padStart(2, '0'); const todayStr = () => { const d = new Date(); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; const daysAgoStr = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; const daysSince = (dateStr) => { if (!dateStr) return null; const then = new Date(dateStr + 'T00:00:00'); if (isNaN(then.getTime())) return null; const now = new Date(todayStr() + 'T00:00:00'); return Math.round((now - then) / 86400000); }; const fmtMsDate = (ms) => { if (!ms) return null; const d = new Date(ms); return isNaN(d.getTime()) ? null : d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); }; const displayName = (u) => u.givenName || u.username || u.userName || '—'; // ── Status → colored badge ── const STATUS_META = { trial_cancelled: { label: 'Trial cancelled', color: '#FE7E07' }, cancelled: { label: 'Cancelled', color: '#FF5C7A' }, expired: { label: 'Expired', color: '#9A8CFF' }, billing_issue: { label: 'Billing issue', color: '#FF5C7A' }, trial: { label: 'On trial', color: '#2ACF56' }, active: { label: 'Active', color: '#2ACF56' }, never: { label: 'Free', color: 'var(--text-muted)' }, }; function StatusBadge({ status }) { const meta = STATUS_META[status] || { label: status || 'Unknown', color: 'var(--text-muted)' }; return ( {meta.label} ); } // ── Reusable rows table ── function UserTable({ rows, lastActiveCol }) { if (rows.length === 0) { return (
{I.users({ size: 24 })} No users in this group
); } const cellHead = { fontFamily: 'var(--font-mono-display)', fontSize: 10, letterSpacing: '.1em', textTransform: 'uppercase', color: 'var(--text-muted)', textAlign: 'left', padding: '0 12px 10px', fontWeight: 600 }; const cell = { fontFamily: 'var(--font-sans)', fontSize: 13, color: 'var(--text-secondary)', padding: '11px 12px', borderTop: '1px solid var(--border-subtle)', verticalAlign: 'middle' }; return (
{rows.map((u) => ( ))}
Name Email {lastActiveCol} Status
{displayName(u)} {u.email || '—'} {u._when}
); } function Users() { const P = window.PA; const [rows, setRows] = useState(null); const [err, setErr] = useState(null); const [q, setQ] = useState(''); const [busy, setBusy] = useState(false); const [result, setResult] = useState(null); // Pull a generous cap; join users + their secure (subscription) doc. const loadRows = useCallback(async () => { const API = window.FlorioAPI; const [users, secure] = await Promise.all([ API.fetchUsers(5000), API.fetchUserSecureDocs(5000), ]); const subByUid = {}; secure.forEach((s) => { subByUid[s.uid] = (s.subscription) || {}; }); return users.map((u) => ({ ...u, subscription: subByUid[u.uid] || {} })); }, []); useEffect(() => { let alive = true; (async () => { await window.FlorioReady; try { const merged = await loadRows(); if (alive) setRows(merged); } catch (e) { console.error('[Users] load failed:', e); if (alive) { setErr(e.message || String(e)); setRows([]); } } })(); return () => { alive = false; }; }, [loadRows]); // Trigger the admin backfillSubscriptionStatus Cloud Function. `dryRun` caps // it to 50 users (?limit=50) so you can verify RevenueCat wiring safely first. const runBackfill = useCallback(async (dryRun) => { if (!dryRun && !window.confirm( 'Backfill subscription status from RevenueCat for ALL users?\n\n' + 'This calls the RevenueCat API once per user and can take a few minutes.', )) return; setBusy(true); setResult(null); try { const cur = window.FlorioAuth.auth.currentUser; if (!cur) throw new Error('Not signed in'); const token = await cur.getIdToken(); const res = await fetch(BACKFILL_URL + (dryRun ? '?limit=50' : ''), { headers: { Authorization: 'Bearer ' + token }, }); const text = await res.text(); let json = null; try { json = JSON.parse(text); } catch (e) { /* non-JSON */ } if (!res.ok) throw new Error((json && json.error) || text || ('HTTP ' + res.status)); setResult(Object.assign({ ok: true, dryRun }, json)); try { setRows(await loadRows()); } catch (e) { /* keep old rows */ } } catch (e) { console.error('[Users] backfill failed:', e); setResult({ ok: false, error: e.message || String(e) }); } finally { setBusy(false); } }, [loadRows]); const cohorts = useMemo(() => { const cutoff7 = daysAgoStr(7); const inactive = []; const trialCancelled = []; const subCancelled = []; const term = q.trim().toLowerCase(); const matches = (u) => !term || displayName(u).toLowerCase().includes(term) || (u.email || '').toLowerCase().includes(term); (rows || []).forEach((u) => { const status = u.subscription?.status; if (matches(u) && u.lastActiveDate && u.lastActiveDate < cutoff7) { const d = daysSince(u.lastActiveDate); inactive.push({ ...u, _status: status || 'never', _when: d != null ? `${d} days ago` : u.lastActiveDate, _sort: d ?? 0 }); } if (matches(u) && status === 'trial_cancelled') { trialCancelled.push({ ...u, _status: status, _when: fmtMsDate(u.subscription?.cancelledAt) || 'Unknown', _sort: u.subscription?.cancelledAt || 0 }); } if (matches(u) && status === 'cancelled') { subCancelled.push({ ...u, _status: status, _when: fmtMsDate(u.subscription?.cancelledAt) || 'Unknown', _sort: u.subscription?.cancelledAt || 0 }); } }); inactive.sort((a, b) => b._sort - a._sort); // longest-inactive first trialCancelled.sort((a, b) => b._sort - a._sort); // most recent cancel first subCancelled.sort((a, b) => b._sort - a._sort); return { inactive, trialCancelled, subCancelled }; }, [rows, q]); if (!rows) { return
; } const fmt = (n) => (window.FlorioAPI && window.FlorioAPI.formatNumber) ? window.FlorioAPI.formatNumber(n) : String(n ?? 0); return (
{err && (
Failed to load users: {err}
)} {/* KPI row */}
{/* Controls: search + RevenueCat backfill */}
{I.search({ size: 17 })} setQ(e.target.value)} placeholder="Filter by name or email" style={{ border: 'none', outline: 'none', background: 'transparent', flex: 1, fontFamily: 'var(--font-sans)', fontSize: 14, color: 'var(--text-primary)' }} />
{result && (
{result.ok ? (result.dryRun ? 'Dry run (up to 50 users): ' : 'Backfill complete: ') + `processed ${result.processed}, updated ${result.updated}, errors ${result.errors}.` + (result.statusCounts ? ' Statuses → ' + Object.entries(result.statusCounts).map(([k, v]) => `${k}: ${v}`).join(', ') : '') : 'Backfill failed: ' + result.error}
)}
); } (window.FlorioTabs = window.FlorioTabs || {}).users = Users; })();