// 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 (
| Name |
Email |
{lastActiveCol} |
Status |
{rows.map((u) => (
| {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;
})();