// 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) => (
);
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;
})();