diff --git a/api/database.php b/api/database.php index 7805fd0..37cee13 100644 --- a/api/database.php +++ b/api/database.php @@ -277,6 +277,20 @@ function migrateDB(PDO $db): void { "); $db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))"); } + + // Add is_favorite column to recipes if missing (#124) + $recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name'); + if (!in_array('is_favorite', $recCols)) { + try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); } + catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; } + } + + // Add nutriments_json column to products if missing (#118) + $prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name'); + if (!in_array('nutriments_json', $prodCols2)) { + try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); } + catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; } + } } /** diff --git a/api/index.php b/api/index.php index a521176..c7053a2 100644 --- a/api/index.php +++ b/api/index.php @@ -922,6 +922,12 @@ try { case 'recipes_delete': recipesDelete($db); break; + case 'recipes_toggle_favorite': + recipeToggleFavorite($db); + break; + case 'macro_stats': + getMacroStats($db); + break; case 'chat_list': chatList($db); break; @@ -2140,7 +2146,7 @@ function stockForName(PDO $db): void { } function _offFetchProduct(string $barcode): ?array { - $fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores'; + $fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores,nutriments'; // Try candidate barcodes: given barcode + EAN-13 (UPC-A → prepend 0) $candidates = [$barcode]; @@ -2200,6 +2206,22 @@ function _offFetchProduct(string $barcode): ?array { $allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags'])); } + // Extract macronutrients per 100g (from OFF 'nutriments' field) + $nutriments = null; + if (!empty($p['nutriments']) && is_array($p['nutriments'])) { + $nm = $p['nutriments']; + $nutriments = [ + 'energy_kcal_100g' => isset($nm['energy-kcal_100g']) ? round((float)$nm['energy-kcal_100g'], 1) : (isset($nm['energy_100g']) ? round((float)$nm['energy_100g'] / 4.184, 1) : null), + 'proteins_100g' => isset($nm['proteins_100g']) ? round((float)$nm['proteins_100g'], 1) : null, + 'carbohydrates_100g' => isset($nm['carbohydrates_100g']) ? round((float)$nm['carbohydrates_100g'], 1) : null, + 'fat_100g' => isset($nm['fat_100g']) ? round((float)$nm['fat_100g'], 1) : null, + 'fiber_100g' => isset($nm['fiber_100g']) ? round((float)$nm['fiber_100g'], 1) : null, + 'salt_100g' => isset($nm['salt_100g']) ? round((float)$nm['salt_100g'], 1) : null, + ]; + // Only keep if at least one macro is present + if (!array_filter(array_values($nutriments))) $nutriments = null; + } + return [ 'name' => $name, 'brand' => $p['brands'] ?? '', @@ -2215,6 +2237,7 @@ function _offFetchProduct(string $barcode): ?array { 'ecoscore' => $p['ecoscore_grade'] ?? '', 'labels' => $p['labels'] ?? '', 'stores' => $p['stores'] ?? '', + 'nutriments' => $nutriments, ]; } } @@ -2380,28 +2403,31 @@ function saveProduct(PDO $db): void { $stmt = $db->prepare(" UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?, default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?, + nutriments_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=? "); + $nutriJson = isset($input['nutriments']) ? json_encode($input['nutriments']) : null; $stmt->execute([ $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, $input['notes'] ?? '', $input['barcode'] ?? null, $input['package_unit'] ?? '', - $shoppingName, $input['id'] + $shoppingName, $nutriJson, $input['id'] ]); echo json_encode(['success' => true, 'id' => $input['id']]); } else { // Insert new $stmt = $db->prepare(" - INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name, nutriments_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "); $barcode = !empty($input['barcode']) ? $input['barcode'] : null; + $nutriJson = isset($input['nutriments']) ? json_encode($input['nutriments']) : null; $stmt->execute([ $barcode, $input['name'], $input['brand'] ?? '', $input['category'] ?? '', $input['image_url'] ?? '', $input['unit'] ?? 'pz', $input['default_quantity'] ?? 1, - $input['notes'] ?? '', $input['package_unit'] ?? '', $shoppingName + $input['notes'] ?? '', $input['package_unit'] ?? '', $shoppingName, $nutriJson ]); echo json_encode(['success' => true, 'id' => $db->lastInsertId()]); } @@ -3739,6 +3765,31 @@ function getMonthlyStats(PDO $db): void { LIMIT 3 ")->fetchAll(PDO::FETCH_ASSOC); + // Estimated € value of wasted items this month (#117) + $wastedValueEur = 0.0; + if ($thisWaste > 0 && file_exists(PRICE_CACHE_PATH)) { + $priceCache = json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?: []; + $country = env('PRICE_COUNTRY', 'Italia'); + $wastedProds = $db->query(" + SELECT p.name, SUM(t.quantity) AS total_qty, p.unit + FROM transactions t + JOIN products p ON t.product_id = p.id + WHERE t.type = 'waste' AND t.undone = 0 + AND t.created_at >= '{$thisMonthStart}' + GROUP BY t.product_id + ")->fetchAll(PDO::FETCH_ASSOC); + foreach ($wastedProds as $wp) { + $key = _priceKey($wp['name'], $country); + if (isset($priceCache[$key]['unit_price']) && $priceCache[$key]['unit_price'] > 0) { + $unitPrice = (float)$priceCache[$key]['unit_price']; + $qty = (float)$wp['total_qty']; + // For weight/volume units treat qty as single-use events (transactions counted per action) + $wastedValueEur += $unitPrice * $qty; + } + } + $wastedValueEur = round($wastedValueEur, 2); + } + echo json_encode([ 'success' => true, 'month' => date('Y-m'), @@ -3746,6 +3797,7 @@ function getMonthlyStats(PDO $db): void { 'items_consumed_prev' => $prevOut, 'items_added' => $thisIn, 'items_wasted' => $thisWaste, + 'wasted_value_eur' => $wastedValueEur, 'top_categories' => $topCats, 'top_products' => array_map(fn($r) => [ 'name' => $r['name'], @@ -3754,6 +3806,104 @@ function getMonthlyStats(PDO $db): void { ]); } +// ===== MACRO STATS (#118) ===== +/** + * Aggregate macronutrients from current inventory. + * For products with barcode-fetched nutriments_json, uses real data. + * For products without, uses per-category static estimates (per 100g). + */ +function getMacroStats(PDO $db): void { + EverLog::debug('getMacroStats'); + + // Static per-category estimates (per 100g, rough averages) + $catDefaults = [ + 'frutta' => ['energy_kcal_100g' => 52, 'proteins_100g' => 0.7, 'carbohydrates_100g' => 12.0, 'fat_100g' => 0.3, 'fiber_100g' => 2.0], + 'verdura' => ['energy_kcal_100g' => 30, 'proteins_100g' => 2.0, 'carbohydrates_100g' => 5.0, 'fat_100g' => 0.2, 'fiber_100g' => 2.5], + 'carne' => ['energy_kcal_100g' => 200, 'proteins_100g' => 20.0,'carbohydrates_100g' => 0.0, 'fat_100g' => 13.0,'fiber_100g' => 0.0], + 'pesce' => ['energy_kcal_100g' => 130, 'proteins_100g' => 20.0,'carbohydrates_100g' => 0.0, 'fat_100g' => 5.0, 'fiber_100g' => 0.0], + 'latticini' => ['energy_kcal_100g' => 150, 'proteins_100g' => 8.0, 'carbohydrates_100g' => 5.0, 'fat_100g' => 8.0, 'fiber_100g' => 0.0], + 'pasta' => ['energy_kcal_100g' => 350, 'proteins_100g' => 12.0,'carbohydrates_100g' => 70.0, 'fat_100g' => 2.0, 'fiber_100g' => 3.0], + 'pane' => ['energy_kcal_100g' => 265, 'proteins_100g' => 9.0, 'carbohydrates_100g' => 50.0, 'fat_100g' => 3.0, 'fiber_100g' => 2.5], + 'cereali' => ['energy_kcal_100g' => 370, 'proteins_100g' => 10.0,'carbohydrates_100g' => 70.0, 'fat_100g' => 4.0, 'fiber_100g' => 6.0], + 'bevande' => ['energy_kcal_100g' => 40, 'proteins_100g' => 0.2, 'carbohydrates_100g' => 10.0, 'fat_100g' => 0.0, 'fiber_100g' => 0.0], + 'condimenti' => ['energy_kcal_100g' => 150, 'proteins_100g' => 1.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 10.0,'fiber_100g' => 0.5], + 'conserve' => ['energy_kcal_100g' => 80, 'proteins_100g' => 4.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 2.0, 'fiber_100g' => 2.0], + 'surgelati' => ['energy_kcal_100g' => 100, 'proteins_100g' => 8.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 3.0, 'fiber_100g' => 2.0], + 'snack' => ['energy_kcal_100g' => 480, 'proteins_100g' => 6.0, 'carbohydrates_100g' => 55.0, 'fat_100g' => 28.0,'fiber_100g' => 2.0], + 'altro' => ['energy_kcal_100g' => 150, 'proteins_100g' => 4.0, 'carbohydrates_100g' => 20.0, 'fat_100g' => 5.0, 'fiber_100g' => 1.5], + ]; + + $rows = $db->query(" + SELECT p.name, p.category, p.unit, p.default_quantity, p.nutriments_json, i.quantity + FROM inventory i + JOIN products p ON i.product_id = p.id + WHERE i.quantity > 0 + ")->fetchAll(PDO::FETCH_ASSOC); + + $totals = ['energy_kcal' => 0.0, 'proteins' => 0.0, 'carbohydrates' => 0.0, 'fat' => 0.0, 'fiber' => 0.0]; + $itemsWithData = 0; + $totalItems = count($rows); + + foreach ($rows as $row) { + $nm = null; + if (!empty($row['nutriments_json'])) { + $nm = json_decode($row['nutriments_json'], true); + } + + // Estimate grams in inventory for this row + $unit = $row['unit'] ?: 'pz'; + $qty = (float)$row['quantity']; + $defQty = (float)($row['default_quantity'] ?: 0); + $grams = 100; // default: assume 100g per item if no unit info + + if ($unit === 'g') $grams = $qty; + elseif ($unit === 'kg') $grams = $qty * 1000; + elseif ($unit === 'ml') $grams = $qty; // approx 1g/ml + elseif ($unit === 'l') $grams = $qty * 1000; + elseif (in_array($unit, ['pz','conf']) && $defQty >= 20) $grams = $qty * $defQty; + elseif (in_array($unit, ['pz','conf']) && $defQty > 0) $grams = $qty * $defQty; + + if ($grams <= 0) $grams = 100; + + // Use real nutriments if available, else fallback to category default + if ($nm && isset($nm['proteins_100g'])) { + $macro = $nm; + } else { + $cat = mb_strtolower(trim(_normalizeCat($row['category'] ?? 'altro'))); + $macro = $catDefaults[$cat] ?? $catDefaults['altro']; + } + + $factor = $grams / 100.0; + $totals['energy_kcal'] += ($macro['energy_kcal_100g'] ?? 0) * $factor; + $totals['proteins'] += ($macro['proteins_100g'] ?? 0) * $factor; + $totals['carbohydrates'] += ($macro['carbohydrates_100g'] ?? 0) * $factor; + $totals['fat'] += ($macro['fat_100g'] ?? 0) * $factor; + $totals['fiber'] += ($macro['fiber_100g'] ?? 0) * $factor; + if ($nm && isset($nm['proteins_100g'])) $itemsWithData++; + } + + // Round + foreach ($totals as $k => $v) $totals[$k] = round($v); + + // Macro ratio percentages (of kcal from P/C/F) + $pKcal = $totals['proteins'] * 4; + $cKcal = $totals['carbohydrates'] * 4; + $fKcal = $totals['fat'] * 9; + $sumKcal = max($pKcal + $cKcal + $fKcal, 1); + + echo json_encode([ + 'success' => true, + 'total_items' => $totalItems, + 'items_with_data' => $itemsWithData, + 'totals' => $totals, + 'ratios' => [ + 'proteins' => round($pKcal / $sumKcal * 100), + 'carbohydrates' => round($cKcal / $sumKcal * 100), + 'fat' => round($fKcal / $sumKcal * 100), + ], + ]); +} + // ===== RECENT & POPULAR PRODUCTS ===== function recentPopularProducts(PDO $db): void { EverLog::debug('recentPopularProducts'); @@ -9279,21 +9429,32 @@ function appSettingsSave(PDO $db): void { function recipesList(PDO $db): void { $limit = min(intval($_GET['limit'] ?? 60), 200); - $rows = $db->query("SELECT id, date, meal, recipe_json, created_at FROM recipes ORDER BY date DESC, created_at DESC LIMIT {$limit}")->fetchAll(); + $rows = $db->query("SELECT id, date, meal, recipe_json, created_at, is_favorite FROM recipes ORDER BY is_favorite DESC, date DESC, created_at DESC LIMIT {$limit}")->fetchAll(); EverLog::debug('recipesList'); $recipes = []; foreach ($rows as $row) { $recipes[] = [ - 'id' => $row['id'], - 'date' => $row['date'], - 'meal' => $row['meal'], - 'recipe' => json_decode($row['recipe_json'], true), - 'savedAt' => strtotime($row['created_at']) * 1000 + 'id' => $row['id'], + 'date' => $row['date'], + 'meal' => $row['meal'], + 'recipe' => json_decode($row['recipe_json'], true), + 'savedAt' => strtotime($row['created_at']) * 1000, + 'is_favorite' => (bool)$row['is_favorite'], ]; } echo json_encode(['success' => true, 'recipes' => $recipes]); } +function recipeToggleFavorite(PDO $db): void { + EverLog::info('recipeToggleFavorite'); + $input = json_decode(file_get_contents('php://input'), true); + $id = intval($input['id'] ?? 0); + if ($id <= 0) { echo json_encode(['error' => 'Invalid id']); return; } + $db->prepare("UPDATE recipes SET is_favorite = 1 - is_favorite WHERE id = ?")->execute([$id]); + $fav = (int)$db->query("SELECT is_favorite FROM recipes WHERE id = {$id}")->fetchColumn(); + echo json_encode(['success' => true, 'is_favorite' => (bool)$fav]); +} + function recipesSave(PDO $db): void { EverLog::info('recipesSave'); $input = json_decode(file_get_contents('php://input'), true); diff --git a/assets/css/style.css b/assets/css/style.css index d826a25..6618a48 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -6535,6 +6535,108 @@ body.cooking-mode-active .app-header { flex-wrap: wrap; } +/* ===== RECIPE FAVORITES (#124) ===== */ +.recipe-fav-badge { + margin-left: auto; + font-size: 1.1rem; + color: #f59e0b; + flex-shrink: 0; +} + +.recipe-archive-card-fav { + border-left: 3px solid #f59e0b; +} + +.btn-recipe-fav { + background: none; + border: none; + font-size: 1.4rem; + cursor: pointer; + color: var(--text-muted); + padding: 0 4px; + line-height: 1; + transition: color 0.2s, transform 0.15s; + margin-left: auto; +} + +.btn-recipe-fav:hover { color: #f59e0b; transform: scale(1.2); } +.btn-recipe-fav.active { color: #f59e0b; } + +/* ===== PORTION RESCALER (#123) ===== */ +.recipe-persons-ctrl { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 6px; +} + +.btn-persons-adj { + background: var(--bg-secondary, #1e2a3a); + border: 1px solid var(--border-color, #2a3a50); + color: var(--text-primary); + border-radius: 50%; + width: 22px; + height: 22px; + font-size: 1rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + transition: background 0.15s; +} + +.btn-persons-adj:hover { background: var(--accent, #6366f1); color: #fff; } + +/* ===== MACRONUTRIENT PANEL (#118) ===== */ +.macro-bars { + display: flex; + flex-direction: column; + gap: 8px; + margin: 12px 0 8px; +} + +.macro-row { + display: flex; + align-items: center; + gap: 8px; +} + +.macro-label { + font-size: 0.75rem; + color: var(--text-muted); + min-width: 70px; + flex-shrink: 0; +} + +.macro-bar-wrap { + flex: 1; + height: 8px; + background: var(--bg-secondary, #1e2a3a); + border-radius: 4px; + overflow: hidden; +} + +.macro-bar-fill { + height: 100%; + width: 0%; + border-radius: 4px; + transition: width 0.6s ease; +} + +.macro-val { + font-size: 0.72rem; + color: var(--text-primary); + text-align: right; + min-width: 80px; + flex-shrink: 0; +} + +.macro-val small { + color: var(--text-muted); +} + /* ===== SCREENSAVER ===== */ .screensaver-overlay { position: fixed; diff --git a/assets/js/app.js b/assets/js/app.js index da795d9..16dffd2 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -4411,8 +4411,14 @@ function _renderMonthlyStatsSection(data) { 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.items_wasted > 0) { + let wastedBadgeText = `${data.items_wasted}${t('stats_monthly.wasted')}`; + if (data.wasted_value_eur > 0) { + const sym = getSettings().price_currency === 'USD' ? '$' : (getSettings().price_currency === 'GBP' ? '£' : '€'); + wastedBadgeText = `${data.items_wasted}${t('stats_monthly.wasted')} · ${sym}${data.wasted_value_eur.toFixed(2)}`; + } + badges.push(`🗑️${wastedBadgeText}`); + } if (data.top_products?.length > 0) badges.push(`${escapeHtml(data.top_products[0].name)}${t('stats_monthly.top_used')}`); @@ -4449,11 +4455,59 @@ function _renderMonthlyStatsSection(data) { section.style.display = (_insightPhase === 'monthly') ? 'block' : 'none'; } +// ===== MACROS SECTION (#118) ===== +/** + * Render the macronutrient breakdown panel into #macros-section. + */ +function _renderMacrosSection(data) { + const section = document.getElementById('macros-section'); + if (!section) return; + if (!data || !data.success || data.total_items === 0) { + section.innerHTML = ''; + section.style.display = 'none'; + return; + } + + const { totals, ratios, total_items } = data; + const macros = [ + { key: 'carbohydrates', label: t('nutrition.macros_carbs'), color: '#a78bfa', value: totals.carbohydrates, unit: 'g', pct: ratios.carbohydrates }, + { key: 'fat', label: t('nutrition.macros_fat'), color: '#fbbf24', value: totals.fat, unit: 'g', pct: ratios.fat }, + { key: 'proteins', label: t('nutrition.macros_proteins'), color: '#4ade80', value: totals.proteins, unit: 'g', pct: ratios.proteins }, + { key: 'fiber', label: t('nutrition.macros_fiber'), color: '#34d399', value: totals.fiber, unit: 'g', pct: null }, + ]; + + const bars = macros.map(m => { + const barPct = m.pct !== null ? m.pct : Math.min(100, Math.round((m.value / Math.max(totals.carbohydrates + totals.fat + totals.proteins, 1)) * 100)); + return `
+ ${m.label} +
+
+
+ ${m.value.toLocaleString(_currentLang === 'de' ? 'de-DE' : 'it-IT')}${m.unit}${m.pct !== null ? ` (${m.pct}%)` : ''} +
`; + }).join(''); + + section.innerHTML = ` +
+
+
+ +

