feat: AI suggestions, smart shopping qty, shelf life fixes, UX polish
- bringSuggestItems(): Gemini AI for seasonal/complementary suggestions (6h cache) - renderSuggestions(): AI badge (🤖 AI) for AI-sourced items + CSS .priority-ai - smartShopping(): suggested_qty/unit/approx with package-aware tiers - autoSyncUrgencySpecs(): sync suggested quantities to Bring! spec field - estimateOpenedExpiryDays(): dairy-outside-fridge rules (panna 3d, yogurt 2d, latte 1d) - AI shelf-life upper bound tightened to max(rule×4, 30) days - Opened section: fix 0g display (remainderAmt >= 0.5 threshold, pkgSize guard) - guessCategoryFromName(): expanded with 50+ new patterns (uova, herbs, vegetables...) - Suggestions panel: excludes already-added Bring! items - Shopping list: no re-render while suggestions panel is open - Translations: remove duplicate 🍳 from dashboard.quick_recipe (all 3 langs) - Scale icon: always white via filter:brightness(0)invert(1) - opened_shelf_cache.json: remove 3 bad dairy entries (60d outside fridge)
This commit is contained in:
+7
-2
@@ -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;
|
||||
|
||||
+201
-20
@@ -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}" : '';
|
||||
|
||||
$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.";
|
||||
|
||||
$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' => $item['name'],
|
||||
'name' => ucfirst(trim($ai['name'])),
|
||||
'specification' => '',
|
||||
'reason' => $reason,
|
||||
'category' => $item['category'] ?: 'altro',
|
||||
'priority' => $priority,
|
||||
'reason' => trim($ai['reason'] ?? 'Stagionale'),
|
||||
'category' => $ai['category'] ?? 'altro',
|
||||
'priority' => 'bassa',
|
||||
'source' => 'ai',
|
||||
];
|
||||
|
||||
if (count($suggestions) >= 12) break;
|
||||
$knownNames[] = $aiName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
|
||||
+27
-5
@@ -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;
|
||||
|
||||
+259
-63
@@ -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,30 +3253,38 @@ 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 || '';
|
||||
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 > 0.01) {
|
||||
if (wholePackages > 0 && remainder >= 1) {
|
||||
qtyText = `${wholePackages} × ${pkgSize}${unitLabel} + ${Math.round(remainder)}${unitLabel} ${t('inventory.qty_remainder_suffix')}`;
|
||||
} else if (remainder > 0.01) {
|
||||
} else if (remainder >= 1) {
|
||||
qtyText = `${Math.round(remainder)}${unitLabel} / ${pkgSize}${unitLabel}`;
|
||||
} else {
|
||||
qtyText = `${qty}${unitLabel}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry badge
|
||||
const days = item.days_to_expiry;
|
||||
@@ -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 += `<button class="btn-banner btn-banner-ai" onclick="explainBannerAnomaly()" title="Chiedi a Gemini una spiegazione">\ud83e\udd16 Spiega</button>`;
|
||||
}
|
||||
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 = `<button class="btn-banner btn-banner-edit" onclick="editBannerNoExpiry()">${t('dashboard.banner_no_expiry_action_set')}</button>`;
|
||||
btns += `<button class="btn-banner btn-banner-ok" onclick="confirmNoExpiryNeeded(${pid})">${t('dashboard.banner_no_expiry_action_dismiss')}</button>`;
|
||||
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 = `<span class="smart-freq-badge freq-med">${t('shopping.freq_regular')}</span>`;
|
||||
else if (item.use_count >= 2) freqBadge = `<span class="smart-freq-badge freq-low">${t('shopping.freq_occasional')}</span>`;
|
||||
|
||||
// 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 = `<span class="smart-freq-badge freq-suggest${extraClass}" title="${t(tTip)}">${suggestLabelFinal}</span>`;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
<div class="smart-item-reasons">${item.reasons.map(r => `<span>${escapeHtml(r)}</span>`).join(' · ')}</div>
|
||||
<div class="smart-item-badges">
|
||||
<span class="smart-urgency-badge" style="color:${u.color}">${u.icon} ${u.label}</span>
|
||||
${freqBadge}${predBadge}${expiryBadge}
|
||||
${freqBadge}${predBadge}${expiryBadge}${suggestBadge}
|
||||
${item.is_opened ? `<span class="smart-freq-badge freq-low">${t('inventory.opened_badge')}</span>` : ''}
|
||||
${item.on_bring ? `<span class="smart-bring-badge">${t('shopping.bring_badge')}</span>` : ''}
|
||||
</div>
|
||||
@@ -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();
|
||||
// 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': `<span class="priority-badge priority-high">${t('shopping.priority_high')}</span>`,
|
||||
'media': `<span class="priority-badge priority-med">${t('shopping.priority_medium')}</span>`,
|
||||
'bassa': `<span class="priority-badge priority-low">${t('shopping.priority_low')}</span>`,
|
||||
}[item.priority] || '';
|
||||
const aiBadge = isAi ? `<span class="priority-badge priority-ai">🤖 AI</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="suggestion-item ${item.selected ? 'selected' : ''}" onclick="toggleSuggestion(${idx})" data-suggestion-name="${escapeHtml(item.name)}">
|
||||
<div class="suggestion-check">${item.selected ? '☑️' : '⬜'}</div>
|
||||
<span class="shopping-item-icon">${catIcon}</span>
|
||||
<div class="suggestion-info">
|
||||
<div class="suggestion-name">${escapeHtml(item.name)}${item.specification ? ` <small>(${escapeHtml(item.specification)})</small>` : ''} ${priorityBadge}</div>
|
||||
<div class="suggestion-name">${escapeHtml(item.name)}${item.specification ? ` <small>(${escapeHtml(item.specification)})</small>` : ''} ${priorityBadge}${aiBadge}</div>
|
||||
<div class="suggestion-reason">${escapeHtml(item.reason)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -20,25 +20,32 @@
|
||||
"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
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
+10
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user