diff --git a/api/index.php b/api/index.php index 91cd81c..5dac51d 100644 --- a/api/index.php +++ b/api/index.php @@ -801,6 +801,10 @@ try { getStats($db); break; + case 'monthly_stats': + getMonthlyStats($db); + break; + case 'consumption_predictions': getConsumptionPredictions($db); break; @@ -3592,6 +3596,80 @@ function getStats(PDO $db): void { ]); } +// ===== MONTHLY STATS ===== +function getMonthlyStats(PDO $db): void { + EverLog::debug('getMonthlyStats'); + + $thisMonthStart = date('Y-m-01'); + $lastMonthStart = date('Y-m-01', strtotime('first day of last month')); + $lastMonthEnd = date('Y-m-01'); // exclusive upper bound for prev month + + // Totals: consumed + added + wasted this month vs previous calendar month + $totals = $db->query(" + SELECT + SUM(CASE WHEN created_at >= '{$thisMonthStart}' + AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS this_out, + SUM(CASE WHEN created_at >= '{$lastMonthStart}' AND created_at < '{$lastMonthEnd}' + AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS prev_out, + SUM(CASE WHEN created_at >= '{$thisMonthStart}' + AND type = 'in' AND undone=0 THEN 1 ELSE 0 END) AS this_in, + SUM(CASE WHEN created_at >= '{$thisMonthStart}' + AND type = 'waste' AND undone=0 THEN 1 ELSE 0 END) AS this_wasted + FROM transactions + WHERE created_at >= '{$lastMonthStart}' + ")->fetch(PDO::FETCH_ASSOC); + + $thisOut = (int)($totals['this_out'] ?? 0); + $prevOut = (int)($totals['prev_out'] ?? 0); + $thisIn = (int)($totals['this_in'] ?? 0); + $thisWaste = (int)($totals['this_wasted'] ?? 0); + + // Top categories consumed this month + $catRows = $db->query(" + SELECT COALESCE(NULLIF(TRIM(p.category), ''), 'altro') AS cat, COUNT(*) AS cnt + FROM transactions t + JOIN products p ON t.product_id = p.id + WHERE t.type IN ('out','waste') AND t.undone = 0 + AND t.created_at >= '{$thisMonthStart}' + GROUP BY cat + ORDER BY cnt DESC + LIMIT 5 + ")->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); + + // Top consumed products this month + $topProds = $db->query(" + SELECT p.name, COUNT(*) AS cnt + FROM transactions t + JOIN products p ON t.product_id = p.id + WHERE t.type IN ('out','waste') AND t.undone = 0 + AND t.created_at >= '{$thisMonthStart}' + GROUP BY t.product_id + ORDER BY cnt DESC + LIMIT 3 + ")->fetchAll(PDO::FETCH_ASSOC); + + echo json_encode([ + 'success' => true, + 'month' => date('Y-m'), + 'items_consumed' => $thisOut, + 'items_consumed_prev' => $prevOut, + 'items_added' => $thisIn, + 'items_wasted' => $thisWaste, + 'top_categories' => $topCats, + 'top_products' => array_map(fn($r) => [ + 'name' => $r['name'], + 'count' => (int)$r['cnt'], + ], $topProds), + ]); +} + // ===== RECENT & POPULAR PRODUCTS ===== function recentPopularProducts(PDO $db): void { EverLog::debug('recentPopularProducts'); diff --git a/assets/css/style.css b/assets/css/style.css index a83e3a8..d826a25 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -6924,6 +6924,82 @@ body.cooking-mode-active .app-header { } .nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; } +/* ===== MONTHLY STATS PANEL ===== */ +.ms-main-row { + display: flex; + align-items: center; + gap: 14px; + margin: 12px 0 8px; +} +.ms-main-num { + font-size: 2.8rem; + font-weight: 700; + color: #6366f1; + line-height: 1; + letter-spacing: -0.02em; +} +.ms-main-info { + display: flex; + flex-direction: column; + gap: 4px; +} +.ms-main-label { + font-size: .85rem; + color: #94a3b8; +} +.ms-trend { + font-size: .8rem; + font-weight: 500; +} +.ms-cats-section { + margin: 6px 0 4px; +} +.ms-cats-title { + font-size: .68rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: .06em; + margin-bottom: 7px; +} +.ms-cat-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 5px; +} +.ms-cat-name { + font-size: .74rem; + color: #cbd5e1; + min-width: 78px; + max-width: 78px; + text-align: right; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ms-cat-bar-wrap { + flex: 1; + height: 8px; + background: #1e293b; + border-radius: 4px; + overflow: hidden; +} +.ms-cat-bar { + height: 100%; + border-radius: 4px; + width: 0; +} +.ms-cat-cnt { + font-size: .7rem; + color: #64748b; + min-width: 22px; + text-align: right; +} +.ms-badges-row { + margin-top: 6px; +} + /* ===== SETUP WIZARD ===== */ .setup-wizard-content { max-width: 480px; diff --git a/assets/js/app.js b/assets/js/app.js index 272382e..87f81b8 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4355,47 +4355,147 @@ function _nutrScoreBar(label, val, color) { `; } -/** - * Start the waste ↔ nutrition alternation on the dashboard. - * One is shown, the other hidden; they swap every hour (+ random phase offset). - */ -let _insightPhase = null; // 'waste' | 'nutrition' +// ===== MONTHLY STATS SECTION ===== +// Third panel in the insight rotation (waste → nutrition → monthly → waste …) -function _startInsightAlternation(inventory) { +function _renderMonthlyStatsSection(data) { + const section = document.getElementById('monthly-stats-section'); + if (!section) return; + if (!data || !data.success || data.items_consumed === 0) { + section.innerHTML = ''; + section.style.display = 'none'; + return; + } + + // Month label from 'YYYY-MM' → formatted locale string + const [yr, mo] = data.month.split('-').map(Number); + const localeMap = { de: 'de-DE', fr: 'fr-FR', es: 'es-ES', en: 'en-GB', it: 'it-IT' }; + const locale = localeMap[_currentLang] || 'it-IT'; + const monthLabel = new Date(yr, mo - 1, 1).toLocaleDateString(locale, { month: 'long', year: 'numeric' }); + const prevLabel = new Date(yr, mo - 2, 1).toLocaleDateString(locale, { month: 'long' }); + + // Trend vs previous month + let trendHTML = ''; + const prev = data.items_consumed_prev; + const curr = data.items_consumed; + if (prev > 0) { + const diff = Math.round((curr - prev) / prev * 100); + if (diff < -2) { + trendHTML = `↓ ${t('stats_monthly.trend_down').replace('{pct}', Math.abs(diff)).replace('{prev}', prevLabel)}`; + } else if (diff > 2) { + trendHTML = `↑ ${t('stats_monthly.trend_up').replace('{pct}', diff).replace('{prev}', prevLabel)}`; + } else { + trendHTML = `→ ${t('stats_monthly.trend_same')}`; + } + } + + // Top category bars + const top = (data.top_categories || []).slice(0, 4); + const maxCnt = top.length ? Math.max(...top.map(c => c.count)) : 1; + 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; + return `
+ ${escapeHtml(label)} +
+
+
+ ${c.count} +
`; + }).join(''); + + // Badges + const badges = []; + if (data.items_added > 0) + badges.push(`📦${data.items_added}${t('stats_monthly.added')}`); + if (data.items_wasted > 0) + badges.push(`🗑️${data.items_wasted}${t('stats_monthly.wasted')}`); + if (data.top_products?.length > 0) + badges.push(`${escapeHtml(data.top_products[0].name)}${t('stats_monthly.top_used')}`); + + section.innerHTML = ` +
+
+
+ +

