feat: monthly stats panel in rotating insight banner (closes #100)

- Add PHP action 'monthly_stats' + getMonthlyStats() function:
  items consumed/added/wasted this month, trend vs prev month,
  top 5 categories and top 3 products by transaction count
- Expand insight rotation from 2 → 3 phases (waste → nutrition → monthly)
- Add _renderMonthlyStatsSection() following nutrition section styling:
  large indigo number, trend arrow with %, horizontal category bars
  animated on show, badges for added/wasted/top-product
- Add ms-* CSS classes (bar chart layout matching nutr-* design)
- Add stats_monthly translations in it/en/de/fr/es (10 keys each)
This commit is contained in:
dadaloop82
2026-05-26 17:19:54 +00:00
parent 695c23fc21
commit b985247b95
9 changed files with 347 additions and 27 deletions
+78
View File
@@ -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');
+76
View File
@@ -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;
+131 -26
View File
@@ -4355,47 +4355,147 @@ function _nutrScoreBar(label, val, color) {
</div>`;
}
/**
* 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 = `<span class="aw-arrow-good">↓ ${t('stats_monthly.trend_down').replace('{pct}', Math.abs(diff)).replace('{prev}', prevLabel)}</span>`;
} else if (diff > 2) {
trendHTML = `<span class="aw-arrow-bad">↑ ${t('stats_monthly.trend_up').replace('{pct}', diff).replace('{prev}', prevLabel)}</span>`;
} else {
trendHTML = `<span class="aw-arrow-ok">→ ${t('stats_monthly.trend_same')}</span>`;
}
}
// 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 `<div class="ms-cat-row">
<span class="ms-cat-name">${escapeHtml(label)}</span>
<div class="ms-cat-bar-wrap">
<div class="ms-cat-bar" style="background:${color}" data-target="${barPct}"></div>
</div>
<span class="ms-cat-cnt">${c.count}</span>
</div>`;
}).join('');
// Badges
const badges = [];
if (data.items_added > 0)
badges.push(`<span class="aw-badge"><span class="aw-badge-icon">📦</span><span class="aw-badge-body"><b>${data.items_added}</b><small>${t('stats_monthly.added')}</small></span></span>`);
if (data.items_wasted > 0)
badges.push(`<span class="aw-badge aw-badge-wasted"><span class="aw-badge-icon">🗑️</span><span class="aw-badge-body"><b>${data.items_wasted}</b><small>${t('stats_monthly.wasted')}</small></span></span>`);
if (data.top_products?.length > 0)
badges.push(`<span class="aw-badge aw-badge-better"><span class="aw-badge-icon">⭐</span><span class="aw-badge-body"><b>${escapeHtml(data.top_products[0].name)}</b><small>${t('stats_monthly.top_used')}</small></span></span>`);
section.innerHTML = `
<div class="nutr-card">
<div class="aw-header">
<div class="aw-title-row">
<span class="aw-live-dot aw-live-on"></span>
<h3 class="aw-title">${t('stats_monthly.title')}</h3>
</div>
<span class="aw-grade" style="background:#6366f1;font-size:.75rem;padding:4px 10px">${monthLabel}</span>
</div>
<div class="ms-main-row">
<div class="ms-main-num">${curr}</div>
<div class="ms-main-info">
<div class="ms-main-label">${t('stats_monthly.consumed')}</div>
<div class="ms-trend">${trendHTML}</div>
</div>
</div>
${top.length > 0 ? `
<div class="ms-cats-section">
<div class="ms-cats-title">${t('stats_monthly.top_cats')}</div>
${catBars}
</div>` : ''}
${badges.length > 0 ? `<div class="aw-savings-row ms-badges-row">${badges.join('')}</div>` : ''}
<div class="aw-source">${t('stats_monthly.source')}</div>
</div>`;
// 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)
+2 -1
View File
@@ -169,10 +169,11 @@
<div id="expired-list"></div>
</div>
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
<div id="dashboard-insight-wrap" style="position:relative">
<div id="waste-chart-section" style="display:none"></div>
<div id="nutrition-section" style="display:none"></div>
<div id="monthly-stats-section" style="display:none"></div>
</div>
<!-- Alert for soonest expiring items -->
+12
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}
+12
View File
@@ -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"
}
}