// Florio Analytics — Gamification tab (real data via window.FlorioAPI). // Mirrors the existing js/pages/gamification.js data, rebuilt in the new design kit. (function () { const NS = window.HyperChartsDesignSystem_e51a95; const { LineChart, BarChart, ProgressRing, Badge } = NS; const I = window.HyperIcons; const { useState, useEffect } = React; const rangeToDays = (r) => ({ Day: 1, Week: 7, Month: 30, Year: 365 }[r] || 30); // ── 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('[Gamification] ' + key + ' failed:', e); return fallback; } } const shortDate = (s) => { const d = new Date(s + 'T00:00:00'); return `${d.getDate()}/${d.getMonth() + 1}`; }; const streakOf = (u) => (typeof u.streakCount === 'number' ? u.streakCount : 0); // formatNumber from the data layer (window.FlorioAPI may not be ready at definition time → lazy). function fmt(n) { return (window.FlorioAPI && window.FlorioAPI.formatNumber) ? window.FlorioAPI.formatNumber(n) : String(n ?? 0); } // Empty-state block used inside ChartCards when a series has no data. function Empty({ note }) { return (
{I.sparkle ? I.sparkle({ size: 24 }) : null} {note || 'No data yet'}
); } function Gamification({ 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; // Range-DEPENDENT: daily aggregates for the selected range (Total XP, XP/day, Coins/day). const aggP = API.fetchDailyAggregates(dates).catch((e) => { console.error('[Gamification] aggregates', e); return {}; }); // Range-INDEPENDENT (cached across range changes): // • users → avg streak + streak % thresholds // • recent14 → streak distribution (active last 14 days) // • secure → level distribution + coin-balance distribution // • recent30 + plantCounts → plant-count distribution (active last 30 days) const [agg, users, recent14, secure, plantData] = await Promise.all([ aggP, cached('users', () => API.fetchUsers(10000), []), cached('recent14', () => API.fetchRecentUsers(14), []), cached('secure', () => API.fetchUserSecureDocs(10000), []), cached('plantDist', async () => { const ru = await API.fetchRecentUsers(30); const counts = await API.fetchPlantCounts(ru); return counts; }, []), ]); if (!alive) return; // ── KPI stats (from full users list) ── const totalUsers = users.length; const streaks = users.map(streakOf); const streakSum = streaks.reduce((a, b) => a + b, 0); const avgStreak = totalUsers > 0 ? (streakSum / totalUsers) : 0; const above3 = streaks.filter((s) => s >= 3).length; const above7 = streaks.filter((s) => s >= 7).length; const pct3 = totalUsers > 0 ? (above3 / totalUsers) * 100 : 0; const pct7 = totalUsers > 0 ? (above7 / totalUsers) * 100 : 0; // ── Total XP (range-dependent) ── const totalXp = API.sumDailyField(agg, 'xpGained'); // ── XP / coins per day (range-dependent series) ── const xpSeries = dates.map((x) => agg[x]?.xpGained ?? 0); const coinSeries = dates.map((x) => agg[x]?.coinsEarned ?? 0); const xpTotal = xpSeries.reduce((a, b) => a + b, 0); const coinTotal = coinSeries.reduce((a, b) => a + b, 0); // ── Streak length distribution (active last 14 days, streak ≥ 1), bucketed to // match the kit's compact 5-bar design (full per-value data is summarised here) ── const recentStreaks = recent14.map(streakOf).filter((s) => s >= 1); const streakItems = [ { label: '1–2 d', value: recentStreaks.filter((s) => s <= 2).length }, { label: '3–6 d', value: recentStreaks.filter((s) => s >= 3 && s <= 6).length }, { label: '7–13 d', value: recentStreaks.filter((s) => s >= 7 && s <= 13).length }, { label: '14–29 d', value: recentStreaks.filter((s) => s >= 14 && s <= 29).length }, { label: '30 d+', value: recentStreaks.filter((s) => s >= 30).length }, ]; // ── Level distribution (userSecure): bucket by garden.level ── const levelCounts = {}; for (const u of secure) { const lvl = u.garden?.level ?? 0; levelCounts[lvl] = (levelCounts[lvl] || 0) + 1; } const sortedLevels = Object.keys(levelCounts).map(Number).sort((a, b) => a - b); const levelData = sortedLevels.map((l) => levelCounts[l]); const levelLabels = sortedLevels.map((l) => 'Lv ' + l); // ── Coin balance distribution (userSecure): buckets of 10 ── const coinValues = secure.map((u) => (typeof u.coins === 'number' ? u.coins : 0)); const maxCoins = Math.max(...coinValues, 0); const coinBuckets = Math.floor(maxCoins / 10) + 1; const coinDist = new Array(coinBuckets).fill(0); for (const c of coinValues) coinDist[Math.floor(c / 10)]++; const coinBalData = coinDist; const coinBalLabels = coinDist.map((_, i) => `${i * 10}-${i * 10 + 9}`); // ── Plant count distribution (active last 30 days, >=1 plant): one bucket per plant count ── const withPlants = plantData.filter((c) => c >= 1); const maxPlants = Math.max(...withPlants, 0); const plantDist = new Array(maxPlants).fill(0); for (const c of withPlants) plantDist[c - 1]++; const plantData2 = plantDist; const plantLabels = plantDist.map((_, i) => String(i + 1)); setD({ totalUsers, avgStreak, above3, above7, pct3, pct7, totalXp, xpSeries, coinSeries, xpTotal, coinTotal, labels: dates.map(shortDate), dayCount: days, streakItems, streakHasData: streakItems.some((b) => b.value > 0), levelData, levelLabels, levelHasData: levelData.length > 0 && levelData.some((v) => v > 0), coinBalData, coinBalLabels, coinBalHasData: coinBalData.length > 0 && coinBalData.some((v) => v > 0), plantData: plantData2, plantLabels, plantHasData: plantData2.length > 0 && plantData2.some((v) => v > 0), }); })(); return () => { alive = false; }; }, [range]); if (!d) { return
; } const rk = (range && range.key === 'custom') ? (range.start + '_' + range.end) : String((range && range.key) || range); // stable redraw key per range // Per-user averages for KPI subtext (guard /0). const xpPerUser = d.totalUsers > 0 ? (d.totalXp / d.totalUsers) : 0; const xpAvgPerDay = d.dayCount > 0 ? Math.round(d.xpTotal / d.dayCount) : 0; const coinAvgPerDay = d.dayCount > 0 ? Math.round(d.coinTotal / d.dayCount) : 0; const xpToday = d.xpSeries.length ? d.xpSeries[d.xpSeries.length - 1] : 0; const coinToday = d.coinSeries.length ? d.coinSeries[d.coinSeries.length - 1] : 0; const lineAction = (val, sub) => (
{fmt(val)}
{sub}
); return (
{/* KPI row — 5 stat tiles */}
{/* Streak distribution (rings + bars) + Level distribution */}
Streak length
{d.streakHasData ? : }
{d.levelHasData ? : } {/* XP & coins per day (range-dependent) */} {d.xpTotal > 0 ?
range total {fmt(d.xpTotal)} XP avg {fmt(xpAvgPerDay)} / day
: }
{d.coinTotal > 0 ?
range total {fmt(d.coinTotal)} avg {fmt(coinAvgPerDay)} / day
: }
{/* Distributions */} {d.coinBalHasData ? : } {d.plantHasData ? : }
); } // Register under a Florio-owned namespace. NOTE: _ds_bundle.js itself defines a // MOCK window.PAGamification — keying off that would leak fake data, so we must NOT. (window.FlorioTabs = window.FlorioTabs || {}).gamification = Gamification; })();