${t('stats_monthly.title')}

+
+ ${monthLabel} +
+ +
+
${curr}
+
+
${t('stats_monthly.consumed')}
+
${trendHTML}
+
+
+ + ${top.length > 0 ? ` +
+
${t('stats_monthly.top_cats')}
+ ${catBars} +
` : ''} + + ${badges.length > 0 ? `
${badges.join('')}
` : ''} + +
${t('stats_monthly.source')}
+
`; + + // Show only if it's the active phase (mirrors _applyInsightPhase logic) + section.style.display = (_insightPhase === 'monthly') ? 'block' : 'none'; +} + +/** + * Start the waste ↔ nutrition ↔ monthly stats alternation on the dashboard. + */ +let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly' +const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly']; + +function _startInsightAlternation() { clearInterval(_insightFlipTimer); - // Pick initial panel based on current minute: even hour → waste, odd → nutrition - const isNutritionTurn = Math.floor(Date.now() / 3_600_000) % 2 === 1; - _insightPhase = isNutritionTurn ? 'nutrition' : 'waste'; + // Pick initial panel based on current hour, cycling through 3 phases + const idx = Math.floor(Date.now() / 3_600_000) % _INSIGHT_PHASES.length; + _insightPhase = _INSIGHT_PHASES[idx]; _applyInsightPhase(); - // Flip every hour + // Advance to next phase every hour _insightFlipTimer = setInterval(() => { - _insightPhase = _insightPhase === 'waste' ? 'nutrition' : 'waste'; + _insightPhase = _INSIGHT_PHASES[(_INSIGHT_PHASES.indexOf(_insightPhase) + 1) % _INSIGHT_PHASES.length]; _applyInsightPhase(); }, 3_600_000); } function _applyInsightPhase() { - const wasteEl = document.getElementById('waste-chart-section'); - const nutrEl = document.getElementById('nutrition-section'); + const wasteEl = document.getElementById('waste-chart-section'); + const nutrEl = document.getElementById('nutrition-section'); + const monthlyEl = document.getElementById('monthly-stats-section'); if (!wasteEl || !nutrEl) return; - const showNutr = _insightPhase === 'nutrition' && nutrEl.innerHTML.trim() !== ''; - const showWaste = _insightPhase === 'waste' && wasteEl.innerHTML.trim() !== ''; - // Fade-swap - [wasteEl, nutrEl].forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; }); + const showWaste = _insightPhase === 'waste' && wasteEl.innerHTML.trim() !== ''; + const showNutr = _insightPhase === 'nutrition' && nutrEl.innerHTML.trim() !== ''; + const showMonthly = _insightPhase === 'monthly' && !!monthlyEl && monthlyEl.innerHTML.trim() !== ''; + // Fade-swap all three panels + const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : [])]; + els.forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; }); setTimeout(() => { - wasteEl.style.display = showWaste ? 'block' : 'none'; - nutrEl.style.display = showNutr ? 'block' : 'none'; - // If neither ready yet, keep waste visible - if (!showWaste && !showNutr) wasteEl.style.display = 'block'; + 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(() => { - wasteEl.style.opacity = '1'; - nutrEl.style.opacity = '1'; - // Animate score bars when nutrition becomes visible + els.forEach(el => { el.style.opacity = '1'; }); if (showNutr) { nutrEl.querySelectorAll('.nutr-score-fill').forEach(bar => { bar.style.width = (bar.dataset.target || 0) + '%'; }); } + if (showMonthly && monthlyEl) { + monthlyEl.querySelectorAll('.ms-cat-bar').forEach(bar => { + bar.style.transition = 'width 0.6s ease'; + bar.style.width = (bar.dataset.target || 0) + '%'; + }); + } }); }, 620); } @@ -4512,10 +4612,11 @@ async function loadDashboard() { // Banner alerts (suspicious quantities + consumption predictions) loadBannerAlerts(); - // Anti-waste section + Nutrition section: load in parallel - const [, invForNutr] = await Promise.all([ + // Anti-waste section + Nutrition section + Monthly stats: load in parallel + const [, invForNutr, monthlyData] = await Promise.all([ _awLoadFacts(), api('inventory_list').then(d => d.inventory || []).catch(() => []), + api('monthly_stats').catch(() => null), ]); _renderAntiWasteSection( statsData.used_30d || 0, statsData.wasted_30d || 0, @@ -4527,6 +4628,10 @@ async function loadDashboard() { // Nutrition section — built from the full inventory list _renderNutritionSection(invForNutr); + + // Monthly stats panel + _renderMonthlyStatsSection(monthlyData); + _startInsightAlternation(); // Opened (partially used products with known package capacity) diff --git a/index.html b/index.html index c19778e..294cc7b 100644 --- a/index.html +++ b/index.html @@ -169,10 +169,11 @@
- +
+
diff --git a/translations/de.json b/translations/de.json index 24501f1..fb78711 100644 --- a/translations/de.json +++ b/translations/de.json @@ -1424,5 +1424,17 @@ "retry": "Erneut versuchen", "syncing_local": "Lokale Daten synchronisieren...", "sync_done": "Lokale Daten aktualisiert" + }, + "stats_monthly": { + "title": "Monatsstatistik", + "consumed": "Produkte verbraucht", + "trend_up": "+{pct}% vs. {prev}", + "trend_down": "-{pct}% vs. {prev}", + "trend_same": "gleiches Tempo wie letzten Monat", + "added": "hinzugefügt", + "wasted": "verschwendet", + "top_used": "meistbenutzt", + "top_cats": "Hauptkategorien", + "source": "Transaktionsverlauf · aktueller Monat" } } \ No newline at end of file diff --git a/translations/en.json b/translations/en.json index b5f1228..48a40a1 100644 --- a/translations/en.json +++ b/translations/en.json @@ -1424,5 +1424,17 @@ "retry": "Retry", "syncing_local": "Syncing local data...", "sync_done": "Local data synced" + }, + "stats_monthly": { + "title": "Monthly Stats", + "consumed": "products used", + "trend_up": "+{pct}% vs {prev}", + "trend_down": "-{pct}% vs {prev}", + "trend_same": "same pace as last month", + "added": "added", + "wasted": "wasted", + "top_used": "top used", + "top_cats": "Top categories", + "source": "Transaction history · current month" } } \ No newline at end of file diff --git a/translations/es.json b/translations/es.json index 4216b01..447b9c7 100644 --- a/translations/es.json +++ b/translations/es.json @@ -1367,5 +1367,17 @@ "retry": "Reintentar", "syncing_local": "Sincronizando datos locales...", "sync_done": "Datos locales sincronizados" + }, + "stats_monthly": { + "title": "Estadísticas Mensuales", + "consumed": "productos usados", + "trend_up": "+{pct}% vs {prev}", + "trend_down": "-{pct}% vs {prev}", + "trend_same": "mismo ritmo que el mes pasado", + "added": "añadidos", + "wasted": "desperdiciados", + "top_used": "más usado", + "top_cats": "Categorías principales", + "source": "Historial de transacciones · mes actual" } } \ No newline at end of file diff --git a/translations/fr.json b/translations/fr.json index 6fe373a..0445551 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -1367,5 +1367,17 @@ "retry": "Réessayer", "syncing_local": "Synchronisation des données locales...", "sync_done": "Données locales synchronisées" + }, + "stats_monthly": { + "title": "Statistiques Mensuelles", + "consumed": "produits utilisés", + "trend_up": "+{pct}% vs {prev}", + "trend_down": "-{pct}% vs {prev}", + "trend_same": "même rythme que le mois dernier", + "added": "ajoutés", + "wasted": "gaspillés", + "top_used": "le plus utilisé", + "top_cats": "Catégories principales", + "source": "Historique des transactions · mois en cours" } } \ No newline at end of file diff --git a/translations/it.json b/translations/it.json index c24bc2f..66731df 100644 --- a/translations/it.json +++ b/translations/it.json @@ -1424,5 +1424,17 @@ "retry": "Riprova", "syncing_local": "Sincronizzazione dati locali...", "sync_done": "Dati locali aggiornati" + }, + "stats_monthly": { + "title": "Statistiche Mensili", + "consumed": "prodotti usati", + "trend_up": "+{pct}% rispetto a {prev}", + "trend_down": "-{pct}% rispetto a {prev}", + "trend_same": "stesso ritmo del mese scorso", + "added": "aggiunti", + "wasted": "sprecati", + "top_used": "più usato", + "top_cats": "Categorie principali", + "source": "Storico transazioni · mese corrente" } } \ No newline at end of file