diff --git a/api/index.php b/api/index.php index 5dac51d..a521176 100644 --- a/api/index.php +++ b/api/index.php @@ -3597,6 +3597,80 @@ function getStats(PDO $db): void { } // ===== MONTHLY STATS ===== +/** + * Normalize a raw category string (may contain OpenFoodFacts "en:slug" format) + * to one of the app's known Italian category slugs. + */ +function _normalizeCat(string $raw): string { + static $known = [ + 'frutta','verdura','carne','pesce','latticini', + 'pasta','pane','cereali','bevande','condimenti', + 'surgelati','conserve','snack','altro', + ]; + $raw = trim($raw); + if (in_array($raw, $known, true)) return $raw; + + // Strip language prefix: "en:", "it:", "fr:", etc. + $slug = (string)preg_replace('/^[a-z]{2}:/', '', $raw); + if (in_array($slug, $known, true)) return $slug; + + // Map common OpenFoodFacts slugs → app categories + static $map = [ + // latticini + 'dairies'=>'latticini','dairy'=>'latticini','milk'=>'latticini', + 'fermented-milk-products'=>'latticini','cheeses'=>'latticini', + 'yogurts'=>'latticini','plant-based-milks'=>'latticini', + 'cream'=>'latticini','butter'=>'latticini','eggs'=>'latticini', + // frutta + 'fruits'=>'frutta','fresh-fruits'=>'frutta','tropical-fruits'=>'frutta', + 'dried-fruits'=>'frutta','berries'=>'frutta', + // verdura + 'vegetables'=>'verdura','fresh-vegetables'=>'verdura', + 'plant-based-foods'=>'verdura','legumes'=>'verdura', + 'mushrooms'=>'verdura','herbs'=>'verdura', + // carne + 'meats'=>'carne','beef'=>'carne','pork'=>'carne', + 'poultry'=>'carne','chicken'=>'carne','processed-meat'=>'carne', + 'sausages'=>'carne','charcuterie'=>'carne', + // pesce + 'fish'=>'pesce','seafood'=>'pesce','fish-products'=>'pesce', + 'canned-fish'=>'conserve', + // pasta + 'pastas'=>'pasta','pasta'=>'pasta','pasta-based-dishes'=>'pasta', + 'noodles'=>'pasta', + // pane + 'breads'=>'pane','bread'=>'pane','baked-goods'=>'pane', + 'pastries'=>'pane','cakes'=>'snack', + // cereali + 'cereals'=>'cereali','breakfast-cereals'=>'cereali', + 'rice'=>'cereali','grains'=>'cereali','flours'=>'cereali', + 'seeds'=>'cereali', + // bevande + 'beverages'=>'bevande','drinks'=>'bevande','waters'=>'bevande', + 'juices'=>'bevande','fruit-juices'=>'bevande','sodas'=>'bevande', + 'plant-based-foods-and-beverages'=>'bevande','coffee'=>'bevande', + 'tea'=>'bevande','alcoholic-beverages'=>'bevande','wine'=>'bevande', + 'beer'=>'bevande', + // condimenti + 'sauces'=>'condimenti','condiments'=>'condimenti', + 'spreads'=>'condimenti','oils'=>'condimenti', + 'vinegars'=>'condimenti','dressings'=>'condimenti', + 'sugar'=>'condimenti','salt'=>'condimenti','spices'=>'condimenti', + // surgelati + 'frozen-foods'=>'surgelati','frozen-vegetables'=>'surgelati', + 'frozen-fish'=>'surgelati','ice-cream'=>'surgelati', + // conserve + 'preserved-foods'=>'conserve','canned-foods'=>'conserve', + 'jams'=>'conserve','pickles'=>'conserve','tomato-sauces'=>'conserve', + // snack + 'snacks'=>'snack','cookies'=>'snack','chips'=>'snack', + 'chocolates'=>'snack','candies'=>'snack','sweets'=>'snack', + 'crackers'=>'snack','biscuits'=>'snack','nuts'=>'snack', + ]; + + return $map[$slug] ?? $map[strtolower($slug)] ?? 'altro'; +} + function getMonthlyStats(PDO $db): void { EverLog::debug('getMonthlyStats'); @@ -3637,11 +3711,21 @@ function getMonthlyStats(PDO $db): void { ")->fetchAll(PDO::FETCH_ASSOC); $totalCatEvents = array_sum(array_column($catRows, 'cnt')) ?: 1; - $topCats = array_map(fn($r) => [ - 'cat' => $r['cat'], - 'count' => (int)$r['cnt'], - 'pct' => (int)round((int)$r['cnt'] / $totalCatEvents * 100), - ], $catRows); + + // Normalize OFF slugs (e.g. "en:dairies" → "latticini"), then re-aggregate + $normAgg = []; + foreach ($catRows as $r) { + $norm = _normalizeCat((string)$r['cat']); + $normAgg[$norm] = ($normAgg[$norm] ?? 0) + (int)$r['cnt']; + } + arsort($normAgg); + $normAgg = array_slice($normAgg, 0, 4, true); + $totalNorm = array_sum($normAgg) ?: 1; + $topCats = array_map(fn($cat, $cnt) => [ + 'cat' => $cat, + 'count' => $cnt, + 'pct' => (int)round($cnt / $totalNorm * 100), + ], array_keys($normAgg), array_values($normAgg)); // Top consumed products this month $topProds = $db->query(" diff --git a/assets/js/app.js b/assets/js/app.js index 87f81b8..2cec51c 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4395,7 +4395,9 @@ function _renderMonthlyStatsSection(data) { const catBars = top.map(c => { const color = _NUTR_COLORS[c.cat] || '#64748b'; const barPct = Math.round(c.count / maxCnt * 100); - const label = t('categories.' + c.cat) || c.cat; + // t() returns the key itself when not found — guard against it + const catKey = 'categories.' + c.cat; + const label = t(catKey) !== catKey ? t(catKey) : c.cat.replace(/-/g, ' '); return `
${escapeHtml(label)}
@@ -4455,15 +4457,15 @@ const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly']; function _startInsightAlternation() { clearInterval(_insightFlipTimer); - // Pick initial panel based on current hour, cycling through 3 phases - const idx = Math.floor(Date.now() / 3_600_000) % _INSIGHT_PHASES.length; + // Pick initial panel cycling through 3 phases based on current 30-second slot + const idx = Math.floor(Date.now() / 30_000) % _INSIGHT_PHASES.length; _insightPhase = _INSIGHT_PHASES[idx]; _applyInsightPhase(); - // Advance to next phase every hour + // Advance every 30 seconds so the rotation is actually visible _insightFlipTimer = setInterval(() => { _insightPhase = _INSIGHT_PHASES[(_INSIGHT_PHASES.indexOf(_insightPhase) + 1) % _INSIGHT_PHASES.length]; _applyInsightPhase(); - }, 3_600_000); + }, 30_000); } function _applyInsightPhase() { @@ -4471,9 +4473,25 @@ function _applyInsightPhase() { const nutrEl = document.getElementById('nutrition-section'); const monthlyEl = document.getElementById('monthly-stats-section'); if (!wasteEl || !nutrEl) return; - const showWaste = _insightPhase === 'waste' && wasteEl.innerHTML.trim() !== ''; - const showNutr = _insightPhase === 'nutrition' && nutrEl.innerHTML.trim() !== ''; - const showMonthly = _insightPhase === 'monthly' && !!monthlyEl && monthlyEl.innerHTML.trim() !== ''; + + // Map of which panels actually have rendered content + const hasContent = { + 'waste': wasteEl.innerHTML.trim() !== '', + 'nutrition': nutrEl.innerHTML.trim() !== '', + 'monthly': !!monthlyEl && monthlyEl.innerHTML.trim() !== '', + }; + + // If the intended phase has no content, advance to the next one that does + let phase = _insightPhase; + for (let i = 0; i < _INSIGHT_PHASES.length; i++) { + if (hasContent[phase]) break; + phase = _INSIGHT_PHASES[(_INSIGHT_PHASES.indexOf(phase) + 1) % _INSIGHT_PHASES.length]; + } + + const showWaste = phase === 'waste'; + const showNutr = phase === 'nutrition'; + const showMonthly = phase === 'monthly'; + // Fade-swap all three panels const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : [])]; els.forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; }); @@ -4481,8 +4499,6 @@ function _applyInsightPhase() { wasteEl.style.display = showWaste ? 'block' : 'none'; nutrEl.style.display = showNutr ? 'block' : 'none'; if (monthlyEl) monthlyEl.style.display = showMonthly ? 'block' : 'none'; - // If nothing ready yet, keep waste visible as fallback - if (!showWaste && !showNutr && !showMonthly) wasteEl.style.display = 'block'; requestAnimationFrame(() => { els.forEach(el => { el.style.opacity = '1'; }); if (showNutr) {