diff --git a/api/database.php b/api/database.php index 565a678..576052a 100644 --- a/api/database.php +++ b/api/database.php @@ -308,7 +308,12 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc if (preg_match('/\bpane\b/', $n)) return 4; // Specific jarred tomato sauce in pantry (opened, not refrigerated) if (preg_match('/salsa\s+di\s+(pomodoro|pronta)/', $n)) return 5; - return 60; // generic pantry fallback (was 30, doubled) + // Dairy opened outside fridge: bad very quickly at room temperature + if (preg_match('/\bpanna\b/', $n)) return 3; + if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2; + if (preg_match('/\blatte\b/', $n)) return 1; + if (preg_match('/\bformaggio\b/', $n)) return 2; + return 60; // generic pantry fallback } // ── F: Fridge — short-life perishables ────────────────────────────── @@ -317,7 +322,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc // Long-life mountain/brand milks stored in pantry before use (UHT) if (preg_match('/latte.*(montagna|alta\s+qual|parmalat|granarolo|esselunga|conservaz|microfiltrat)/i', $n)) return 7; if (preg_match('/\blatte\b/', $n)) return 4; - if (preg_match('/\byogurt\b/', $n)) return 5; + if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5; if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3; if (preg_match('/philadelphia|spalmabile/', $n)) return 7; if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5; diff --git a/api/index.php b/api/index.php index 1e301a8..add5a45 100644 --- a/api/index.php +++ b/api/index.php @@ -2305,7 +2305,11 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location // Reject AI values if they are suspiciously low compared to the rule-based estimate // (protects against Gemini hallucinations like "1 day for butter"). $ruleMin = estimateOpenedExpiryDaysPHP($name, $category, $location); - if ($parsed > 0 && $parsed <= 3650 && $parsed >= max(1, (int)floor($ruleMin * 0.5))) { + // Accept AI value only if within a reasonable multiple of the rule estimate. + // Upper bound: 4× rule (or 30 days minimum ceiling) — blocks Gemini hallucinations + // like "60 days for yogurt" (rule=5 → max allowed = 20). + $aiMax = max($ruleMin * 4, 30); + if ($parsed > 0 && $parsed <= $aiMax && $parsed >= max(1, (int)floor($ruleMin * 0.5))) { $days = $parsed; } } @@ -5356,6 +5360,87 @@ function smartShopping(PDO $db): void { continue; } + // --- Suggested purchase quantity (based on 14-day consumption) --- + // Rules: + // unit='conf' → conf count from dailyRate directly + // unit=g/ml/pz + package_unit non-empty → # confezioni (definitive) + // unit=g/ml + defQty > 0 (no pkg_unit) → round to nearest defQty multiple (approx) + // unit=g/ml, no defQty, no pkg_unit → raw amount, rounded to sensible step + // unit=pz, no pkg_unit → raw pz count (approx) + // dailyRate=0 → null (no data) + $suggestedQty = null; + $suggestedUnit = $unit; + $suggestedApprox = false; // true = show "almeno" in badge + + $pkgUnit = trim($p['package_unit'] ?? ''); // non-empty only when user set a real package + + if ($dailyRate > 0) { + $need14 = $dailyRate * 14; + + if ($unit === 'conf') { + // dailyRate already in conf/day + $suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3))); + $suggestedUnit = 'conf'; + + } elseif ($pkgUnit !== '' && $defQty > 0) { + // Real package info available → express in confezioni (definitive) + $pkgs = (int) max(1, min(20, (int)($need14 / $defQty + 0.3))); + $suggestedQty = $pkgs; + $suggestedUnit = 'conf'; + + } elseif (($unit === 'g' || $unit === 'ml') && $defQty > 0) { + // defQty known but no pkg_unit (e.g. Pomodorini 400g, Salame 100g) → + // use defQty as the minimum purchase unit and round to nearest multiple. + // This ensures we never suggest less than one "reference pack". + $pkgs = (int) max(1, (int)($need14 / $defQty + 0.3)); + $pkgs = min(20, $pkgs); + $suggestedQty = $pkgs * (int)$defQty; + $suggestedUnit = $unit; + $suggestedApprox = true; // always "almeno" — no confirmed pkg size + + } elseif ($unit === 'g' || $unit === 'ml') { + // No reference at all → raw amount, approximate + // Skip if consumption is negligible (< 30 units/14gg) + if ($need14 >= 30) { + if ($need14 < 500) { + $rounded = (int) max(100, round($need14 / 100) * 100); + } elseif ($need14 < 2000) { + $rounded = (int) max(250, round($need14 / 250) * 250); + } else { + $rounded = (int) max(500, round($need14 / 500) * 500); + } + $suggestedQty = $rounded; + $suggestedUnit = $unit; + $suggestedApprox = true; + } + + } elseif ($unit === 'pz') { + // No package info → raw pz count, approximate + $suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3))); + $suggestedUnit = 'pz'; + $suggestedApprox = ($suggestedQty > 1); + } + } + + // If stock is still >50% just suggest the minimum sensible purchase (don't over-stock) + if ($suggestedQty !== null && $pctLeft > 50) { + if ($suggestedUnit === 'conf') { + $suggestedQty = 1; + $suggestedApprox = false; + } elseif ($suggestedUnit === 'pz') { + $suggestedQty = 1; + $suggestedApprox = false; + } else { + // g/ml with >50% stock: suggest minimum reference pack or skip + if ($defQty > 0) { + $suggestedQty = (int)$defQty; + $suggestedApprox = true; + } else { + $suggestedQty = null; + } + } + } + $items[] = [ 'product_id' => $pid, 'name' => $p['name'], @@ -5382,6 +5467,9 @@ function smartShopping(PDO $db): void { 'on_bring' => $onBring, 'locations' => $inv ? $inv['locations'] : '', 'variants' => [], + 'suggested_qty' => $suggestedQty, // null = no badge + 'suggested_unit' => $suggestedUnit, + 'suggested_approx' => $suggestedApprox, // true = show "almeno" prefix ]; } @@ -5425,7 +5513,7 @@ function smartShopping(PDO $db): void { } function bringSuggestItems(PDO $db): void { - // Offline: derive suggestions from smart shopping cache (no AI needed) + $apiKey = env('GEMINI_API_KEY'); // 1. Load smart shopping data from cache or compute fresh $cacheFile = __DIR__ . '/../data/smart_shopping_cache.json'; @@ -5450,13 +5538,36 @@ function bringSuggestItems(PDO $db): void { // 2. Get Bring! listUUID for response $listUUID = ''; $auth = bringAuth(); - if ($auth) { - $listUUID = $auth['bringListUUID'] ?? ''; - } + if ($auth) $listUUID = $auth['bringListUUID'] ?? ''; // 3. Convert smart shopping items → suggestions (alta/media priority only, skip on_bring) $suggestions = []; - $seasonalTips = [ + $knownNames = []; // names already in suggestion list (to deduplicate AI output) + + foreach ($smartItems as $item) { + if ($item['on_bring'] ?? false) continue; + $urgency = $item['urgency'] ?? 'low'; + if ($urgency === 'low') continue; + + $priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media'; + $reasons = $item['reasons'] ?? []; + $reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse'; + + $suggestions[] = [ + 'name' => $item['name'], + 'specification' => '', + 'reason' => $reason, + 'category' => $item['category'] ?: 'altro', + 'priority' => $priority, + 'source' => 'stock', + ]; + $knownNames[] = mb_strtolower($item['name']); + + if (count($suggestions) >= 15) break; + } + + // 4. Seasonal tip (fallback static, overridden by Gemini below) + $monthTips = [ 1 => 'Gennaio: arance, mandarini, kiwi, carciofi e verze sono di stagione.', 2 => 'Febbraio: radicchio, finocchi, pere e agrumi da non perdere.', 3 => 'Marzo: arrivano gli asparagi! Ottimo anche con piselli freschi e spinaci.', @@ -5470,27 +5581,97 @@ function bringSuggestItems(PDO $db): void { 11 => 'Novembre: cachi, melograni, cavoli, broccoli e radicchio tardivo.', 12 => 'Dicembre: arance, mandarini, cachi, verze e cavolfiori.', ]; - $seasonalTip = $seasonalTips[(int)date('n')] ?? ''; + $seasonalTip = $monthTips[(int)date('n')] ?? ''; - foreach ($smartItems as $item) { - if ($item['on_bring'] ?? false) continue; // already on shopping list + // 5. Try to enrich with Gemini: generate ADDITIONAL seasonal / complementary suggestions + if (!empty($apiKey)) { + // Cache key: month + list of known names (so it refreshes each month) + $gemCacheFile = __DIR__ . '/../data/food_facts_cache.json'; + $gemCache = file_exists($gemCacheFile) ? (json_decode(file_get_contents($gemCacheFile), true) ?: []) : []; + $gemCacheKey = 'suggest_ai_' . date('Y-m') . '_' . md5(implode('|', $knownNames)); - $urgency = $item['urgency'] ?? 'low'; - if ($urgency === 'low') continue; // not urgent enough to suggest + // Cache valid for 6 hours + $cached = $gemCache[$gemCacheKey] ?? null; + $cacheTs = $gemCache[$gemCacheKey . '_ts'] ?? 0; + $cacheValid = $cached && (time() - $cacheTs < 21600); - $priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media'; - $reasons = $item['reasons'] ?? []; - $reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse'; + if ($cacheValid) { + $aiResult = $cached; + } else { + // Build inventory snapshot for Gemini (what the user already has) + $inStockNames = array_map(fn($i) => $i['name'], array_filter($smartItems, fn($i) => ($i['current_qty'] ?? 0) > 0)); + $dietary = trim(env('DIETARY') ?? ''); + $monthName = [1=>'Gennaio',2=>'Febbraio',3=>'Marzo',4=>'Aprile',5=>'Maggio',6=>'Giugno', + 7=>'Luglio',8=>'Agosto',9=>'Settembre',10=>'Ottobre',11=>'Novembre',12=>'Dicembre'][(int)date('n')]; + $inStockJson = json_encode(array_values(array_slice($inStockNames, 0, 40)), JSON_UNESCAPED_UNICODE); + $alreadyJson = json_encode(array_values($knownNames), JSON_UNESCAPED_UNICODE); + $dietaryLine = $dietary ? "- Dietary preferences: {$dietary}" : ''; - $suggestions[] = [ - 'name' => $item['name'], - 'specification' => '', - 'reason' => $reason, - 'category' => $item['category'] ?: 'altro', - 'priority' => $priority, - ]; + $prompt = "You are a helpful Italian household shopping assistant.\n" + . "Today is {$monthName} " . date('Y') . ".\n" + . "The user already has these products in stock: {$inStockJson}\n" + . "The following products are already in the shopping list: {$alreadyJson}\n" + . ($dietaryLine ? $dietaryLine . "\n" : '') + . "\nTask: suggest 3 to 6 additional products the user should buy this month.\n" + . "Focus on:\n" + . " a) Seasonal Italian fruits and vegetables for {$monthName}\n" + . " b) Complementary staples that pair well with what the user has\n" + . " c) Anything commonly forgotten but regularly needed\n" + . "Do NOT suggest products already in stock or already in the shopping list.\n" + . "Also write one short seasonal tip (max 15 words) in Italian.\n" + . "\nReply ONLY with valid JSON in this exact format (no markdown):\n" + . "{\"seasonal_tip\":\"...\",\"suggestions\":[{\"name\":\"...\",\"reason\":\"...\",\"category\":\"...\",\"priority\":\"bassa\"}]}\n" + . "Category must be one of: frutta,verdura,latticini,carne,pesce,pane,cereali,condimenti,bevande,surgelati,altro\n" + . "Priority must be: bassa\n" + . "Name and reason must be in Italian. Reason max 8 words."; - if (count($suggestions) >= 12) break; + $payload = ['contents' => [['parts' => [['text' => $prompt]]]]]; + $gemResult = callGeminiWithFallback($apiKey, $payload, 20); + + $aiResult = null; + if ($gemResult['http_code'] === 200) { + $text = $gemResult['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''; + $text = preg_replace('/^```json\s*/i', '', trim($text)); + $text = preg_replace('/\s*```$/i', '', $text); + $parsed = json_decode(trim($text), true); + if (is_array($parsed) && isset($parsed['suggestions'])) { + $aiResult = $parsed; + // Cache result + $gemCache[$gemCacheKey] = $aiResult; + $gemCache[$gemCacheKey . '_ts'] = time(); + file_put_contents($gemCacheFile, json_encode($gemCache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + } + } + } + + if ($aiResult) { + // Override seasonal tip with AI-generated one + if (!empty($aiResult['seasonal_tip'])) { + $seasonalTip = $aiResult['seasonal_tip']; + } + // Append AI suggestions (deduplicate against stock-based ones) + foreach ($aiResult['suggestions'] ?? [] as $ai) { + $aiName = mb_strtolower(trim($ai['name'] ?? '')); + if (!$aiName) continue; + // Skip if already in list (first-token check) + $aiFirst = explode(' ', $aiName)[0]; + $isDup = false; + foreach ($knownNames as $kn) { + if (str_starts_with($kn, $aiFirst)) { $isDup = true; break; } + } + if ($isDup) continue; + + $suggestions[] = [ + 'name' => ucfirst(trim($ai['name'])), + 'specification' => '', + 'reason' => trim($ai['reason'] ?? 'Stagionale'), + 'category' => $ai['category'] ?? 'altro', + 'priority' => 'bassa', + 'source' => 'ai', + ]; + $knownNames[] = $aiName; + } + } } echo json_encode([ diff --git a/assets/css/style.css b/assets/css/style.css index 0ea46e2..e79042a 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -294,7 +294,9 @@ body { .scale-icon-emoji { font-size: 20px; line-height: 1; - transition: filter 0.3s, opacity 0.3s; + /* Force white icon regardless of connection status — only the dot changes color */ + filter: brightness(0) invert(1); + opacity: 0.9; } .scale-status-dot { position: absolute; @@ -310,13 +312,10 @@ body { } /* Connected: white fill + bright-green border ring — clearly visible on any dark/green bg */ .scale-status-connected .scale-status-dot { background: #ffffff; border-color: #4ade80; box-shadow: 0 0 0 1px rgba(0,0,0,0.25), 0 0 8px #4ade80cc, 0 0 2px #fff; } -.scale-status-connected .scale-icon-emoji { filter: none; opacity: 1; } .scale-status-searching .scale-status-dot { background: #f59e0b; border-color: rgba(0,0,0,0.35); animation: scaleStatusPulse 1.4s infinite; } -.scale-status-searching .scale-icon-emoji { filter: none; opacity: 0.85; } .scale-status-disconnected .scale-status-dot { background: #64748b; border-color: rgba(0,0,0,0.35); } -.scale-status-disconnected .scale-icon-emoji { filter: grayscale(0.5); opacity: 0.55; } +.scale-status-disconnected .scale-icon-emoji { opacity: 0.55; } .scale-status-error .scale-status-dot { background: #ef4444; border-color: rgba(0,0,0,0.35); box-shadow: 0 0 5px #ef4444aa; } -.scale-status-error .scale-icon-emoji { filter: none; opacity: 1; } @keyframes scaleStatusPulse { 0%, 100% { box-shadow: 0 0 3px #f59e0b88; } 50% { box-shadow: 0 0 9px #f59e0bcc; } @@ -2194,6 +2193,11 @@ body { background: #ecfdf5; color: #059669; } +.priority-ai { + background: #f0f4ff; + color: #4338ca; + font-style: italic; +} .suggestion-actions { margin-top: 12px; @@ -2389,6 +2393,18 @@ body { color: #15803d; } +.smart-freq-badge.freq-suggest { + background: #e0f2fe; + color: #0369a1; + font-weight: 600; +} +.smart-freq-badge.freq-suggest-approx { + background: #f0f9ff; + color: #0284c7; + font-weight: 500; + font-style: italic; +} + .smart-pred-badge { background: #fefce8; color: #a16207; @@ -4915,6 +4931,12 @@ body.cooking-mode-active .app-header { } .banner-anomaly .alert-banner-title { color: #9a3412; } .banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; } +.alert-banner.banner-no-expiry { + background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%); + border-color: #16a34a; +} +.banner-no-expiry .alert-banner-title { color: #14532d; } +.banner-no-expiry .alert-banner-counter .banner-dot.active { background: #16a34a; } .alert-banner-inner { display: flex; align-items: flex-start; diff --git a/assets/js/app.js b/assets/js/app.js index c256b38..6d2f943 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1234,7 +1234,7 @@ function mapToLocalCategory(ofCategory, productName) { if (/sweetener|dolcific/.test(cat)) return 'condimenti'; // Specific tag patterns - if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte/.test(cat)) return 'latticini'; + if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte|egg|uova|uovo|poultry-egg/.test(cat)) return 'latticini'; if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne'; if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce'; if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta'; @@ -1263,20 +1263,20 @@ function guessCategoryFromName(name) { if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane'; // Conserve if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve'; - // Condimenti - if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa/.test(n)) return 'condimenti'; + // Condimenti (include spezie, farine, zucchero) + if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola/.test(n)) return 'condimenti'; // Bevande if (/acqua|birra|vino|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|latte\b/.test(n)) return 'bevande'; - // Latticini - if (/latte\b|yogurt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b/.test(n)) return 'latticini'; - // Carne - if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck/.test(n)) return 'carne'; + // Latticini (include eggs/uova) + if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg/.test(n)) return 'latticini'; + // Carne (include salumi) + if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia/.test(n)) return 'carne'; // Pesce - if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe/.test(n)) return 'pesce'; + if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi/.test(n)) return 'pesce'; // Frutta - if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta/.test(n)) return 'frutta'; + if (/mela|mele|banana|arancia|pera|fragola|uva|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca|nettarina|fico|melograno/.test(n)) return 'frutta'; // Verdura - if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata/.test(n)) return 'verdura'; + if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais|zucca|aglio/.test(n)) return 'verdura'; // Surgelati if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati'; // Snack @@ -1636,6 +1636,11 @@ function estimateOpenedExpiryDays(product, location) { if (/\b(confettura|marmellata)\b/.test(name)) return 90; if (/\b(nutella|cioccolat)\b/.test(name)) return 90; if (/\bpane\b/.test(name)) return 4; + // Dairy opened outside fridge: spoils very quickly at room temperature + if (/\bpanna\b/.test(name)) return 3; + if (/\b(yogurt|yaourt|yoghurt)\b/.test(name)) return 2; + if (/\blatte\b/.test(name)) return 1; + if (/\bformaggio\b/.test(name)) return 2; return 60; } @@ -1644,7 +1649,7 @@ function estimateOpenedExpiryDays(product, location) { // Long-life mountain/brand milks stored in pantry before use (UHT) if (/latte.*(montagna|alta\s+qual|parmalat|granarolo|esselunga|conservaz|microfiltrat)/i.test(name)) return 7; if (/\blatte\b/.test(name)) return 4; - if (/\byogurt\b/.test(name)) return 5; + if (/\b(yogurt|yaourt|yoghurt)\b/.test(name)) return 5; if (/mozzarella|burrata|stracciatella/.test(name)) return 3; if (/philadelphia|spalmabile/.test(name)) return 7; if (/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/.test(name)) return 5; @@ -2744,7 +2749,8 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60, const section = document.getElementById('waste-chart-section'); const total30 = used30 + wasted30; if (total30 === 0) { section.style.display = 'none'; return; } - section.style.display = 'block'; + // Show only if the alternation phase allows it (or before alternation starts) + section.style.display = (!_insightPhase || _insightPhase === 'waste') ? 'block' : 'none'; const bm = WASTE_BENCHMARKS[_currentLang] || WASTE_BENCHMARKS['it']; const country = t(bm.countryKey); @@ -3247,28 +3253,36 @@ async function loadDashboard() { if (item.unit === 'conf') { const pkgUnit = item.package_unit; - const pkgLabel = unitLabels[pkgUnit] || pkgUnit; + const pkgLabel = (pkgUnit && pkgUnit !== '') ? (unitLabels[pkgUnit] || pkgUnit) : ''; const wholeConf = Math.floor(qty + 0.001); const frac = Math.round((qty - wholeConf) * 1000) / 1000; - const remainderAmt = frac * pkgSize; - const remainderText = formatSubRemainder(remainderAmt, pkgUnit); - if (wholeConf > 0 && remainderAmt >= 1) { - qtyText = `${wholeConf} conf (da ${pkgSize}${pkgLabel}) + ${remainderText}`; + const remainderAmt = pkgSize > 0 ? frac * pkgSize : 0; + // Only show remainder if it rounds to at least 1 unit + const remainderText = remainderAmt >= 0.5 ? formatSubRemainder(remainderAmt, pkgUnit) : ''; + if (wholeConf > 0 && remainderText) { + qtyText = `${wholeConf} conf${pkgLabel ? ` (da ${pkgSize}${pkgLabel})` : ''} + ${remainderText}`; } else if (wholeConf > 0) { - qtyText = `${wholeConf} conf (da ${pkgSize}${pkgLabel})`; + qtyText = `${wholeConf} conf${pkgLabel ? ` (da ${pkgSize}${pkgLabel})` : ''}`; + } else if (remainderText) { + qtyText = remainderAmt >= 1 ? remainderText : t('inventory.qty_trace') || '< 1' + (pkgLabel || ''); } else { - qtyText = remainderText; + qtyText = `${qty} conf`; } } else { const unitLabel = unitLabels[item.unit] || item.unit || ''; - const wholePackages = Math.floor(qty / pkgSize + 0.001); - const remainder = Math.round((qty - wholePackages * pkgSize) * 100) / 100; - if (wholePackages > 0 && remainder > 0.01) { - qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} ${t('inventory.qty_remainder_suffix')}`; - } else if (remainder > 0.01) { - qtyText = `${Math.round(remainder)}${unitLabel} / ${pkgSize}${unitLabel}`; - } else { + if (!pkgSize || pkgSize <= 0) { + // No package size — just show raw quantity qtyText = `${qty}${unitLabel}`; + } else { + const wholePackages = Math.floor(qty / pkgSize + 0.001); + const remainder = Math.round((qty - wholePackages * pkgSize) * 100) / 100; + if (wholePackages > 0 && remainder >= 1) { + qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} ${t('inventory.qty_remainder_suffix')}`; + } else if (remainder >= 1) { + qtyText = `${Math.round(remainder)}${unitLabel} / ${pkgSize}${unitLabel}`; + } else { + qtyText = `${qty}${unitLabel}`; + } } } @@ -3377,6 +3391,17 @@ function setReviewConfirmed(inventoryId) { api('app_settings_save', {}, 'POST', { settings: { review_confirmed: c } }).catch(() => {}); } +/** Return map of product IDs the user has marked as "no expiry needed". */ +function _getNoExpiryDismissed() { + try { return JSON.parse(localStorage.getItem('_noExpiryDismissed') || '{}'); } catch { return {}; } +} +/** Permanently mark a product as "no expiry needed" for this browser. */ +function _dismissNoExpiry(productId) { + const m = _getNoExpiryDismissed(); + m[String(productId)] = Date.now(); + localStorage.setItem('_noExpiryDismissed', JSON.stringify(m)); +} + // === ALERT BANNER SYSTEM (replaces old review table) === let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction' let _bannerIndex = 0; @@ -3510,6 +3535,23 @@ async function loadBannerAlerts() { _bannerQueue.push({ type: 'finished', data: fin }); }); + // 7. Products with no expiry date set (and not permanently dismissed) + const noExpiryDismissed = _getNoExpiryDismissed(); + const PERISHABLE_CATS = ['latticini','carne','pesce','salumi','fresco','verdura','frutta','surgelati', + 'dairy','meat','fish','fresh','vegetables','fruit','frozen']; + items.forEach(item => { + if (item.expiry_date) return; // already has expiry + if (parseFloat(item.quantity) <= 0) return; // no stock + const pid = String(item.product_id || item.id); + if (noExpiryDismissed[pid]) return; // user said "no expiry needed" + // Only flag perishable-looking categories or items with opened_at + const cat = (item.category || '').toLowerCase(); + const likelyPerishable = item.opened_at || + PERISHABLE_CATS.some(c => cat.includes(c)); + if (!likelyPerishable) return; + _bannerQueue.push({ type: 'no_expiry', data: item }); + }); + // Sort by priority (highest first) _bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a)); @@ -3563,6 +3605,8 @@ function _bannerPriority(entry) { } case 'finished': return 600; // product ran out — confirm before removing from DB + case 'no_expiry': + return 30; // low priority: informational, show after everything else default: return 0; } @@ -3724,6 +3768,17 @@ function renderBannerItem() { btns += ``; } actionsEl.innerHTML = btns; + + } else if (entry.type === 'no_expiry') { + const item = entry.data; + banner.className = 'alert-banner banner-no-expiry'; + iconEl.textContent = '📅'; + titleEl.textContent = t('dashboard.banner_no_expiry_title').replace('{name}', item.name + (item.brand ? ' (' + item.brand + ')' : '')); + detailEl.textContent = t('dashboard.banner_no_expiry_detail'); + const pid = item.product_id || item.id; + let btns = ``; + btns += ``; + actionsEl.innerHTML = btns; } if (_bannerQueue.length > 1) { @@ -3757,6 +3812,19 @@ function confirmBannerReview() { dismissBannerItem(); } +function confirmNoExpiryNeeded(productId) { + _dismissNoExpiry(productId); + showToast(t('dashboard.banner_no_expiry_toast_dismissed'), 'success'); + dismissBannerItem(); +} + +function editBannerNoExpiry() { + const entry = _bannerQueue[_bannerIndex]; + if (!entry || entry.type !== 'no_expiry') return; + _bannerEditPending = true; + openEditInventoryModal(entry.data.id); +} + function editBannerReview() { const entry = _bannerQueue[_bannerIndex]; if (!entry || entry.type !== 'review') return; @@ -7469,6 +7537,10 @@ async function addLowStockToBring() { if (shoppingListUUID) payload.listUUID = shoppingListUUID; const data = await api('bring_add', {}, 'POST', payload); if (data.success && data.added > 0) { + // Pin as user-added so cleanup never auto-removes it + const pinned = JSON.parse(localStorage.getItem('_userPinnedBring') || '{}'); + pinned[bringName.toLowerCase()] = Date.now(); + localStorage.setItem('_userPinnedBring', JSON.stringify(pinned)); showToast(t('shopping.added_to_bring').replace('{n}', data.added), 'success'); } else if (data.success && data.skipped > 0) { showToast(t('shopping.already_in_list_short'), 'info'); @@ -8462,6 +8534,35 @@ function _urgencyToSpec(urgency, brand) { return brand || ''; } +/** + * Track items auto-added by autoAddCriticalItems so the cleanup + * function only ever removes those, never manually-added ones. + */ +function _getAutoAddedBring() { + try { + const raw = localStorage.getItem('_autoAddedBring'); + const map = raw ? JSON.parse(raw) : {}; + const now = Date.now(); + let changed = false; + for (const k of Object.keys(map)) { + if (now - map[k] > 30 * 24 * 60 * 60 * 1000) { delete map[k]; changed = true; } + } + if (changed) localStorage.setItem('_autoAddedBring', JSON.stringify(map)); + return map; + } catch(e) { return {}; } +} +function _markAutoAddedBring(names) { + const map = _getAutoAddedBring(); + const now = Date.now(); + for (const n of names) map[n.toLowerCase()] = now; + localStorage.setItem('_autoAddedBring', JSON.stringify(map)); +} +function _unmarkAutoAddedBring(names) { + const map = _getAutoAddedBring(); + for (const n of names) delete map[n.toLowerCase()]; + localStorage.setItem('_autoAddedBring', JSON.stringify(map)); +} + // ===== BRING! PURCHASED BLOCKLIST ===== // When an item disappears from Bring (user bought it), we block auto-re-add for 4h. const _BRING_PURCHASED_TTL = 4 * 60 * 60 * 1000; // 4 hours @@ -8529,6 +8630,8 @@ async function autoAddCriticalItems() { try { const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID }); if (result.success && result.added > 0) { + // Track these as auto-added so cleanupObsoleteBringItems can safely remove them later + _markAutoAddedBring(itemsToAdd.map(i => i.name)); showToast(t('shopping.add_urgent_toast', { n: result.added }), 'success'); logOperation('bring_auto_add', { added: itemsToAdd.map(i => i.name) }); loadShoppingList(); @@ -8544,11 +8647,12 @@ async function autoAddCriticalItems() { async function forceSyncBring() { const btn = document.getElementById('btn-force-sync'); if (btn) { btn.disabled = true; btn.textContent = `⏳ ${t('shopping.syncing')}`; } - // Clear all guards so the next run is unconditional + // Clear auto-add/cleanup guards so the next run is unconditional. + // Do NOT clear _userPinnedBring — items the user manually added must stay protected. localStorage.removeItem('_bringPurchasedBlocklist'); localStorage.removeItem('_autoAddedCriticalTs'); localStorage.removeItem('_bringCleanupTs'); - localStorage.removeItem('_userPinnedBring'); + localStorage.removeItem('_autoAddedBring'); logOperation('force_sync_bring', {}); // Reload everything from scratch await loadShoppingList(); @@ -8588,20 +8692,23 @@ async function cleanupObsoleteBringItems() { } } - // Build: any matching token → smart item (any urgency — all predictions are protected) - const smartByToken = new Map(); + // Build: first token of smart item name → smart item + const smartByFirstToken = new Map(); for (const si of smartShoppingItems) { - for (const tok of _nameTokens(si.name)) { - if (!smartByToken.has(tok)) smartByToken.set(tok, si); + const first = _nameTokens(si.name)[0]; + if (first && !smartByFirstToken.has(first)) smartByFirstToken.set(first, si); + // Also index shopping_name first token + if (si.shopping_name) { + const sFirst = _nameTokens(si.shopping_name)[0]; + if (sFirst && !smartByFirstToken.has(sFirst)) smartByFirstToken.set(sFirst, si); } } - // User-pinned: items manually added via the suggestions panel — never auto-remove + // User-pinned: items manually added via any path — never auto-remove let userPinned; try { const raw = localStorage.getItem('_userPinnedBring'); const map = raw ? JSON.parse(raw) : {}; - // Prune entries older than 30 days const now = Date.now(); let changed = false; for (const k of Object.keys(map)) { @@ -8611,25 +8718,39 @@ async function cleanupObsoleteBringItems() { userPinned = map; } catch(e) { userPinned = {}; } + // Auto-added set: only items the app itself auto-added are candidates for cleanup + const autoAdded = _getAutoAddedBring(); + const toRemove = []; for (const item of shoppingItems) { - // Check if any significant token of this Bring item has stock in inventory - const itemTokens = _nameTokens(item.name); - const stockQty = itemTokens.reduce((sum, tok) => sum + (stockByAnyToken.get(tok) || 0), 0); + const nameLower = item.name.toLowerCase(); + const itemFirst = _nameTokens(item.name)[0]; - // No inventory stock for any related product → nothing to remove - if (stockQty <= 0) continue; + // Safety: only clean up items the app auto-added — NEVER remove manually-added ones + const isAutoAdded = !!(autoAdded[nameLower] || + (itemFirst && Object.keys(autoAdded).some(k => _nameTokens(k)[0] === itemFirst))); + if (!isAutoAdded) continue; - // Never remove items the user explicitly pinned from suggestions - if (userPinned[item.name.toLowerCase()]) continue; + // User explicitly pinned this item → skip + if (userPinned[nameLower]) continue; - // Check if smart shopping flags something with a matching token as needed (any urgency) - const smartSi = itemTokens.map(tok => smartByToken.get(tok)).find(Boolean); - if (smartSi) { - // Smart still predicts this item will be needed and it has remaining stock → keep it - if (smartSi.current_qty > 0) continue; - } + // Find smart item by first-token match (strict — avoids "latte" matching "latte di soia") + const smartSi = itemFirst ? smartByFirstToken.get(itemFirst) : undefined; + // Smart still considers this critical or high urgency → keep it on the list + if (smartSi && (smartSi.urgency === 'critical' || smartSi.urgency === 'high')) continue; + + // Out of stock → the user still needs to buy it, keep it + if (smartSi && (smartSi.current_qty ?? 0) <= 0) continue; + + // Smart predicts medium urgency AND stock < 60% → keep it + if (smartSi && smartSi.urgency === 'medium' && (smartSi.pct_left ?? 100) < 60) continue; + + // Check actual inventory stock for this exact item (first-token match) + const stockQty = itemFirst ? (stockByAnyToken.get(itemFirst) || 0) : 0; + if (stockQty <= 0) continue; // no related stock → don't remove + + // All guards passed: item is auto-added, stock is sufficient, not urgently needed toRemove.push(item); } @@ -8649,6 +8770,7 @@ async function cleanupObsoleteBringItems() { } if (removed > 0) { + _unmarkAutoAddedBring(removedNames); showToast(t('shopping.removed_sufficient', { removed }), 'info'); logOperation('bring_cleanup', { removed: removedNames }); loadShoppingList(); @@ -8924,6 +9046,21 @@ function renderSmartItem(item) { else if (item.use_count >= 4) freqBadge = `${t('shopping.freq_regular')}`; else if (item.use_count >= 2) freqBadge = `${t('shopping.freq_occasional')}`; + // Suggested purchase quantity badge + let suggestBadge = ''; + const sqtyFormatted = _formatSuggestQty(item.suggested_qty, item.suggested_unit || item.unit); + if (!item.on_bring && sqtyFormatted) { + const approx = !!item.suggested_approx; + const tKey = approx ? 'shopping.suggest_buy_approx' : 'shopping.suggest_buy'; + const tTip = approx ? 'shopping.suggest_buy_approx_tip' : 'shopping.suggest_buy_tip'; + const suggestLabel = t(tKey).replace('{qty} {unit}', sqtyFormatted); + const suggestLabelFinal = suggestLabel.includes('{qty}') + ? t(tKey).replace('{qty}', item.suggested_qty).replace('{unit}', item.suggested_unit || item.unit) + : suggestLabel; + const extraClass = approx ? ' freq-suggest-approx' : ''; + suggestBadge = `${suggestLabelFinal}`; + } + // Days left prediction let predBadge = ''; if (item.days_left <= 3 && item.days_left > 0 && item.current_qty > 0) { @@ -8951,7 +9088,7 @@ function renderSmartItem(item) {
${item.reasons.map(r => `${escapeHtml(r)}`).join(' · ')}
${u.icon} ${u.label} - ${freqBadge}${predBadge}${expiryBadge} + ${freqBadge}${predBadge}${expiryBadge}${suggestBadge} ${item.is_opened ? `${t('inventory.opened_badge')}` : ''} ${item.on_bring ? `${t('shopping.bring_badge')}` : ''}
@@ -9002,10 +9139,15 @@ async function addSmartToBring() { if (item) { const shoppingName = item.shopping_name || item.name; const isGeneric = shoppingName !== item.name; - // When generic, use specific product name + brand as the specification - const spec = isGeneric + // Specific product/brand prefix (used when item is grouped under a generic name) + const productPrefix = isGeneric ? (item.name + (item.brand ? ` · ${item.brand}` : '')) - : _urgencyToSpec(item.urgency, item.brand); + : ''; + // Full spec = urgency+qty from _buildSmartSpec, with product prefix prepended if needed + const smartSpec = _buildSmartSpec(item); + const spec = productPrefix + ? (smartSpec ? `${productPrefix} · ${smartSpec}` : productPrefix) + : smartSpec; itemsToAdd.push({ name: shoppingName, specification: spec, @@ -9109,27 +9251,65 @@ function _syncTagsFromBringSpec() { * This makes urgency visible in the native Bring app via the item specification field. * Only updates if the spec has changed (to avoid unnecessary API calls). */ +/** + * Format a suggested purchase quantity into a human-readable string. + * - conf/pz: returned as-is ("2 conf", "3 pz") + * - g ≥ 1000 → kg ("1.5 kg") + * - ml ≥ 1000 → l ("2 l") + * Returns null if qty is null/zero (badge should be hidden). + */ +function _formatSuggestQty(qty, unit) { + if (!qty || qty <= 0) return null; + if (unit === 'conf') return `${qty} conf`; + if (unit === 'pz') return `${qty} pz`; + if (unit === 'g' && qty >= 1000) { + const kg = qty / 1000; + return `${Number.isInteger(kg) ? kg : parseFloat(kg.toFixed(1))} kg`; + } + if (unit === 'ml' && qty >= 1000) { + const l = qty / 1000; + return `${Number.isInteger(l) ? l : parseFloat(l.toFixed(1))} l`; + } + return `${qty} ${unit}`; +} + +/** + * Build the full Bring! specification string for a matched smart item. + * Combines urgency label + suggested quantity so both appear in the Bring app. + * Returns empty string for low/medium urgency items with no useful extra info. + */ +function _buildSmartSpec(smartMatch) { + const urgPart = _urgencyToSpec(smartMatch.urgency, ''); + let qtyPart = ''; + const qtyFormatted = _formatSuggestQty(smartMatch.suggested_qty, smartMatch.suggested_unit || smartMatch.unit); + if (qtyFormatted) { + const approx = !!smartMatch.suggested_approx; + const tKey = approx ? 'shopping.suggest_buy_approx' : 'shopping.suggest_buy'; + qtyPart = t(tKey).replace('{qty} {unit}', qtyFormatted); + // Fallback if the key uses separate {qty} and {unit} placeholders + if (qtyPart.includes('{qty}')) { + qtyPart = t(tKey) + .replace('{qty}', smartMatch.suggested_qty) + .replace('{unit}', smartMatch.suggested_unit || smartMatch.unit); + } + } + const parts = [urgPart, qtyPart].filter(Boolean); + return parts.join(' · '); +} + async function autoSyncUrgencySpecs() { if (!shoppingListUUID || !smartShoppingItems.length) return; const toUpdate = []; for (const item of shoppingItems) { const smartMatch = _matchBringToSmart(item.name, smartShoppingItems); if (!smartMatch) continue; - const expectedSpec = _urgencyToSpec(smartMatch.urgency, ''); - const currentSpec = (item.specification || '').toLowerCase(); - // Only update if urgency marker changed (don't clobber user-set spec info that isn't urgency) - const currentHasUrgencyMarker = currentSpec.includes('urgente') || currentSpec.includes('presto'); - const needsUpdate = expectedSpec && !currentHasUrgencyMarker; - const needsClear = !expectedSpec && currentHasUrgencyMarker; - // Also update if urgency level changed (e.g. medium→high or high→critical) - const currentIsHigh = currentSpec.includes('urgente'); - const newIsHigh = (expectedSpec || '').toLowerCase().includes('urgente'); - const urgencyEscalated = expectedSpec && currentHasUrgencyMarker && (currentIsHigh !== newIsHigh); - if (needsUpdate || needsClear || urgencyEscalated) { - toUpdate.push({ name: item.name, specification: expectedSpec, update_spec: true }); - // Optimistically update local item so re-render is immediate - item.specification = expectedSpec; - } + const targetSpec = _buildSmartSpec(smartMatch); + const currentSpec = (item.specification || '').trim(); + // Normalise for comparison: ignore case and leading/trailing whitespace + if (targetSpec.toLowerCase() === currentSpec.toLowerCase()) continue; + toUpdate.push({ name: item.name, specification: targetSpec, update_spec: true }); + // Optimistically update local item so re-render doesn't flicker + item.specification = targetSpec; } if (toUpdate.length === 0) return; try { @@ -9209,7 +9389,11 @@ async function loadShoppingList() { updateShoppingTabCounts(); // update tab badges with corrected counts autoAddCriticalItems(); cleanupObsoleteBringItems(); - renderShoppingItems(); // re-render shopping tab with urgency badges + // Re-render shopping items ONLY if the user is not currently browsing the suggestions panel. + // Avoids interrupting the user mid-selection while background data loads. + if (suggestionsEl.style.display === 'none') { + renderShoppingItems(); // re-render shopping tab with urgency badges + } }); } catch (err) { @@ -9408,7 +9592,17 @@ async function generateSuggestions() { return; } - suggestionItems = (data.suggestions || []).map(s => ({ ...s, selected: true })); + suggestionItems = (data.suggestions || []).map(s => ({ ...s, selected: true })) + // Exclude items already present in the current Bring shopping list + .filter(s => { + const sFirst = _nameTokens(s.name)[0]; + const sLower = s.name.toLowerCase(); + return !shoppingItems.some(bi => { + const bLower = bi.name.toLowerCase(); + const bFirst = _nameTokens(bi.name)[0]; + return bLower === sLower || (sFirst && bFirst && bFirst === sFirst); + }); + }); // Show seasonal tip const tipEl = document.getElementById('seasonal-tip'); @@ -9447,18 +9641,20 @@ function renderSuggestions() { container.innerHTML = sorted.map((item, idx) => { const catIcon = CATEGORY_ICONS[item.category] || '🛒'; + const isAi = item.source === 'ai'; const priorityBadge = { 'alta': `${t('shopping.priority_high')}`, 'media': `${t('shopping.priority_medium')}`, 'bassa': `${t('shopping.priority_low')}`, }[item.priority] || ''; + const aiBadge = isAi ? `🤖 AI` : ''; return `
${item.selected ? '☑️' : '⬜'}
${catIcon}
-
${escapeHtml(item.name)}${item.specification ? ` (${escapeHtml(item.specification)})` : ''} ${priorityBadge}
+
${escapeHtml(item.name)}${item.specification ? ` (${escapeHtml(item.specification)})` : ''} ${priorityBadge}${aiBadge}
${escapeHtml(item.reason)}
`; diff --git a/data/opened_shelf_cache.json b/data/opened_shelf_cache.json index 9f18f7f..6db3b20 100644 --- a/data/opened_shelf_cache.json +++ b/data/opened_shelf_cache.json @@ -1,44 +1,51 @@ { - "226887def70e33ef73290ebfe75ed4d0": { - "days": 7, - "source": "ai", - "name": "Polpa di pomodoro finissima", - "location": "frigo", - "ts": 1777444819 - }, - "0ed51c9496aa9edfe38caf41772f54ed": { - "days": 7, - "source": "rule", - "name": "Latte di Montagna", - "location": "frigo", - "ts": 1777444820 - }, - "2d63d0216a75d46b465150e925d2e7ad": { - "days": 30, - "source": "rule", - "name": "Burro", - "location": "frigo", - "ts": 1777444821 - }, - "f6504a014f17457e3dbe0ba917ad681f": { - "days": 7, - "source": "rule", - "name": "Latte di Montagna", - "location": "dispensa", - "ts": 1777444888 - }, - "7b15356b493402e17fa417a389e89716": { - "days": 60, - "source": "rule", - "name": "Yaourt Vanille", - "location": "dispensa", - "ts": 1777472391 - }, - "9afdf35c4a256867ef47c32495349eb6": { - "days": 5, - "source": "rule", - "name": "Yaourt Vanille", - "location": "frigo", - "ts": 1777480477 - } + "226887def70e33ef73290ebfe75ed4d0": { + "days": 7, + "source": "ai", + "name": "Polpa di pomodoro finissima", + "location": "frigo", + "ts": 1777444819 + }, + "0ed51c9496aa9edfe38caf41772f54ed": { + "days": 7, + "source": "rule", + "name": "Latte di Montagna", + "location": "frigo", + "ts": 1777444820 + }, + "2d63d0216a75d46b465150e925d2e7ad": { + "days": 30, + "source": "rule", + "name": "Burro", + "location": "frigo", + "ts": 1777444821 + }, + "9afdf35c4a256867ef47c32495349eb6": { + "days": 5, + "source": "rule", + "name": "Yaourt Vanille", + "location": "frigo", + "ts": 1777480477 + }, + "584f57418733a1f2acd29fe2e8816129": { + "days": 5, + "source": "rule", + "name": "Passata di pomodoro", + "location": "frigo", + "ts": 1778133522 + }, + "baeb7f2021b4bb91c368c9131a61f07c": { + "days": 10, + "source": "rule", + "name": "Formaggio Monte Maria", + "location": "frigo", + "ts": 1778133523 + }, + "063f2d534407214786d039bb2bffbb93": { + "days": 5, + "source": "rule", + "name": "Carote", + "location": "frigo", + "ts": 1778133524 + } } \ No newline at end of file diff --git a/translations/de.json b/translations/de.json index e0bc773..d09a314 100644 --- a/translations/de.json +++ b/translations/de.json @@ -84,7 +84,7 @@ "opened_title": "📦 Geöffnete Produkte", "review_title": "🔍 Zu prüfen", "review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.", - "quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten", + "quick_recipe": "Schnelles Rezept mit ablaufenden Produkten", "banner_review_title": "Ungewöhnliche Menge", "banner_review_action_ok": "Ist korrekt", "banner_review_action_finish": "🗑️ Alles aufgebraucht", @@ -103,8 +103,11 @@ "banner_expired_action_throw": "Habe ich weggeworfen", "banner_expired_action_edit": "Datum korrigieren", "banner_anomaly_action_edit": "Bestand korrigieren", - "banner_anomaly_action_dismiss": "Menge ist korrekt", - "banner_expiring_title": "Bald ablaufend", + "banner_anomaly_action_dismiss": "Menge ist korrekt", "banner_no_expiry_title": "Ablaufdatum fehlt: {name}", + "banner_no_expiry_detail": "Dieses Produkt hat kein Ablaufdatum. Möchten Sie eines hinzufügen oder bestätigen, dass es nicht verfällt?", + "banner_no_expiry_action_set": "Ablaufdatum setzen", + "banner_no_expiry_action_dismiss": "Läuft nicht ab ✓", + "banner_no_expiry_toast_dismissed": "Als 'läuft nicht ab' markiert", "banner_expiring_title": "Bald ablaufend", "banner_expiring_today": "Läuft heute ab!", "banner_expiring_tomorrow": "Läuft morgen ab", "banner_expiring_days": "Läuft in {days} Tagen ab", @@ -357,6 +360,10 @@ "smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus", "all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.", "search_complete": "Suche abgeschlossen: {count} Produkte", + "suggest_buy": "🛒 Kaufen: {qty} {unit}", + "suggest_buy_approx": "🛒 Mindestens: {qty} {unit}", + "suggest_buy_tip": "Empfohlene Menge basierend auf dem Verbrauch der letzten 14 Tage", + "suggest_buy_approx_tip": "Mindestschätzung basierend auf Verbrauch (nächste Packungsgröße kaufen)", "removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt", "bring_badge": "🛒 Schon auf Bring!", "add_urgent_toast": "🔴 {n} dringende(s) Produkt(e) automatisch zu Bring! hinzugefügt", diff --git a/translations/en.json b/translations/en.json index 1c75e25..74908a8 100644 --- a/translations/en.json +++ b/translations/en.json @@ -84,7 +84,7 @@ "opened_title": "📦 Opened Products", "review_title": "🔍 To Review", "review_hint": "Quantities that seem unusual. Confirm if correct or modify.", - "quick_recipe": "🍳 Quick recipe with expiring products", + "quick_recipe": "Quick recipe with expiring products", "banner_review_title": "Anomalous quantity", "banner_review_action_ok": "It's correct", "banner_review_action_finish": "🗑️ All gone", @@ -104,6 +104,11 @@ "banner_expired_action_edit": "Fix date", "banner_anomaly_action_edit": "Fix inventory", "banner_anomaly_action_dismiss": "Quantity is correct", + "banner_no_expiry_title": "Missing expiry: {name}", + "banner_no_expiry_detail": "This product has no expiry date. Would you like to add one, or confirm it doesn't expire?", + "banner_no_expiry_action_set": "Set expiry date", + "banner_no_expiry_action_dismiss": "Doesn't expire ✓", + "banner_no_expiry_toast_dismissed": "Marked as 'no expiry'", "banner_expiring_title": "Expiring soon", "banner_expiring_today": "Expires today!", "banner_expiring_tomorrow": "Expires tomorrow", @@ -357,6 +362,10 @@ "all_searched": "All products have already been searched. Use 🔄 to search individual ones.", "search_complete": "Search complete: {count} products", "removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list", + "suggest_buy": "🛒 Buy: {qty} {unit}", + "suggest_buy_approx": "🛒 At least: {qty} {unit}", + "suggest_buy_tip": "Suggested quantity based on your last 14 days of consumption", + "suggest_buy_approx_tip": "Minimum estimate based on consumption (buy the nearest package size)", "bring_badge": "🛒 Already on Bring!", "add_urgent_toast": "🔴 {n} urgent product(s) automatically added to Bring!", "migration_done": "✅ {migrated} updated, {skipped} already ok", diff --git a/translations/it.json b/translations/it.json index 8e38271..a30331a 100644 --- a/translations/it.json +++ b/translations/it.json @@ -84,7 +84,7 @@ "opened_title": "📦 Prodotti Aperti", "review_title": "🔍 Da revisionare", "review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.", - "quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza", + "quick_recipe": "Ricetta veloce con prodotti in scadenza", "banner_review_title": "Quantità anomala", "banner_review_action_ok": "È corretto", "banner_review_action_finish": "🗑️ È finito tutto", @@ -104,6 +104,11 @@ "banner_expired_action_edit": "Correggi data", "banner_anomaly_action_edit": "Correggi inventario", "banner_anomaly_action_dismiss": "La quantità è giusta", + "banner_no_expiry_title": "Scadenza mancante: {name}", + "banner_no_expiry_detail": "Questo prodotto non ha una data di scadenza. Vuoi aggiungerla o confermare che non scade?", + "banner_no_expiry_action_set": "Imposta scadenza", + "banner_no_expiry_action_dismiss": "Non scade ✓", + "banner_no_expiry_toast_dismissed": "Segnato come 'non scade'", "banner_expiring_title": "In scadenza", "banner_expiring_today": "Scade oggi!", "banner_expiring_tomorrow": "Scade domani", @@ -357,6 +362,10 @@ "all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.", "search_complete": "Ricerca completata: {count} prodotti", "removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista", + "suggest_buy": "🛒 Compra: {qty} {unit}", + "suggest_buy_approx": "🛒 Almeno: {qty} {unit}", + "suggest_buy_tip": "Quantità suggerita in base al consumo degli ultimi 14 giorni", + "suggest_buy_approx_tip": "Stima minima basata sul consumo (compra la confezione più vicina)", "bring_badge": "🛒 Già su Bring!", "add_urgent_toast": "🔴 {n} prodotto/i urgente/i aggiunto/i automaticamente a Bring!", "migration_done": "✅ {migrated} aggiornati, {skipped} già ok",