diff --git a/api/index.php b/api/index.php index 0492f2a..2104a27 100644 --- a/api/index.php +++ b/api/index.php @@ -1606,17 +1606,22 @@ function getStats(PDO $db): void { return $da <=> $db2; }); - // Waste vs consumption stats (last 30 days) - $wasteStats = $db->query(" - SELECT type, COUNT(*) as count + // Waste vs consumption trend (3 × 30-day buckets) + $wasteStats3m = $db->query(" + SELECT type, + SUM(CASE WHEN created_at >= datetime('now', '-30 days') THEN 1 ELSE 0 END) AS m0, + SUM(CASE WHEN created_at >= datetime('now', '-60 days') AND created_at < datetime('now', '-30 days') THEN 1 ELSE 0 END) AS m1, + SUM(CASE WHEN created_at >= datetime('now', '-90 days') AND created_at < datetime('now', '-60 days') THEN 1 ELSE 0 END) AS m2 FROM transactions - WHERE type IN ('out', 'waste') AND created_at >= datetime('now', '-30 days') + WHERE type IN ('out', 'waste') AND created_at >= datetime('now', '-90 days') GROUP BY type ")->fetchAll(); $used30 = 0; $wasted30 = 0; - foreach ($wasteStats as $ws) { - if ($ws['type'] === 'out') $used30 = (int)$ws['count']; - if ($ws['type'] === 'waste') $wasted30 = (int)$ws['count']; + $usedP30 = 0; $wastedP30 = 0; + $usedP60 = 0; $wastedP60 = 0; + foreach ($wasteStats3m as $ws) { + if ($ws['type'] === 'out') { $used30 = (int)$ws['m0']; $usedP30 = (int)$ws['m1']; $usedP60 = (int)$ws['m2']; } + if ($ws['type'] === 'waste') { $wasted30 = (int)$ws['m0']; $wastedP30 = (int)$ws['m1']; $wastedP60 = (int)$ws['m2']; } } echo json_encode([ @@ -1628,8 +1633,12 @@ function getStats(PDO $db): void { 'expiring_soon' => $expiring, 'expired' => $expired, 'opened' => $opened, - 'used_30d' => $used30, - 'wasted_30d' => $wasted30, + 'used_30d' => $used30, + 'wasted_30d' => $wasted30, + 'used_prev_30d' => $usedP30, + 'wasted_prev_30d' => $wastedP30, + 'used_prev_60d' => $usedP60, + 'wasted_prev_60d' => $wastedP60, ]); } diff --git a/assets/css/style.css b/assets/css/style.css index 74cb5d9..b29acfe 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -376,55 +376,196 @@ body { .btn-quick-recipe span:last-child { font-size: 1.1rem; opacity: 0.8; } .btn-quick-recipe:active { transform: scale(0.98); } -/* Waste chart section */ -.waste-chart-section { +/* ── Anti-Waste Report Card ─────────────────────────────── */ +#waste-chart-section { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; } -.waste-chart-section h3 { - font-size: 0.95rem; - margin-bottom: 10px; -} -.waste-chart-bar { - display: flex; - height: 24px; - border-radius: 12px; - overflow: hidden; - background: var(--bg-main); - margin-bottom: 8px; -} -.waste-bar-used { - background: var(--success); - transition: width 0.5s ease; - min-width: 0; -} -.waste-bar-wasted { - background: var(--danger); - transition: width 0.5s ease; - min-width: 0; -} -.waste-chart-legend { - display: flex; - justify-content: space-between; - font-size: 0.8rem; - color: var(--text-light); -} -.waste-legend-item { + +/* Header row: title + grade */ +.aw-header { display: flex; align-items: center; - gap: 4px; + justify-content: space-between; + margin-bottom: 12px; } -.waste-legend-dot { - width: 10px; - height: 10px; - border-radius: 50%; +.aw-title { + font-size: 0.95rem; + font-weight: 700; + margin: 0; + color: var(--text); +} +.aw-grade-wrap { + display: flex; + align-items: center; + gap: 6px; +} +.aw-grade-label { + font-size: 0.72rem; + color: var(--text-light); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.aw-grade { display: inline-block; + min-width: 36px; + text-align: center; + padding: 3px 8px; + border-radius: 999px; + font-size: 0.9rem; + font-weight: 900; + letter-spacing: -0.01em; + color: #fff; +} +.aw-grade-ap { background: #16a34a; } +.aw-grade-a { background: #22c55e; } +.aw-grade-b { background: #f59e0b; } +.aw-grade-c { background: #fb923c; } +.aw-grade-d { background: #dc2626; } + +/* Comparison bars */ +.aw-compare { display: flex; flex-direction: column; gap: 7px; margin-bottom: 10px; } +.aw-compare-row { display: flex; align-items: center; gap: 8px; } +.aw-compare-lbl { + min-width: 76px; + font-size: 0.75rem; + color: var(--text-light); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.aw-you-lbl { color: var(--success); font-weight: 700; } +.aw-bar-track { + flex: 1; + height: 12px; + border-radius: 6px; + background: var(--bg-main); + overflow: hidden; +} +.aw-bar-you { + height: 100%; + background: var(--success); + border-radius: 6px; + transition: width 0.6s cubic-bezier(.4,0,.2,1); + min-width: 2px; +} +.aw-bar-avg { + height: 100%; + background: #d1d5db; + border-radius: 6px; + transition: width 0.6s cubic-bezier(.4,0,.2,1); + min-width: 2px; +} +.aw-compare-pct { font-size: 0.78rem; font-weight: 700; color: var(--text-light); min-width: 30px; text-align: right; } +.aw-you-pct { color: var(--success); } + +/* Status message */ +.aw-status { + font-size: 0.8rem; + font-weight: 600; + padding: 6px 10px; + border-radius: var(--radius-sm); + margin-bottom: 10px; + border-left: 3px solid transparent; +} +.aw-status-good { background: #f0fdf4; border-color: var(--success); color: #166534; } +.aw-status-bad { background: #fef2f2; border-color: var(--danger); color: #991b1b; } +.aw-status-ok { background: #fffbeb; border-color: var(--warning); color: #92400e; } + +/* Savings badges */ +.aw-savings-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} +.aw-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.76rem; + font-weight: 600; + border: 1px solid transparent; +} +.aw-badge-money { background: #fef9c3; color: #854d0e; border-color: #fde047; } +.aw-badge-meals { background: #f0fdf4; color: #166534; border-color: #86efac; } +.aw-badge-co2 { background: #eff6ff; color: #1e40af; border-color: #93c5fd; } + +/* Trend mini chart */ +.aw-trend-section { margin-top: 2px; } +.aw-trend-title { + display: block; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-light); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 6px; +} +.aw-trend-bars { + display: flex; + gap: 10px; + align-items: flex-end; + height: 56px; +} +.aw-trend-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; +} +.aw-trend-col.aw-trend-empty { opacity: 0.35; } +.aw-trend-rate { + font-size: 0.72rem; + font-weight: 800; + line-height: 1; +} +.aw-rate-good { color: var(--success); } +.aw-rate-ok { color: var(--warning); } +.aw-rate-bad { color: var(--danger); } +.aw-trend-bar-wrap { + flex: 1; + width: 100%; + max-height: 32px; + display: flex; + align-items: flex-end; + border-radius: 4px; + overflow: hidden; + background: var(--bg-main); + min-height: 6px; +} +.aw-trend-bar-fill { + width: 100%; + border-radius: 4px; + transition: height 0.5s ease; +} +.aw-tbar-good { background: var(--success); } +.aw-tbar-ok { background: var(--warning); } +.aw-tbar-bad { background: var(--danger); } +.aw-trend-label { + font-size: 0.64rem; + color: var(--text-light); + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +/* Source footnote */ +.aw-source { + font-size: 0.62rem; + color: #9ca3af; + margin-top: 8px; + text-align: right; } -.waste-legend-dot.used { background: var(--success); } -.waste-legend-dot.wasted { background: var(--danger); } .alert-section h3 { font-size: 1.05rem; diff --git a/assets/js/app.js b/assets/js/app.js index 010a218..ad73f8f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2071,6 +2071,138 @@ function showPage(pageId, param = null) { window.scrollTo(0, 0); } +// ===== ANTI-WASTE SECTION ===== + +/** + * Benchmark data per language (data sources: REDUCE/Eurostat for IT/DE, USDA/NRDC for US). + * avgWasteRate: % of disposal events that end in waste (not consumed). + * avgKgMonth: kg wasted at household level per person per month. + * costPerKg: avg food cost per kg in the respective country (EUR/USD). + * KG_PER_EVENT: assumed avg weight per disposal transaction. + */ +const WASTE_BENCHMARKS = { + it: { avgWasteRate: 22, avgKgMonth: 5.4, costPerKg: 8.2, currency: '€', countryKey: 'antiwaste.country_it' }, + de: { avgWasteRate: 20, avgKgMonth: 6.5, costPerKg: 7.7, currency: '€', countryKey: 'antiwaste.country_de' }, + en: { avgWasteRate: 30, avgKgMonth: 9.2, costPerKg: 8.5, currency: '$', countryKey: 'antiwaste.country_en' }, +}; +const _AW_KG_PER_EVENT = 0.5; // estimated avg kg per out/waste transaction + +function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60, wastedP60) { + const section = document.getElementById('waste-chart-section'); + const total30 = used30 + wasted30; + if (total30 === 0) { section.style.display = 'none'; return; } + section.style.display = 'block'; + + const bm = WASTE_BENCHMARKS[_currentLang] || WASTE_BENCHMARKS['it']; + const country = t(bm.countryKey); + + // Waste rates (0–100 %) + const myRate = Math.round((wasted30 / total30) * 100); + const avgRate = bm.avgWasteRate; + + // Grade + let grade, gradeClass; + if (myRate <= 3) { grade = 'A+'; gradeClass = 'ap'; } + else if (myRate <= 8) { grade = 'A'; gradeClass = 'a'; } + else if (myRate <= 15) { grade = 'B'; gradeClass = 'b'; } + else if (myRate <= 25) { grade = 'C'; gradeClass = 'c'; } + else { grade = 'D'; gradeClass = 'd'; } + + // Estimated savings vs average person + const avgWastedEvents = total30 * (avgRate / 100); + const savedEvents = Math.max(0, avgWastedEvents - wasted30); + const savedKg = +(savedEvents * _AW_KG_PER_EVENT).toFixed(1); + const savedMoney = Math.round(savedKg * bm.costPerKg); + const savedMeals = Math.round(savedKg * 2.5); // ~400 g per meal + const savedCO2 = +(savedKg * 2.5).toFixed(1); // ~2.5 kg CO2 / kg food wasted + + // Status message + let statusMsg, statusCls; + if (myRate < avgRate) { + statusMsg = t('antiwaste.better').replace('{country}', country).replace('{diff}', avgRate - myRate); + statusCls = 'aw-status-good'; + } else if (myRate > avgRate) { + statusMsg = t('antiwaste.worse').replace('{country}', country); + statusCls = 'aw-status-bad'; + } else { + statusMsg = t('antiwaste.on_par').replace('{country}', country); + statusCls = 'aw-status-ok'; + } + + // Comparison bars (scaled to max of the two rates, min 5 for visual) + const maxRate = Math.max(myRate, avgRate, 5); + const myBarPct = Math.round((myRate / maxRate) * 100); + const avgBarPct = Math.round((avgRate / maxRate) * 100); + + // Trend (3 monthly buckets: m2=oldest … m0=current) + const totals = [usedP60 + wastedP60, usedP30 + wastedP30, total30]; + const rates60 = totals.map((tot, i) => { + const w = [wastedP60, wastedP30, wasted30][i]; + return tot > 0 ? Math.round((w / tot) * 100) : null; + }); + const trendLabels = [t('antiwaste.months_ago_2'), t('antiwaste.months_ago_1'), t('antiwaste.this_month')]; + const maxTrend = Math.max(...rates60.filter(r => r !== null), 5); + const hasTrendData = rates60[0] !== null || rates60[1] !== null; + + const trendBars = rates60.map((rate, i) => { + if (rate === null) { + return `