diff --git a/assets/css/style.css b/assets/css/style.css index eafeb53..7cb544e 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -5631,6 +5631,248 @@ body.cooking-mode-active .app-header { transform: scale(0.92); } +/* ── Screensaver nutrition panel ── */ +.screensaver-nutrition { + display: none; + align-items: center; + justify-content: center; + max-width: 90vw; + pointer-events: none; + user-select: none; +} +.ss-nutr-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} +.ss-nutr-title { + color: rgba(255,255,255,0.75); + font-size: 1.3rem; + font-weight: 300; + letter-spacing: 1px; + text-align: center; +} +.ss-nutr-charts { + display: flex; + align-items: flex-start; + gap: 36px; + flex-wrap: wrap; + justify-content: center; +} +.ss-nutr-chart-block { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +.ss-nutr-chart-label { + color: rgba(255,255,255,0.5); + font-size: .85rem; + text-align: center; +} +/* 3D animated pie */ +.ss-pie3d { + width: 140px; + height: 140px; + border-radius: 50%; + background: var(--pie-bg, conic-gradient(#4ade80 0deg 90deg, #60a5fa 90deg 180deg, #fbbf24 180deg 270deg, #334155 270deg 360deg)); + transform: perspective(250px) rotateX(40deg) scale(0.82); + box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 4px 0 rgba(255,255,255,0.06); + transition: transform 0.8s cubic-bezier(.34,1.56,.64,1), box-shadow 0.8s; + will-change: transform; +} +.ss-pie3d.ss-pie3d-ready { + transform: perspective(250px) rotateX(30deg) scale(1); + animation: ss-pie-spin 18s linear infinite; +} +@keyframes ss-pie-spin { + from { transform: perspective(250px) rotateX(30deg) rotate(0deg) scale(1); } + to { transform: perspective(250px) rotateX(30deg) rotate(360deg) scale(1); } +} +.ss-nutr-legend { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 130px; +} +.ss-leg-row { + display: flex; + align-items: center; + gap: 6px; + color: rgba(255,255,255,0.65); + font-size: .8rem; +} +.ss-leg-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; +} +.ss-leg-pct { + margin-left: auto; + color: rgba(255,255,255,0.45); +} +/* Score donut column */ +.ss-nutr-scores-col { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; +} +.ss-donut-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} +.ss-donut-label { + color: rgba(255,255,255,0.45); + font-size: .75rem; +} +/* CSS-only ring donut using conic-gradient */ +.ss-donut-ring { + position: relative; + width: 72px; + height: 72px; + border-radius: 50%; + background: conic-gradient(var(--color, #4ade80) calc(var(--val, 0) * 1%), rgba(255,255,255,0.08) calc(var(--val, 0) * 1%)); + transform: perspective(120px) rotateX(30deg) scale(0.7); + box-shadow: 0 6px 16px rgba(0,0,0,0.5); + transition: transform 0.7s cubic-bezier(.34,1.56,.64,1), background 0.9s ease; + display: flex; + align-items: center; + justify-content: center; +} +.ss-donut-ring.ss-donut-ready { + transform: perspective(120px) rotateX(25deg) scale(1); + animation: ss-donut-bob 4s ease-in-out infinite; +} +@keyframes ss-donut-bob { + 0%,100% { transform: perspective(120px) rotateX(25deg) scale(1) translateY(0); } + 50% { transform: perspective(120px) rotateX(25deg) scale(1) translateY(-4px); } +} +.ss-donut-ring::after { + content: ''; + position: absolute; + width: 48px; + height: 48px; + border-radius: 50%; + background: #0a0a0a; +} +.ss-donut-text { + position: relative; + z-index: 1; + color: rgba(255,255,255,0.8); + font-size: .8rem; + font-weight: 600; +} + +/* ── Dashboard nutrition card ── */ +.nutr-card { + background: var(--bg-card, #fff); + border-radius: 14px; + padding: 16px; + box-shadow: var(--shadow); + margin-bottom: 16px; +} +.nutr-body { + display: flex; + gap: 16px; + align-items: flex-start; + margin: 12px 0; +} +.nutr-pie-wrap { + position: relative; + flex-shrink: 0; +} +.nutr-pie-3d { + width: 100px; + height: 100px; + border-radius: 50%; + transform: perspective(180px) rotateX(38deg) scale(0.82); + box-shadow: 0 8px 20px rgba(0,0,0,0.18); + transition: transform 0.7s cubic-bezier(.34,1.56,.64,1); + will-change: transform; +} +.nutr-pie-3d.nutr-pie-ready { + transform: perspective(180px) rotateX(28deg) scale(1); + animation: nutr-pie-spin 22s linear infinite; +} +@keyframes nutr-pie-spin { + from { transform: perspective(180px) rotateX(28deg) rotate(0deg) scale(1); } + to { transform: perspective(180px) rotateX(28deg) rotate(360deg) scale(1); } +} +.nutr-pie-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; +} +.nutr-pie-total { + font-size: 1.4rem; + font-weight: 700; + line-height: 1; +} +.nutr-pie-label { + font-size: .6rem; + color: var(--text-secondary); +} +.nutr-legend { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} +.nutr-leg-row { + display: flex; + align-items: center; + gap: 5px; + font-size: .78rem; +} +.nutr-leg-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.nutr-leg-pct { + margin-left: auto; + color: var(--text-secondary); + font-weight: 600; +} +/* Score bars */ +.nutr-scores { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; +} +.nutr-score-row { + display: flex; + align-items: center; + gap: 8px; + font-size: .78rem; +} +.nutr-score-label { flex: 0 0 80px; white-space: nowrap; } +.nutr-score-track { + flex: 1; + height: 7px; + border-radius: 4px; + background: rgba(0,0,0,0.07); + overflow: hidden; +} +.nutr-score-fill { + height: 100%; + border-radius: 4px; + width: 0%; + transition: width 1s ease; +} +.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; } + /* ===== SETUP WIZARD ===== */ .setup-wizard-content { max-width: 480px; diff --git a/assets/js/app.js b/assets/js/app.js index f4ae003..d38b660 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -2772,6 +2772,205 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60, } } +// ===== NUTRITION ANALYSIS SECTION ===== +// Alternates with waste-chart-section every hour (randomised offset) + +// Colour palette for pie slices (matches category colours) +const _NUTR_COLORS = { + 'frutta': '#4ade80', 'verdura': '#22d3ee', + 'carne': '#f87171', 'pesce': '#60a5fa', + 'latticini': '#fbbf24', 'pasta': '#a78bfa', + 'pane': '#fb923c', 'cereali': '#f472b6', + 'bevande': '#34d399', 'condimenti':'#94a3b8', + 'surgelati': '#818cf8', 'conserve': '#e879f9', + 'snack': '#fcd34d', 'altro': '#64748b', +}; + +let _nutriData = null; // cached result from last inventory fetch +let _insightFlipTimer = null; // setInterval handle for waste/nutrition alternation + +/** + * Compute nutrition-related metrics from the current inventory array. + * Returns null if not enough data. + */ +function _buildNutritionData(inventory) { + if (!inventory || inventory.length === 0) return null; + + // Category distribution (product count) + const catCounts = {}; + for (const item of inventory) { + const cat = mapToLocalCategory(item.category || '', item.name || ''); + catCounts[cat] = (catCounts[cat] || 0) + 1; + } + const total = Object.values(catCounts).reduce((s, v) => s + v, 0); + + // Sorted slices for pie + const slices = Object.entries(catCounts) + .sort((a, b) => b[1] - a[1]) + .map(([cat, count]) => ({ + cat, + count, + pct: Math.round(count / total * 100), + color: _NUTR_COLORS[cat] || '#64748b', + icon: CATEGORY_ICONS[cat] || '📦', + })); + + // Health score 0-100 based on category mix + // + points for fruit/veg/fish; - for snacks/sweets + const healthyCats = ['frutta','verdura','pesce','carne']; + const unhealthyCats = ['snack','bevande']; + const healthyCount = healthyCats.reduce((s, c) => s + (catCounts[c] || 0), 0); + const unhealthyCount= unhealthyCats.reduce((s, c) => s + (catCounts[c] || 0), 0); + const healthScore = Math.min(100, Math.max(0, + Math.round(50 + (healthyCount / Math.max(total, 1)) * 50 - (unhealthyCount / Math.max(total, 1)) * 30) + )); + + // Variety score: number of distinct categories / max(16) + const varietyScore = Math.min(100, Math.round(Object.keys(catCounts).length / 16 * 100)); + + // Freshness score: % products with expiry date set + const withExpiry = inventory.filter(i => i.expiry_date).length; + const freshnessScore = Math.round(withExpiry / Math.max(total, 1) * 100); + + // Balance: fraction of fresh (frigo+freezer) vs shelf-stable (dispensa) + const fresh = inventory.filter(i => i.location === 'frigo' || i.location === 'freezer').length; + const fresh_pct = Math.round(fresh / Math.max(total, 1) * 100); + + return { slices, total, healthScore, varietyScore, freshnessScore, fresh_pct }; +} + +/** + * Render the nutrition analysis card into #nutrition-section. + */ +function _renderNutritionSection(inventory) { + const section = document.getElementById('nutrition-section'); + if (!section) return; + const data = _buildNutritionData(inventory); + if (!data) { section.style.display = 'none'; return; } + _nutriData = data; + + const { slices, total, healthScore, varietyScore, freshnessScore, fresh_pct } = data; + const top5 = slices.slice(0, 5); + + // Build conic-gradient for pie + let deg = 0; + const stops = top5.map(s => { + const end = deg + s.pct * 3.6; + const stop = `${s.color} ${deg.toFixed(1)}deg ${end.toFixed(1)}deg`; + deg = end; + return stop; + }); + if (deg < 360) stops.push(`#334155 ${deg.toFixed(1)}deg 360deg`); + const gradient = `conic-gradient(from 0deg, ${stops.join(', ')})`; + + // Score colour + const scoreColor = healthScore >= 70 ? '#4ade80' : healthScore >= 45 ? '#fbbf24' : '#f87171'; + const scoreLabel = healthScore >= 70 ? '😄 Ottimo' : healthScore >= 45 ? '🙂 Discreto' : '😬 Migliorabile'; + + section.innerHTML = ` +
+
+
+ +

🥗 Analisi Alimentare

+
+ ${scoreLabel} +
+ +
+ +
+
+
+ ${total} + prodotti +
+
+ + +
+ ${top5.map(s => ` +
+ + ${s.icon} + ${s.cat} + ${s.pct}% +
`).join('')} +
+
+ + +
+ ${_nutrScoreBar('🌿 Salute', healthScore, '#4ade80')} + ${_nutrScoreBar('🎨 Varietà', varietyScore, '#60a5fa')} + ${_nutrScoreBar('❄️ Freschi', fresh_pct, '#22d3ee')} +
+ +
Basato su ${total} prodotti in dispensa · EverShelf
+
`; + + // Trigger pie animation after render + requestAnimationFrame(() => { + const pie = document.getElementById('nutr-pie'); + if (pie) setTimeout(() => pie.classList.add('nutr-pie-ready'), 60); + }); +} + +function _nutrScoreBar(label, val, color) { + return `
+ ${label} +
+
+
+ ${val}% +
`; +} + +/** + * 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' + +function _startInsightAlternation(inventory) { + 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'; + _applyInsightPhase(); + // Flip every hour + _insightFlipTimer = setInterval(() => { + _insightPhase = _insightPhase === 'waste' ? 'nutrition' : 'waste'; + _applyInsightPhase(); + }, 3_600_000); +} + +function _applyInsightPhase() { + const wasteEl = document.getElementById('waste-chart-section'); + const nutrEl = document.getElementById('nutrition-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'; }); + 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'; + requestAnimationFrame(() => { + wasteEl.style.opacity = '1'; + nutrEl.style.opacity = '1'; + // Animate score bars when nutrition becomes visible + if (showNutr) { + nutrEl.querySelectorAll('.nutr-score-fill').forEach(bar => { + bar.style.width = (bar.dataset.target || 0) + '%'; + }); + } + }); + }, 620); +} + // ===== DASHBOARD ===== async function loadDashboard() { try { @@ -2885,6 +3084,13 @@ async function loadDashboard() { ); _startAntiWasteAutoRefresh(); + // Nutrition section — built from the full inventory list + try { + const invForNutr = (await api('inventory_list')).inventory || []; + _renderNutritionSection(invForNutr); + } catch(_e) {} + _startInsightAlternation(); + // Opened (partially used products with known package capacity) const openedSection = document.getElementById('alert-opened'); const openedList = document.getElementById('opened-list'); @@ -11388,10 +11594,9 @@ function activateScreensaver() { requestAnimationFrame(() => overlay.classList.add('visible')); updateScreensaverClock(); _screensaverClockInterval = setInterval(updateScreensaverClock, 1000); - // Load data and start facts + // Load data and start fact/nutrition rotation loadScreensaverData().then(() => { - showNextScreensaverFact(); - _screensaverFactInterval = setInterval(showNextScreensaverFact, SCREENSAVER_FACT_DURATION); + _startScreensaverRotation(); }); } @@ -11427,6 +11632,11 @@ function dismissScreensaver(targetPage) { if (!_screensaverActive) return; clearInterval(_screensaverClockInterval); clearInterval(_screensaverFactInterval); + clearInterval(_ssRotationTimer); + const nutrEl = document.getElementById('screensaver-nutrition'); + if (nutrEl) { nutrEl.style.display = 'none'; nutrEl.innerHTML = ''; } + const factEl = document.getElementById('screensaver-fact'); + if (factEl) { factEl.classList.remove('visible'); } const overlay = document.getElementById('screensaver'); overlay.classList.remove('visible'); setTimeout(() => { @@ -11442,6 +11652,121 @@ function dismissScreensaver(targetPage) { }, 400); } +// Handle for screensaver rotation timer +let _ssRotationTimer = null; +let _ssSlot = 0; // 0=fact, 1=nutrition, 2=fact, 3=nutrition … + +/** + * Start the screensaver content rotation: + * Every SCREENSAVER_FACT_DURATION ms flip between fact text and nutrition charts. + */ +function _startScreensaverRotation() { + clearInterval(_ssRotationTimer); + _ssSlot = 0; + _showScreensaverSlot(0); + _screensaverFactInterval = _ssRotationTimer = setInterval(() => { + _ssSlot = (_ssSlot + 1) % 4; // 4 steps: fact, nutr, fact, nutr (with repeats for more facts) + _showScreensaverSlot(_ssSlot); + }, SCREENSAVER_FACT_DURATION); +} + +function _showScreensaverSlot(slot) { + const factEl = document.getElementById('screensaver-fact'); + const nutrEl = document.getElementById('screensaver-nutrition'); + if (!factEl || !nutrEl) return; + const showNutr = slot % 2 === 1; // odd slots = nutrition + // Fade out both + factEl.classList.remove('visible'); + nutrEl.style.opacity = '0'; + nutrEl.style.transition = 'opacity 1.5s ease'; + setTimeout(() => { + if (!_screensaverActive) return; + if (showNutr) { + factEl.style.display = 'none'; + nutrEl.style.display = 'flex'; + _renderScreensaverNutrition(); + requestAnimationFrame(() => { nutrEl.style.opacity = '1'; }); + } else { + nutrEl.style.display = 'none'; + factEl.style.display = ''; + factEl.textContent = generateScreensaverFact(); + requestAnimationFrame(() => { factEl.classList.add('visible'); }); + } + }, 1600); +} + +/** + * Render animated 3D-style pie charts inside the screensaver. + * Shows: category distribution, health score, freshness. + */ +function _renderScreensaverNutrition() { + const el = document.getElementById('screensaver-nutrition'); + if (!el) return; + // Use cached nutrition data from dashboard if available, else build from screensaver inventory + const inv = (_screensaverData && _screensaverData.inventory) || []; + const data = (_nutriData && _nutriData.slices) ? _nutriData : _buildNutritionData(inv); + if (!data) { el.style.display = 'none'; return; } + + const { slices, total, healthScore, varietyScore, freshnessScore, fresh_pct } = data; + const top4 = slices.slice(0, 4); + + // Build conic-gradient + let deg = 0; + const stops = top4.map(s => { + const end = deg + s.pct * 3.6; + const stop = `${s.color} ${deg.toFixed(1)}deg ${end.toFixed(1)}deg`; + deg = end; + return stop; + }); + if (deg < 360) stops.push(`rgba(255,255,255,0.08) ${deg.toFixed(1)}deg 360deg`); + const gradient = `conic-gradient(from 0deg, ${stops.join(', ')})`; + + // Three mini donut charts: categories, health, freshness + const healthColor = healthScore >= 70 ? '#4ade80' : healthScore >= 45 ? '#fbbf24' : '#f87171'; + const freshColor = freshnessScore >= 70 ? '#22d3ee' : freshnessScore >= 40 ? '#60a5fa' : '#94a3b8'; + const varColor = varietyScore >= 70 ? '#a78bfa' : varietyScore >= 40 ? '#fbbf24' : '#64748b'; + + el.innerHTML = ` +
+
🥗 La tua dispensa oggi
+
+ +
+
+
${total} prodotti
+
+ ${top4.map(s => `
${s.icon} ${s.cat}${s.pct}%
`).join('')} +
+
+ +
+ ${_ssDonut('❤️ Salute', healthScore, healthColor)} + ${_ssDonut('🎨 Varietà', varietyScore, varColor)} + ${_ssDonut('❄️ Freschi', fresh_pct, freshColor)} +
+
+
`; + + // Trigger animations + requestAnimationFrame(() => { + const pie = document.getElementById('ss-pie-main'); + if (pie) setTimeout(() => pie.classList.add('ss-pie3d-ready'), 80); + el.querySelectorAll('.ss-donut-ring').forEach(ring => { + const val = parseInt(ring.dataset.val || 0); + setTimeout(() => { ring.style.setProperty('--val', val); ring.classList.add('ss-donut-ready'); }, 200); + }); + }); +} + +function _ssDonut(label, val, color) { + return `
+
+ ${val}% +
+
${label}
+
`; +} + // Load all data needed for screensaver facts async function loadScreensaverData() { try { @@ -11699,49 +12024,40 @@ function generateScreensaverFact() { } // --- Time-of-day greetings & suggestions --- - facts.push(() => `${greeting}! Se vuoi che ti preparo una ricetta, tocca qui.`); - facts.push(() => `${greeting}! La tua dispensa è sotto controllo. 😊`); if (hour >= 6 && hour < 10) { - facts.push(() => `Buongiorno! Pronto per la colazione? ☕`); if (byCategory['pane']) facts.push(() => `Buongiorno! Hai del pane per la colazione. 🍞`); if (byCategory['latticini']) facts.push(() => `C'è del latte in frigo per il cappuccino? ☕🥛`); + if (byCategory['frutta']) facts.push(() => `Buongiorno! Una bella frutta fresca per iniziare bene. 🍎`); } if (hour >= 11 && hour < 14) { - facts.push(() => `È quasi ora di pranzo! Cosa cuciniamo? 🍽️`); if (byCategory['pasta']) facts.push(() => `Ora di pranzo… Un bel piatto di pasta? 🍝`); + if (byCategory['verdura']) facts.push(() => `Un'insalata fresca per pranzo? Hai ${byCategory['verdura'].length} verdure! 🥗`); } if (hour >= 17 && hour < 21) { - facts.push(() => `Buona sera! Hai pensato alla cena? 🍽️`); if (byCategory['carne']) facts.push(() => `Per cena potresti usare la carne che hai. 🥩`); if (byCategory['pesce']) facts.push(() => `Che ne dici di pesce per cena? 🐟`); + if (expiringThisWeek.length > 0) facts.push(() => `Hai ${expiringThisWeek.length} prodotti in scadenza questa settimana — usali stasera!`); } if (hour >= 21 || hour < 6) { - facts.push(() => `Buonanotte! Domani controlla le scadenze. 🌙`); + if (expiringSoon.length > 0) facts.push(() => `Buonanotte! Domani ricordati di usare: ${expiringSoon.slice(0,2).map(i=>i.name).join(', ')}.`); } // --- Weekly stats --- const recentIn = stats.recent_in || 0; const recentOut = stats.recent_out || 0; - if (recentIn > 0) { - facts.push(() => `Questa settimana hai aggiunto ${recentIn} prodotti.`); - } - if (recentOut > 0) { - facts.push(() => `Questa settimana hai consumato ${recentOut} prodotti.`); - } if (recentIn > 0 && recentOut > 0) { - facts.push(() => `Bilancio settimanale: +${recentIn} entrati, -${recentOut} usciti.`); + facts.push(() => `Bilancio settimana: +${recentIn} aggiunti, −${recentOut} consumati.`); + } else if (recentIn > 0) { + facts.push(() => `Questa settimana hai aggiunto ${recentIn} prodotti.`); + } else if (recentOut > 0) { + facts.push(() => `Questa settimana hai consumato ${recentOut} prodotti. Ottimo!`); } // --- Tips & curiosità (statici ma ruotano) --- - facts.push(() => `💡 Lo sapevi? I prodotti in freezer durano molto più a lungo della data di scadenza.`); + facts.push(() => `💡 I prodotti in freezer durano molto più a lungo della data di scadenza.`); facts.push(() => `💡 Il pane congelato mantiene la fragranza per settimane.`); - facts.push(() => `💡 Le uova si conservano fino a 3-4 settimane dopo la data preferita.`); - facts.push(() => `💡 Lo yogurt chiuso in frigo dura spesso 1-2 settimane oltre la scadenza.`); - facts.push(() => `💡 Per evitare sprechi, usa prima i prodotti con scadenza più vicina.`); + facts.push(() => `💡 Per evitare sprechi, usa prima i prodotti con scadenza più vicina (FIFO).`); facts.push(() => `💡 La carne in freezer può durare fino a 6 mesi senza problemi.`); - facts.push(() => `💡 Le verdure fresche durano di più se conservate nel cassetto del frigo.`); - facts.push(() => `💡 Controlla regolarmente la dispensa per evitare doppioni nella spesa.`); - facts.push(() => `💡 I latticini vanno conservati nella parte più fredda del frigo.`); facts.push(() => `💡 Non ricongelare mai un alimento già scongelato. Cucinalo subito!`); facts.push(() => `💡 Un frigo ordinato ti fa risparmiare tempo e denaro.`); facts.push(() => `💡 Le conserve aperte vanno in frigo e consumate in pochi giorni.`); diff --git a/index.html b/index.html index 2ef19fa..a955387 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,7 @@ EverShelf - + @@ -152,8 +152,11 @@
- - + +
+ + +