${t('nutrition.macros_title')}

+
+ ${totals.energy_kcal.toLocaleString()} kcal +
+
${bars}
+
${t('nutrition.macros_source').replace('{n}', total_items)}
+
`; + + section.style.display = (_insightPhase === 'macros') ? 'block' : 'none'; +} + /** * Start the waste ↔ nutrition ↔ monthly stats alternation on the dashboard. */ -let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly' -const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly']; +let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly' | 'macros' +const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly', 'macros']; function _startInsightAlternation() { clearInterval(_insightFlipTimer); @@ -4472,6 +4526,7 @@ function _applyInsightPhase() { const wasteEl = document.getElementById('waste-chart-section'); const nutrEl = document.getElementById('nutrition-section'); const monthlyEl = document.getElementById('monthly-stats-section'); + const macrosEl = document.getElementById('macros-section'); if (!wasteEl || !nutrEl) return; // Map of which panels actually have rendered content @@ -4479,6 +4534,7 @@ function _applyInsightPhase() { 'waste': wasteEl.innerHTML.trim() !== '', 'nutrition': nutrEl.innerHTML.trim() !== '', 'monthly': !!monthlyEl && monthlyEl.innerHTML.trim() !== '', + 'macros': !!macrosEl && macrosEl.innerHTML.trim() !== '', }; // If the intended phase has no content, advance to the next one that does @@ -4491,14 +4547,16 @@ function _applyInsightPhase() { const showWaste = phase === 'waste'; const showNutr = phase === 'nutrition'; const showMonthly = phase === 'monthly'; + const showMacros = phase === 'macros'; - // Fade-swap all three panels - const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : [])]; + // Fade-swap all four panels + const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : []), ...(macrosEl ? [macrosEl] : [])]; 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 (monthlyEl) monthlyEl.style.display = showMonthly ? 'block' : 'none'; + if (macrosEl) macrosEl.style.display = showMacros ? 'block' : 'none'; requestAnimationFrame(() => { els.forEach(el => { el.style.opacity = '1'; }); if (showNutr) { @@ -4512,6 +4570,12 @@ function _applyInsightPhase() { bar.style.width = (bar.dataset.target || 0) + '%'; }); } + if (showMacros && macrosEl) { + macrosEl.querySelectorAll('.macro-bar-fill').forEach(bar => { + bar.style.transition = 'width 0.6s ease'; + bar.style.width = (bar.dataset.target || 0) + '%'; + }); + } }); }, 620); } @@ -4629,10 +4693,11 @@ async function loadDashboard() { loadBannerAlerts(); // Anti-waste section + Nutrition section + Monthly stats: load in parallel - const [, invForNutr, monthlyData] = await Promise.all([ + const [, invForNutr, monthlyData, macroData] = await Promise.all([ _awLoadFacts(), api('inventory_list').then(d => d.inventory || []).catch(() => []), api('monthly_stats').catch(() => null), + api('macro_stats').catch(() => null), ]); _renderAntiWasteSection( statsData.used_30d || 0, statsData.wasted_30d || 0, @@ -4648,6 +4713,9 @@ async function loadDashboard() { // Monthly stats panel _renderMonthlyStatsSection(monthlyData); + // Macronutrient panel (#118) + _renderMacrosSection(macroData); + _startInsightAlternation(); // Opened (partially used products with known package capacity) @@ -12829,10 +12897,12 @@ async function loadRecipeArchive() { const tags = (r.tags || []).slice(0, 3).join(', '); // Find this entry's index in the flat archive array const archiveIdx = archive.indexOf(entry); - html += `
`; + const favBadge = entry.is_favorite ? `` : ''; + html += `
`; html += `
`; html += `${mealIcon}`; html += `${escapeHtml(r.title)}`; + html += favBadge; html += `
`; html += `
`; if (r.prep_time) html += `🔪 ${r.prep_time}`; @@ -12851,7 +12921,7 @@ async function loadRecipeArchive() { function viewArchivedRecipe(idx) { const entry = _recipeArchiveEntries[idx]; if (!entry) return; - _cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe }; + _cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe, id: entry.id, is_favorite: !!entry.is_favorite }; renderRecipe(entry.recipe); document.getElementById('recipe-overlay').style.display = 'flex'; document.getElementById('recipe-ask').style.display = 'none'; @@ -13333,6 +13403,55 @@ function _extractToolsFromSteps(steps) { return found; } +// ===== RECIPE FAVORITES & PORTION RESCALER ===== +let _recipeBasePersons = 1; +let _recipeCurrentPersons = 1; + +/** + * Toggle favorite status for the currently displayed archived recipe (#124). + */ +async function toggleRecipeFavorite(btn) { + if (!_cachedRecipe || !_cachedRecipe.id) return; + const res = await api('recipes_toggle_favorite', {}, 'POST', { id: _cachedRecipe.id }); + if (!res.success) return; + _cachedRecipe.is_favorite = res.is_favorite; + btn.classList.toggle('active', res.is_favorite); + btn.textContent = res.is_favorite ? '★' : '☆'; + btn.title = res.is_favorite ? t('recipes.unfavorite') : t('recipes.favorite'); + // Invalidate archive cache so the star shows on next open + _recipeArchiveCache = null; +} + +/** + * Scale recipe ingredient quantities (#123). + * Delta: +1 or -1. Min 1, max 20 persons. + */ +function adjustRecipePersons(delta) { + const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta)); + if (newPersons === _recipeCurrentPersons) return; + _recipeCurrentPersons = newPersons; + const display = document.getElementById('recipe-persons-display'); + if (display) display.textContent = `👥 ${newPersons} ${t('recipes.persons_short')}`; + + const ratio = _recipeBasePersons > 0 ? (newPersons / _recipeBasePersons) : 1; + document.querySelectorAll('#recipe-content .recipe-ingredient').forEach(li => { + const baseQty = parseFloat(li.dataset.baseQty || '0'); + const baseStr = li.dataset.baseQtyStr || ''; + const qtySpan = li.querySelector('.recipe-ing-qty'); + if (!qtySpan) return; + + if (baseQty > 0) { + // Extract unit suffix from baseStr: e.g. "200 g" → "g", "2 uova" → "uova" + const m = baseStr.match(/^(\d+(?:[.,]\d+)?)\s*(.*)/); + const unitSuffix = m ? m[2].trim() : ''; + const scaled = baseQty * ratio; + // Round sensibly: integers for whole counts, 1 decimal for fractional + const rounded = scaled < 10 ? (Math.round(scaled * 10) / 10) : Math.round(scaled); + qtySpan.textContent = unitSuffix ? `${rounded} ${unitSuffix}` : String(rounded); + } + }); +} + function renderRecipe(r) { // Reset regen choice panel (hide choice, show button) const regenChoice = document.getElementById('recipe-regen-choice'); @@ -13340,15 +13459,29 @@ function renderRecipe(r) { if (regenChoice) regenChoice.style.display = 'none'; if (regenBtn) regenBtn.style.display = ''; + // Store base persons for the rescaler (#123) + _recipeBasePersons = r.persons || 1; + _recipeCurrentPersons = _recipeBasePersons; + + const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite); + let html = `

