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 = `
+
+
+
+
+
${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