// 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 (
{days.map((day) => {
const c = cohort[day]; const a = age(day);
return (
Cohort
Users
{milestones.map((m) => {m.label} )}
);
})}
{label(day)}
{(c.size || 0).toLocaleString()}
{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 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}% ;
})}