${r.title}

`; - // Meta tags + // Meta tags + star (#124) + persons rescaler (#123) html += '
'; if (r.meal) html += `${_mealLabel(r.meal)}`; - html += `👥 ${r.persons} ${t('recipes.persons_short')}`; + html += ` + + 👥 ${r.persons} ${t('recipes.persons_short')} + + `; if (r.prep_time) html += `🔪 ${r.prep_time}`; if (r.cook_time) html += `🔥 ${r.cook_time}`; if (r.tags) r.tags.forEach(t => { html += `${t}`; }); + // Favorite star button (#124) — visible only for archived recipes (have an id) + if (_cachedRecipe && _cachedRecipe.id) { + html += ``; + } html += '
'; // Expiry note @@ -13371,8 +13504,8 @@ function renderRecipe(r) { const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10; const loc = (ing.location || 'dispensa').replace(/'/g, "\\'"); const alreadyUsed = ing.used === true; - html += `
  • `; - html += `${ing.name}${ing.brand ? ' (' + ing.brand + ')' : ''}: ${ing.qty} ✅`; + html += `
  • `; + html += `${ing.name}${ing.brand ? ' (' + ing.brand + ')' : ''}: ${ing.qty} ✅`; // Detail line: location + expiry let details = []; const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`])); @@ -13396,7 +13529,7 @@ function renderRecipe(r) { html += `
  • `; } else { const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒'; - html += `
  • ${ing.name}: ${ing.qty}${pantryIcon}
  • `; + html += `
  • ${ing.name}: ${ing.qty}${pantryIcon}
  • `; } }); html += ''; diff --git a/index.html b/index.html index 294cc7b..e6eca8a 100644 --- a/index.html +++ b/index.html @@ -174,6 +174,7 @@ +
    diff --git a/translations/de.json b/translations/de.json index fb78711..84f9e35 100644 --- a/translations/de.json +++ b/translations/de.json @@ -384,7 +384,10 @@ "scale_wait_stable": "10s stabiles Gewicht für Auto-Ausfüllen abwarten…", "ingredient_scaled_toast": "📦 Zutat vom Vorrat abgezogen!", "finished_added_bring_toast": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt!", - "load_error": "Fehler beim Laden" + "load_error": "Fehler beim Laden", + "favorite": "Zu Favoriten hinzufügen", + "unfavorite": "Aus Favoriten entfernen", + "adjust_persons": "Personen" }, "shopping": { "title": "🛒 Einkaufsliste", @@ -1247,7 +1250,13 @@ "source": "Basierend auf {n} Produkten in deiner Vorratskammer · EverShelf", "products_count": "Produkte", "today_title": "🥗 Deine Vorratskammer heute", - "products_n": "{n} Produkte" + "products_n": "{n} Produkte", + "macros_title": "Geschätzte Makronährstoffe", + "macros_proteins": "Proteine", + "macros_carbs": "Kohlenhydrate", + "macros_fat": "Fett", + "macros_fiber": "Ballaststoffe", + "macros_source": "Schätzung basierend auf {n} Vorratsprodukten" }, "facts": { "greeting_morning": "Guten Morgen", diff --git a/translations/en.json b/translations/en.json index 48a40a1..71152db 100644 --- a/translations/en.json +++ b/translations/en.json @@ -384,7 +384,10 @@ "scale_wait_stable": "Wait 10s of stable weight for auto-fill…", "ingredient_scaled_toast": "📦 Ingredient deducted from pantry!", "finished_added_bring_toast": "🛒 Finished product → added to Bring!", - "load_error": "Loading error" + "load_error": "Loading error", + "favorite": "Add to favourites", + "unfavorite": "Remove from favourites", + "adjust_persons": "Persons" }, "shopping": { "title": "🛒 Shopping List", @@ -1247,7 +1250,13 @@ "source": "Based on {n} products in your pantry · EverShelf", "products_count": "products", "today_title": "🥗 Your pantry today", - "products_n": "{n} products" + "products_n": "{n} products", + "macros_title": "Estimated Macronutrients", + "macros_proteins": "Proteins", + "macros_carbs": "Carbohydrates", + "macros_fat": "Fat", + "macros_fiber": "Fibre", + "macros_source": "Estimate based on {n} pantry products" }, "facts": { "greeting_morning": "Good morning", diff --git a/translations/es.json b/translations/es.json index 447b9c7..422e4c1 100644 --- a/translations/es.json +++ b/translations/es.json @@ -379,7 +379,10 @@ "scale_wait_stable": "Espera 10s de peso estable para el relleno automático…", "ingredient_scaled_toast": "📦 ¡Ingrediente deducido de la despensa!", "finished_added_bring_toast": "🛒 Producto terminado → ¡añadido a Bring!", - "load_error": "Error de carga" + "load_error": "Error de carga", + "favorite": "Añadir a favoritos", + "unfavorite": "Quitar de favoritos", + "adjust_persons": "Personas" }, "shopping": { "title": "🛒 Lista de la compra", @@ -1195,7 +1198,13 @@ "source": "Basado en {n} productos en tu despensa · EverShelf", "products_count": "productos", "today_title": "🥗 Tu despensa hoy", - "products_n": "{n} productos" + "products_n": "{n} productos", + "macros_title": "Macronutrientes estimados", + "macros_proteins": "Proteínas", + "macros_carbs": "Carbohidratos", + "macros_fat": "Grasas", + "macros_fiber": "Fibra", + "macros_source": "Estimación basada en {n} productos en despensa" }, "facts": { "greeting_morning": "Buenos días", diff --git a/translations/fr.json b/translations/fr.json index 0445551..34d0c7c 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -379,7 +379,10 @@ "scale_wait_stable": "Attendez 10s de poids stable pour le remplissage automatique…", "ingredient_scaled_toast": "📦 Ingrédient déduit du garde-manger !", "finished_added_bring_toast": "🛒 Produit terminé → ajouté à Bring !", - "load_error": "Erreur de chargement" + "load_error": "Erreur de chargement", + "favorite": "Ajouter aux favoris", + "unfavorite": "Retirer des favoris", + "adjust_persons": "Personnes" }, "shopping": { "title": "🛒 Liste de courses", @@ -1195,7 +1198,13 @@ "source": "Basé sur {n} produits dans votre garde-manger · EverShelf", "products_count": "produits", "today_title": "🥗 Votre garde-manger aujourd'hui", - "products_n": "{n} produits" + "products_n": "{n} produits", + "macros_title": "Macronutriments estimés", + "macros_proteins": "Protéines", + "macros_carbs": "Glucides", + "macros_fat": "Lipides", + "macros_fiber": "Fibres", + "macros_source": "Estimation basée sur {n} produits en stock" }, "facts": { "greeting_morning": "Bonjour", diff --git a/translations/it.json b/translations/it.json index 66731df..8f1c1ff 100644 --- a/translations/it.json +++ b/translations/it.json @@ -384,7 +384,10 @@ "scale_wait_stable": "Attendi 10s di stabilità per la compilazione automatica…", "ingredient_scaled_toast": "📦 Ingrediente scalato dalla dispensa!", "finished_added_bring_toast": "🛒 Prodotto finito → aggiunto a Bring!", - "load_error": "Errore nel caricamento" + "load_error": "Errore nel caricamento", + "favorite": "Aggiungi ai preferiti", + "unfavorite": "Rimuovi dai preferiti", + "adjust_persons": "Persone" }, "shopping": { "title": "🛒 Lista della Spesa", @@ -1258,7 +1261,13 @@ "source": "Basato su {n} prodotti in dispensa · EverShelf", "products_count": "prodotti", "today_title": "🥗 La tua dispensa oggi", - "products_n": "{n} prodotti" + "products_n": "{n} prodotti", + "macros_title": "Macronutrienti stimati", + "macros_proteins": "Proteine", + "macros_carbs": "Carboidrati", + "macros_fat": "Grassi", + "macros_fiber": "Fibre", + "macros_source": "Stima basata su {n} prodotti in dispensa" }, "facts": { "greeting_morning": "Buongiorno",