diff --git a/.gitignore b/.gitignore index cbcd032..7bd964e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ data/cron.log data/smart_shopping_cache.json data/bring_token.json data/bring_catalog.json +data/bring_migrate_ts.json +data/shopping_price_cache.json +data/anomaly_dismissed.json +data/opened_shelf_cache.json +data/shopping_name_cache.json data/client_debug.log data/rate_limits/ diff --git a/api/database.php b/api/database.php index 576052a..1941873 100644 --- a/api/database.php +++ b/api/database.php @@ -16,6 +16,9 @@ function getDB(): PDO { $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $db->exec("PRAGMA journal_mode=WAL"); $db->exec("PRAGMA foreign_keys=ON"); + $db->exec("PRAGMA synchronous=NORMAL"); // faster writes, still safe with WAL + $db->exec("PRAGMA cache_size=-8000"); // ~8 MB page cache (was 2 MB) + $db->exec("PRAGMA temp_store=MEMORY"); // temp tables in RAM if ($isNew) { initializeDB($db); diff --git a/api/index.php b/api/index.php index add5a45..6654d94 100644 --- a/api/index.php +++ b/api/index.php @@ -16,6 +16,7 @@ define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004'); define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26'); define('GH_REPO', 'dadaloop82/EverShelf'); +define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json'); /** Decode the XOR-obfuscated GitHub token at runtime. */ function _ghToken(): string { @@ -410,6 +411,14 @@ try { geminiAnomalyExplain(); break; + case 'get_shopping_price': + getShoppingPrice($db); + break; + + case 'get_all_shopping_prices': + getAllShoppingPrices($db); + break; + default: http_response_code(404); echo json_encode(['error' => 'Unknown action: ' . $action]); @@ -1213,17 +1222,19 @@ function useFromInventory(PDO $db): void { $stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$existing['id']]); } else { - // Check if item is now opened (first use reduces quantity) + // Check if item is now opened (first use creates a fractional/partial package) $wasOpened = !empty($existing['opened_at']); $isNowOpened = false; $unit = $prodInfo['unit'] ?? 'pz'; $defQty = (float)($prodInfo['default_quantity'] ?? 0); if ($unit === 'conf') { - $w = floor($newQty + 0.001); - $f = round($newQty - $w, 6); + // Opened = a fractional (non-integer) quantity remains + $f = round($newQty - floor($newQty + 0.001), 6); if ($f > 0.001) $isNowOpened = true; - } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) { - $isNowOpened = true; + } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) { + // Opened = remaining qty is not a clean multiple of the package size + $pkgRem = round($newQty - floor($newQty / $defQty + 0.001) * $defQty, 6); + if ($pkgRem > $defQty * 0.01) $isNowOpened = true; } if ($isNowOpened && !$wasOpened) { @@ -1238,8 +1249,45 @@ function useFromInventory(PDO $db): void { if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) { $openedExpiry = $existing['expiry_date']; } - $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); - $stmt->execute([$newQty, $openedExpiry, $existing['id']]); + + // Split opened portion from sealed packages into two separate rows: + // closed packages stay at original location, opened portion is offered to move. + if ($unit === 'conf') { + $newWhole = (int)floor($newQty + 0.001); + $newFrac = round($newQty - $newWhole, 6); + if ($newFrac > 0.001 && $newWhole >= 1) { + // Keep whole confs in original row (no opened_at, sealed expiry unchanged) + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newWhole, $existing['id']]); + // New row for the opened fraction with short shelf-life expiry + $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); + $stmt->execute([$productId, $location, $newFrac, $openedExpiry, $vacuum]); + $openedId = (int)$db->lastInsertId(); + } else { + // Only the opened fraction remains (≤ 1 conf) — single row + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $openedExpiry, $existing['id']]); + } + } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) { + $newWholePkgs = (int)floor($newQty / $defQty + 0.001); + $newRemainder = round($newQty - $newWholePkgs * $defQty, 6); + if ($newRemainder > $defQty * 0.01 && $newWholePkgs >= 1) { + // Keep whole packages in original row (no opened_at, sealed expiry unchanged) + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newWholePkgs * $defQty, $existing['id']]); + // New row for the opened partial package with short shelf-life expiry + $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)"); + $stmt->execute([$productId, $location, $newRemainder, $openedExpiry, $vacuum]); + $openedId = (int)$db->lastInsertId(); + } else { + // Only the opened remainder (last package) — single row + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $openedExpiry, $existing['id']]); + } + } else { + $stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + $stmt->execute([$newQty, $openedExpiry, $existing['id']]); + } } else { $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt->execute([$newQty, $existing['id']]); @@ -1260,12 +1308,16 @@ function useFromInventory(PDO $db): void { $remaining = $newQty; - // Check if opened part remains (for non-split path) - if ($remaining > 0 && $prodInfo && $prodInfo['unit'] === 'conf') { - $w = floor($remaining + 0.001); - $f = round($remaining - $w, 6); - if ($f > 0.001) { - $openedId = (int)$existing['id']; + // Check if opened part remains (for non-split path, only when not already set by split above) + if ($openedId === null && $remaining > 0 && $prodInfo) { + $unitFb = $prodInfo['unit'] ?? ''; + $defQtyFb = (float)($prodInfo['default_quantity'] ?? 0); + if ($unitFb === 'conf') { + $f = round($remaining - floor($remaining + 0.001), 6); + if ($f > 0.001) $openedId = (int)$existing['id']; + } elseif (in_array($unitFb, ['g','kg','ml','l']) && $defQtyFb > 0) { + $pkgRemFb = round($remaining - floor($remaining / $defQtyFb + 0.001) * $defQtyFb, 6); + if ($pkgRemFb > $defQtyFb * 0.01) $openedId = (int)$existing['id']; } } @@ -1772,7 +1824,7 @@ function getStats(PDO $db): void { } // Compute opened shelf-life using AI (with rule-based fallback + persistent cache). // The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays. - $openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum); + $openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum, false); $computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400; // Always respect the manufacturer date: if the package expires before our estimate, // use the manufacturer date (e.g., milk opened 2 days before its sealed expiry). @@ -2079,6 +2131,10 @@ function getServerSettings(): void { 'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true', 'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true', 'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'), + 'price_enabled' => env('PRICE_ENABLED', 'false') === 'true', + 'price_country' => env('PRICE_COUNTRY', 'Italia'), + 'price_currency' => env('PRICE_CURRENCY', 'EUR'), + 'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'), ]); } @@ -2112,6 +2168,8 @@ function saveSettings(): void { 'camera_facing' => 'CAMERA_FACING', 'dietary' => 'DIETARY', 'scale_gateway_url' => 'SCALE_GATEWAY_URL', + 'price_country' => 'PRICE_COUNTRY', + 'price_currency' => 'PRICE_CURRENCY', ]; // Boolean keys $boolMap = [ @@ -2125,11 +2183,13 @@ function saveSettings(): void { 'scale_enabled' => 'SCALE_ENABLED', 'meal_plan_enabled' => 'MEAL_PLAN_ENABLED', 'screensaver_enabled' => 'SCREENSAVER_ENABLED', + 'price_enabled' => 'PRICE_ENABLED', ]; // Integer keys $intMap = [ 'default_persons' => 'DEFAULT_PERSONS', 'screensaver_timeout' => 'SCREENSAVER_TIMEOUT', + 'price_update_months' => 'PRICE_UPDATE_MONTHS', ]; foreach ($keyMap as $inKey => $envKey) { @@ -2266,14 +2326,19 @@ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 3 * Falls back to the rule-based estimate if AI is unavailable or returns an unusable answer. * Cache has no expiry — shelf-life science doesn't change; the file can be manually deleted to refresh. */ -function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false): int { +function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false, bool $allowAI = true): int { $cacheFile = __DIR__ . '/../data/opened_shelf_cache.json'; $cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location)); - // Load cache - $cache = []; - if (file_exists($cacheFile)) { - $cache = json_decode(file_get_contents($cacheFile), true) ?: []; + // Static in-memory cache: the file is read only ONCE per PHP request, + // even when this function is called for many items in a loop (e.g. getStats). + static $cache = null; + static $cacheDirty = false; + if ($cache === null) { + $cache = []; + if (file_exists($cacheFile)) { + $cache = json_decode(file_get_contents($cacheFile), true) ?: []; + } } if (isset($cache[$cacheKey]['days'])) { @@ -2281,10 +2346,10 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location return $vacuumSealed ? (int)round($days * 1.5) : $days; } - // Try Gemini AI + // Try Gemini AI (only when explicitly allowed — NOT during bulk stats loops) $apiKey = env('GEMINI_API_KEY'); $days = 0; - if (!empty($apiKey)) { + if ($allowAI && !empty($apiKey)) { $locLabel = match($location) { 'frigo' => 'refrigerator (4 °C / 39 °F)', 'freezer' => 'freezer (-18 °C / 0 °F)', @@ -2324,8 +2389,10 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location $source = 'ai'; } - // Persist to cache + // Persist to in-memory cache (file will be flushed at end of request via register_shutdown_function) $cache[$cacheKey] = ['days' => $days, 'source' => $source, 'name' => $name, 'location' => $location, 'ts' => time()]; + $cacheDirty = true; + // Write immediately so single-item requests (opened_shelf_life action) are persisted @file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); return $vacuumSealed ? (int)round($days * 1.5) : $days; @@ -3045,7 +3112,7 @@ function generateRecipe(PDO $db): void { $extraRulesText = ''; if (!empty($extraRules)) { - $extraRulesText = "\n\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules); + $extraRulesText = "\n\n⚠️ PREFERENZE OBBLIGATORIE (RISPETTALE SEMPRE, non sono suggerimenti):\n" . implode("\n", array_map(fn($r) => "→ $r", $extraRules)); } // Appliances @@ -3189,7 +3256,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per REGOLE: {$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto. 2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili). -3. Quantità per $persons persona/e. Se un ingrediente ha poca quantità, usalo TUTTO. +3. Quantità MASSIME per $persons persona/e (NON superare mai): pasta/riso asciutto 90g/pers, carne 180g/pers, pesce 200g/pers, legumi secchi 80g/pers (lessi 200g/pers), verdure contorno 200g/pers, formaggio 80g/pers, latte 200ml/pers, farina per dolci 200g/pers. Se un ingrediente rimasto è inferiore a questi limiti, usalo tutto. 4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. 5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario). 6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio). @@ -3722,7 +3789,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per REGOLE: {$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto. 2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili). -3. Quantità per $persons persona/e. Se un ingrediente ha poca quantità, usalo TUTTO. +3. Quantità MASSIME per $persons persona/e (NON superare mai): pasta/riso asciutto 90g/pers, carne 180g/pers, pesce 200g/pers, legumi secchi 80g/pers (lessi 200g/pers), verdure contorno 200g/pers, formaggio 80g/pers, latte 200ml/pers, farina per dolci 200g/pers. Se un ingrediente rimasto è inferiore a questi limiti, usalo tutto. 4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0. 5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario). 6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio). @@ -5378,13 +5445,19 @@ function smartShopping(PDO $db): void { $need14 = $dailyRate * 14; if ($unit === 'conf') { - // dailyRate already in conf/day - $suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3))); + // Guard against unit mismatch: transactions may have been recorded in g/ml + // (e.g. product unit was changed from 'g' to 'conf' after initial tracking). + // If totalUsed is much larger than buy_count (e.g. 900 vs 4), it's clearly grams. + // In that case fall back to purchase-frequency as the daily rate. + if ($buyCount > 0 && $totalUsed > $buyCount * 5 && $daysSinceFirst < 999) { + $need14 = ($buyCount / $daysSinceFirst) * 14; + } + $suggestedQty = (int) max(1, min(10, (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))); + $pkgs = (int) max(1, min(10, (int)($need14 / $defQty + 0.3))); $suggestedQty = $pkgs; $suggestedUnit = 'conf'; @@ -5393,7 +5466,7 @@ function smartShopping(PDO $db): void { // 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); + $pkgs = min(10, $pkgs); $suggestedQty = $pkgs * (int)$defQty; $suggestedUnit = $unit; $suggestedApprox = true; // always "almeno" — no confirmed pkg size @@ -5416,7 +5489,7 @@ function smartShopping(PDO $db): void { } elseif ($unit === 'pz') { // No package info → raw pz count, approximate - $suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3))); + $suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3))); $suggestedUnit = 'pz'; $suggestedApprox = ($suggestedQty > 1); } @@ -6389,3 +6462,328 @@ function geminiAnomalyExplain(): void { echo json_encode(['success' => true, 'explanation' => $explanation]); } + +// ───────────────────────────────────────────────────────────────────────────── +// SHOPPING LIST PRICE ESTIMATION (AI-powered, cached) +// ───────────────────────────────────────────────────────────────────────────── +// Note: PRICE_CACHE_PATH constant is defined at the top of the file. + +function _loadPriceCache(): array { + if (!file_exists(PRICE_CACHE_PATH)) return []; + try { return json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?? []; } catch (\Throwable $e) { return []; } +} + +function _savePriceCache(array $data): void { + file_put_contents(PRICE_CACHE_PATH, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); +} + +/** + * Return cache key: md5(lowercase name + country + schema version) + * Bump version suffix when AI prompt format changes to auto-invalidate old entries. + */ +function _priceKey(string $name, string $country): string { + return md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)) . '|v2'); +} + +/** + * Ask Gemini for the estimated retail price per unit (kg, l, pz as appropriate) + * for a product in a given country/currency. Returns an array: + * { price_per_unit, unit_label, currency, source_note } or null on failure. + */ +function _fetchPriceFromAI(string $name, string $country, string $currency, string $lang): ?array { + $apiKey = env('GEMINI_API_KEY'); + if (empty($apiKey)) return null; + + $langLabel = match($lang) { 'en' => 'English', 'de' => 'German', default => 'Italian' }; + + $prompt = << [['parts' => [['text' => $prompt]]]]]; + $result = callGeminiWithFallback($apiKey, $payload, 20); + + if ($result['http_code'] !== 200) return null; + + $text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''); + $text = preg_replace('/^```json\s*/i', '', $text); + $text = preg_replace('/\s*```$/i', '', $text); + $data = json_decode(trim($text), true); + + if (!$data || !isset($data['price_per_unit'])) return null; + return $data; +} + +/** + * GET /api/?action=get_shopping_price + * POST body: { name, quantity, unit, default_quantity, package_unit, country, currency, lang, force_refresh } + * + * Returns: { success, name, price_per_unit, unit_label, currency, estimated_total, estimated_total_label, cached_at, source_note } + */ +function getShoppingPrice(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $name = trim($input['name'] ?? ''); + $qty = (float)($input['quantity'] ?? 1); + $unit = trim($input['unit'] ?? 'pz'); + $defQty = (float)($input['default_quantity'] ?? 0); + $pkgUnit = trim($input['package_unit'] ?? ''); + $country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia')); + $currency= trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR')); + $lang = trim($input['lang'] ?? 'it'); + $forceRefresh = !empty($input['force_refresh']); + $updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3'); + + if (empty($name)) { + echo json_encode(['success' => false, 'error' => 'missing name']); + return; + } + + $cache = _loadPriceCache(); + $key = _priceKey($name, $country); + $now = time(); + $maxAge = $updateMonths * 30 * 86400; + + // Use cache if fresh + if (!$forceRefresh && isset($cache[$key])) { + $entry = $cache[$key]; + $age = $now - ($entry['cached_at'] ?? 0); + if ($age < $maxAge) { + $entry['success'] = true; + $entry['from_cache'] = true; + $entry['estimated_total'] = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit); + $entry['estimated_total_label'] = _formatPrice($entry['estimated_total'], $currency); + echo json_encode($entry); + return; + } + } + + $priceData = _fetchPriceFromAI($name, $country, $currency, $lang); + if (!$priceData || $priceData['price_per_unit'] === null) { + echo json_encode(['success' => false, 'error' => 'price_not_found', 'name' => $name]); + return; + } + + $entry = [ + 'name' => $name, + 'price_per_unit'=> (float)$priceData['price_per_unit'], + 'unit_label' => $priceData['unit_label'] ?? 'kg', + 'currency' => $currency, + 'source_note' => $priceData['source_note'] ?? '', + 'country' => $country, + 'cached_at' => $now, + ]; + $cache[$key] = $entry; + _savePriceCache($cache); + + $entry['success'] = true; + $entry['from_cache'] = false; + $entry['estimated_total'] = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit); + $entry['estimated_total_label'] = _formatPrice($entry['estimated_total'], $currency); + echo json_encode($entry); +} + +/** + * GET /api/?action=get_all_shopping_prices + * POST body: { items: [{name, quantity, unit, default_quantity, package_unit}], country, currency, lang, force_refresh } + * + * Returns: { success, prices: { name → priceEntry }, total, total_label } + */ +function getAllShoppingPrices(PDO $db): void { + $input = json_decode(file_get_contents('php://input'), true) ?? []; + $items = $input['items'] ?? []; + $country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia')); + $currency= trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR')); + $lang = trim($input['lang'] ?? 'it'); + $forceRefresh = !empty($input['force_refresh']); + $updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3'); + + if (empty($items)) { + echo json_encode(['success' => true, 'prices' => [], 'total' => 0, 'total_label' => _formatPrice(0, $currency)]); + return; + } + + $cache = _loadPriceCache(); + $now = time(); + $maxAge = $updateMonths * 30 * 86400; + $prices = []; + $total = 0.0; + $missing = []; + + // First pass: serve from cache + foreach ($items as $item) { + $name = trim($item['name'] ?? ''); + $qty = (float)($item['quantity'] ?? 1); + $unit = trim($item['unit'] ?? 'pz'); + $defQty = (float)($item['default_quantity'] ?? 0); + $pkgUnit = trim($item['package_unit'] ?? ''); + if (empty($name)) continue; + + $key = _priceKey($name, $country); + if (!$forceRefresh && isset($cache[$key])) { + $age = $now - ($cache[$key]['cached_at'] ?? 0); + if ($age < $maxAge) { + $entry = $cache[$key]; + $est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit); + $prices[$name] = array_merge($entry, [ + 'estimated_total' => $est, + 'estimated_total_label' => _formatPrice($est, $currency), + 'from_cache' => true, + ]); + $total += $est ?? 0; + continue; + } + } + $missing[] = $item; + } + + // Second pass: fetch missing from AI (sequential to avoid rate limits) + foreach ($missing as $item) { + $name = trim($item['name'] ?? ''); + $qty = (float)($item['quantity'] ?? 1); + $unit = trim($item['unit'] ?? 'pz'); + $defQty = (float)($item['default_quantity'] ?? 0); + $pkgUnit = trim($item['package_unit'] ?? ''); + $key = _priceKey($name, $country); + + $priceData = _fetchPriceFromAI($name, $country, $currency, $lang); + if ($priceData && $priceData['price_per_unit'] !== null) { + $entry = [ + 'name' => $name, + 'price_per_unit'=> (float)$priceData['price_per_unit'], + 'unit_label' => $priceData['unit_label'] ?? 'kg', + 'currency' => $currency, + 'source_note' => $priceData['source_note'] ?? '', + 'country' => $country, + 'cached_at' => $now, + ]; + $cache[$key] = $entry; + $est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit); + $prices[$name] = array_merge($entry, [ + 'estimated_total' => $est, + 'estimated_total_label' => _formatPrice($est, $currency), + 'from_cache' => false, + ]); + $total += $est ?? 0; + } else { + $prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null]; + } + } + + _savePriceCache($cache); + + $total = round($total, 2); + echo json_encode([ + 'success' => true, + 'prices' => $prices, + 'total' => $total, + 'total_label' => _formatPrice($total, $currency), + ]); +} + +/** + * Calculate estimated cost for a shopping item given price_per_unit and the item's quantity/unit. + * Price unit: kg, l, pz/unit + */ +function _calcEstimatedTotal(float $pricePerUnit, string $priceUnitLabel, float $qty, string $unit, float $defQty, string $pkgUnit): ?float { + if ($pricePerUnit <= 0) return null; + + $label = strtolower(trim($priceUnitLabel)); + + // ── Weight-based price (per kg) ─────────────────────────────────────────── + // Only exact 'kg' triggers weight conversion; retail-unit labels like + // "pacco 500g" or "mazzo" fall through to the countable path below. + if ($label === 'kg') { + $weightKg = 0.0; + if ($unit === 'conf' && $defQty > 0 && !empty($pkgUnit)) { + $sub = strtolower($pkgUnit); + if ($sub === 'g') $weightKg = $qty * $defQty / 1000.0; + elseif ($sub === 'kg') $weightKg = $qty * $defQty; + // unknown sub-unit: can't convert → return null + } elseif ($unit === 'g') { + $weightKg = $qty / 1000.0; + } elseif ($unit === 'kg') { + $weightKg = $qty; + } + // pz / conf without defQty → we don't know the weight → no total + if ($weightKg <= 0) return null; + return round($pricePerUnit * $weightKg, 2); + } + + // ── Volume-based price (per liter) ──────────────────────────────────────── + if (in_array($label, ['l', 'lt', 'litre', 'liter', 'litro'])) { + $volumeL = 0.0; + if ($unit === 'conf' && $defQty > 0 && !empty($pkgUnit)) { + $sub = strtolower($pkgUnit); + if ($sub === 'ml') $volumeL = $qty * $defQty / 1000.0; + elseif ($sub === 'l') $volumeL = $qty * $defQty; + } elseif ($unit === 'ml') { + $volumeL = $qty / 1000.0; + } elseif ($unit === 'l') { + $volumeL = $qty; + } + if ($volumeL <= 0) return null; + return round($pricePerUnit * $volumeL, 2); + } + + // ── Countable retail unit (mazzo, pacco, barattolo, pz, conf, …) ───────── + // price_per_unit is already the price for ONE retail unit. + // + // Special case: shopping qty is in g/ml but price is per-package. + // We must convert grams→packages so we don't multiply 100×€2.75=€275. + if (in_array(strtolower($unit), ['g', 'ml'])) { + $pkgWeight = 0.0; + // 1) Use defQty if package unit matches (e.g. defQty=250, pkgUnit='g', unit='g') + if ($defQty > 0 && !empty($pkgUnit) && strtolower($pkgUnit) === strtolower($unit)) { + $pkgWeight = $defQty; + } + // 2) Extract weight from label: "confezione 250g", "vasetto 125ml", "pacco 500g" + if ($pkgWeight <= 0) { + if (preg_match('/\b(\d+(?:[.,]\d+)?)\s*(g|ml)\b/i', $priceUnitLabel, $m)) { + if (strtolower($m[2]) === strtolower($unit)) { + $pkgWeight = (float)str_replace(',', '.', $m[1]); + } + } + } + // 3) Also try defQty alone (no pkgUnit set but defQty likely in same unit) + if ($pkgWeight <= 0 && $defQty > 0) { + $pkgWeight = $defQty; + } + if ($pkgWeight > 0) { + $packages = (int) max(1, ceil($qty / $pkgWeight)); + return round($pricePerUnit * $packages, 2); + } + // No conversion possible → return single-unit price (1 package minimum) + return round($pricePerUnit, 2); + } + + $buyQty = max(1.0, $qty); + return round($pricePerUnit * $buyQty, 2); +} + +function _formatPrice(float $amount, string $currency): string { + $sym = match(strtoupper($currency)) { + 'EUR' => '€', 'USD' => '$', 'GBP' => '£', 'CHF' => 'CHF', + 'JPY' => '¥', 'CNY' => '¥', 'CAD' => 'CA$', 'AUD' => 'A$', + 'BRL' => 'R$', 'RUB' => '₽', 'INR' => '₹', 'MXN' => '$', + 'SEK' => 'kr', 'NOK' => 'kr', 'DKK' => 'kr', 'PLN' => 'zł', + 'CZK' => 'Kč', 'HUF' => 'Ft', 'RON' => 'lei', + default => $currency, + }; + return $sym . number_format($amount, 2, '.', ''); +} + diff --git a/assets/css/style.css b/assets/css/style.css index e79042a..dfaf11e 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -516,6 +516,24 @@ body { color: var(--primary); } +/* Skeleton shimmer while stat card data is loading */ +@keyframes stat-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} +.stat-value.stat-loading { + color: transparent !important; + background: linear-gradient(90deg, var(--border) 25%, color-mix(in srgb, var(--border) 40%, white) 50%, var(--border) 75%); + background-size: 200% 100%; + animation: stat-shimmer 1.2s ease-in-out infinite; + border-radius: 8px; + min-width: 40px; + min-height: 2.4rem; + display: inline-block; + vertical-align: middle; + user-select: none; +} + .stat-label { font-size: 0.85rem; color: var(--text-light); @@ -1861,6 +1879,121 @@ body { .badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; } .badge-local-tag:hover { background: #bae6fd; } +/* ─── Shopping price badge ─── */ +.shopping-item-price-badge { + margin-top: 4px; + min-height: 1.2rem; +} +.price-badge-value { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + color: #15803d; + background: #dcfce7; + border-radius: 8px; + padding: 1px 7px; + white-space: nowrap; +} +.price-badge-loading { + display: inline-block; + font-size: 0.7rem; + color: #94a3b8; + font-style: italic; +} +.price-badge-error { + display: inline-block; + font-size: 0.65rem; + color: #94a3b8; +} + +/* ─── Price column — right-side per-item price display ─── */ +.shopping-item-price-col { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: center; + flex-shrink: 0; + min-width: 58px; + text-align: right; + padding: 0 4px; +} +.price-col-main { + font-size: 1.05rem; + font-weight: 700; + color: #15803d; + white-space: nowrap; + line-height: 1.25; +} +.price-col-unit { + font-size: 0.62rem; + color: #94a3b8; + white-space: nowrap; + line-height: 1.2; + margin-top: 1px; +} +.price-col-loading { + font-size: 0.7rem; + color: #cbd5e1; + font-style: italic; +} +.price-col-error { + font-size: 0.75rem; + color: #cbd5e1; +} + +/* ─── Price summary bar (top of shopping tab) ─── */ +.shopping-price-total-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px 4px; + background: linear-gradient(135deg, #f0fdf4, #dcfce7); + border-radius: 10px; + margin: 8px 0 4px; + font-size: 0.88rem; + font-weight: 600; + color: #166534; + box-shadow: 0 1px 4px rgba(21,128,61,0.08); +} +.price-total-label { + flex: 1; +} +.price-total-value { + font-size: 1rem; + font-weight: 700; +} +.btn-price-refresh { + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + padding: 2px 6px; + border-radius: 6px; + transition: background 0.15s; +} +.btn-price-refresh:hover { background: rgba(0,0,0,0.07); } +.btn-price-refresh:disabled { opacity: 0.5; cursor: default; } + +/* Loading progress bar */ +.price-loading-bar { + height: 3px; + background: #dcfce7; + border-radius: 2px; + overflow: hidden; + margin: 0 12px 6px; +} +.price-loading-inner { + height: 100%; + width: 0%; + background: #16a34a; + border-radius: 2px; + transition: width 0.3s ease; +} +@keyframes price-sweep { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(350%); } +} + /* Tag add button */ .shopping-item-tag-btn { background: none; diff --git a/assets/js/app.js b/assets/js/app.js index 6d2f943..7f600fd 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1258,34 +1258,35 @@ function guessCategoryFromName(name) { if (!name) return 'altro'; const n = name.toLowerCase(); // Pasta & Rice - if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio/.test(n)) return 'pasta'; + if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette/.test(n)) return 'pasta'; // Pane & Forno - if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli/.test(n)) return 'pane'; + if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli|pancarrè|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b/.test(n)) return 'pane'; + // Latticini (before bevande to avoid latte→bevande) + if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie|camembert|emmental|asiago|feta\b|provola|caciotta|caprino/.test(n)) return 'latticini'; // Conserve - if (/passata|pelati|pomodoro|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive/.test(n)) return 'conserve'; + if (/passata|pelati|pomodoro|pomodori|pomodorini|ciliegino|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive|tonno in scatola|sgombro in scatola|concentrato|brodo|dado|besciamella/.test(n)) return 'conserve'; // 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 (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'; + 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|pesto|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco/.test(n)) return 'condimenti'; + // Bevande (after latticini to avoid latte conflict) + if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino/.test(n)) return 'bevande'; // Carne (include salumi) - if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia/.test(n)) return 'carne'; + if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola/.test(n)) return 'carne'; // Pesce - if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi/.test(n)) return 'pesce'; + if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi|alici|branzino|orata|sardine|trota|dentice|seppia|polpo/.test(n)) return 'pesce'; // 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'; + if (/mela|mele|banana|arancia|pera|fragola|uva\b|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|fico\b|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta'; // 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'; + 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|cetriolo|rapa|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura'; // Surgelati - if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati'; - // Snack - if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini/.test(n)) return 'snack'; + if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati'; + // Snack & Dolci + if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack'; // Cereali - if (/cereali|muesli|fiocchi|granola|polenta/.test(n)) return 'cereali'; - // Igiene / Pulizia - if (/sapone|shampoo|dentifricio|deodorante/.test(n)) return 'igiene'; - if (/detersivo|pulito|sgrassatore/.test(n)) return 'pulizia'; + if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.test(n)) return 'cereali'; + // Igiene personale + if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene'; + // Pulizia casa + if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia'; return 'altro'; } @@ -1871,26 +1872,9 @@ function debounce(fn, ms) { async function syncSettingsFromDB() { try { - // Primary: load from server .env + // Primary: load from server .env (only when not already done via _applySyncedSettings) const serverSettings = await api('get_settings'); - _geminiAvailable = !!(serverSettings.gemini_key_set); - _demoMode = !!serverSettings.demo_mode; - _updateGeminiButtonState(); - _applyDemoModeUI(); - const s = getSettings(); - const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze', - 'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances', - 'camera_facing','scale_enabled','scale_gateway_url', - 'meal_plan_enabled','tts_enabled','tts_url','tts_token', - 'tts_method','tts_auth_type','tts_content_type','tts_payload_key', - 'screensaver_enabled','screensaver_timeout']; - for (const key of serverKeys) { - if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') { - s[key] = serverSettings[key]; - } - } - _settingsCache = s; - localStorage.setItem('evershelf_settings', JSON.stringify(s)); + _applySyncedSettings(serverSettings); // Also load review_confirmed from DB const res = await api('app_settings_get'); if (res.success && res.settings) { @@ -1901,6 +1885,37 @@ async function syncSettingsFromDB() { } catch(e) { /* offline, use local */ } } +/** + * Apply server settings object into localStorage cache. + * Called both from _initApp (to reuse an already-fetched response) and syncSettingsFromDB. + */ +function _applySyncedSettings(serverSettings) { + if (!serverSettings) return; + _geminiAvailable = !!(serverSettings.gemini_key_set); + _demoMode = !!serverSettings.demo_mode; + _updateGeminiButtonState(); + _applyDemoModeUI(); + const s = getSettings(); + const serverKeys = ['default_persons','pref_veloce','pref_pocafame','pref_scadenze', + 'pref_healthy','pref_opened','pref_zerowaste','dietary','appliances', + 'camera_facing','scale_enabled','scale_gateway_url', + 'meal_plan_enabled','tts_enabled','tts_url','tts_token', + 'tts_method','tts_auth_type','tts_content_type','tts_payload_key', + 'screensaver_enabled','screensaver_timeout', + 'price_enabled','price_country','price_currency','price_update_months']; + let changed = false; + for (const key of serverKeys) { + if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') { + s[key] = serverSettings[key]; + changed = true; + } + } + if (changed) { + _settingsCache = s; + localStorage.setItem('evershelf_settings', JSON.stringify(s)); + } +} + async function loadSettingsUI() { const s = getSettings(); document.getElementById('setting-gemini-key').value = s.gemini_key || ''; @@ -1993,7 +2008,8 @@ async function loadSettingsUI() { 'camera_facing','scale_enabled','scale_gateway_url', 'meal_plan_enabled', 'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type', - 'tts_content_type','tts_payload_key']; + 'tts_content_type','tts_payload_key', + 'price_enabled','price_country','price_currency','price_update_months']; // Note: gemini_key is never sent from server; settings_token_set is metadata only const settingsTokenRequired = !!serverSettings.settings_token_set; const tokenHintEl = document.getElementById('settings-token-status-hint'); @@ -2035,8 +2051,34 @@ async function loadSettingsUI() { if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp; if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none'; if (mpLegendCard) mpLegendCard.style.display = mpEnabledUp ? '' : 'none'; + // Price settings (server merge) + if (priceEnabledEl) { + priceEnabledEl.checked = !!s.price_enabled; + const pSub = document.getElementById('price-settings-sub'); + if (pSub) pSub.style.display = s.price_enabled ? '' : 'none'; + } + if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia'; + if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR'; + if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3; } } catch(e) { /* offline, use local */ } + // Price settings + const priceEnabledEl = document.getElementById('setting-price-enabled'); + if (priceEnabledEl) { + priceEnabledEl.checked = !!s.price_enabled; + const priceSubEl = document.getElementById('price-settings-sub'); + if (priceSubEl) priceSubEl.style.display = s.price_enabled ? '' : 'none'; + priceEnabledEl.onchange = function() { + const sub = document.getElementById('price-settings-sub'); + if (sub) sub.style.display = this.checked ? '' : 'none'; + }; + } + const priceCountryEl = document.getElementById('setting-price-country'); + if (priceCountryEl) priceCountryEl.value = s.price_country || 'Italia'; + const priceCurrencyEl = document.getElementById('setting-price-currency'); + if (priceCurrencyEl) priceCurrencyEl.value = s.price_currency || 'EUR'; + const priceMonthsEl = document.getElementById('setting-price-update-months'); + if (priceMonthsEl) priceMonthsEl.value = s.price_update_months || 3; // Scale settings const scaleEnabledUiEl = document.getElementById('setting-scale-enabled'); if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled; @@ -2358,6 +2400,15 @@ async function saveSettings() { if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked; const scaleUrlEl = document.getElementById('setting-scale-url'); if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim(); + // Price settings + const priceEnabledSaveEl = document.getElementById('setting-price-enabled'); + if (priceEnabledSaveEl) s.price_enabled = priceEnabledSaveEl.checked; + const priceCountrySaveEl = document.getElementById('setting-price-country'); + if (priceCountrySaveEl) s.price_country = priceCountrySaveEl.value; + const priceCurrencySaveEl = document.getElementById('setting-price-currency'); + if (priceCurrencySaveEl) s.price_currency = priceCurrencySaveEl.value; + const priceMonthsSaveEl = document.getElementById('setting-price-update-months'); + if (priceMonthsSaveEl) s.price_update_months = parseInt(priceMonthsSaveEl.value, 10) || 3; saveSettingsToStorage(s); // Save ALL settings to server .env @@ -2389,6 +2440,10 @@ async function saveSettings() { tts_auth_type: s.tts_auth_type, tts_content_type: s.tts_content_type, tts_payload_key: s.tts_payload_key, + price_enabled: s.price_enabled, + price_country: s.price_country, + price_currency: s.price_currency, + price_update_months: s.price_update_months, }, tokenHeader); const statusEl = document.getElementById('settings-status'); if (result.success) { @@ -2481,7 +2536,10 @@ function refreshCurrentPage() { switch(_currentPageId) { case 'dashboard': loadDashboard(); break; case 'inventory': loadInventory(); break; - case 'shopping': loadShoppingList(); break; + case 'shopping': + loadShoppingList._bgCall = true; + loadShoppingList(); + break; case 'products': loadAllProducts(); break; case 'recipe': loadRecipeArchive(); break; case 'log': loadLog(); break; @@ -3117,6 +3175,12 @@ function _applyInsightPhase() { // ===== DASHBOARD ===== async function loadDashboard() { + // Show shimmer on stat cards while loading + ['stat-dispensa', 'stat-frigo', 'stat-freezer'].forEach(id => { + const el = document.getElementById(id); + if (el) el.classList.add('stat-loading'); + }); + try { const [summaryData, statsData] = await Promise.all([ api('inventory_summary'), @@ -3131,6 +3195,7 @@ async function loadDashboard() { const count = s ? s.product_count : 0; const el = document.getElementById(`stat-${loc}`); el.textContent = count; + el.classList.remove('stat-loading'); total += count; }); // Add non-standard locations @@ -3218,8 +3283,11 @@ async function loadDashboard() { // Banner alerts (suspicious quantities + consumption predictions) loadBannerAlerts(); - // Anti-waste section (load facts first so rotation has full dataset) - await _awLoadFacts(); + // Anti-waste section + Nutrition section: load in parallel + const [, invForNutr] = await Promise.all([ + _awLoadFacts(), + api('inventory_list').then(d => d.inventory || []).catch(() => []), + ]); _renderAntiWasteSection( statsData.used_30d || 0, statsData.wasted_30d || 0, statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0, @@ -3229,10 +3297,7 @@ async function loadDashboard() { _startAntiWasteAutoRefresh(); // Nutrition section — built from the full inventory list - try { - const invForNutr = (await api('inventory_list')).inventory || []; - _renderNutritionSection(invForNutr); - } catch(_e) {} + _renderNutritionSection(invForNutr); _startInsightAlternation(); // Opened (partially used products with known package capacity) @@ -3334,6 +3399,11 @@ async function loadDashboard() { } catch (err) { console.error('Dashboard load error:', err); + // Remove shimmer even on error so numbers don't disappear forever + ['stat-dispensa', 'stat-frigo', 'stat-freezer'].forEach(id => { + const el = document.getElementById(id); + if (el) { el.classList.remove('stat-loading'); if (el.textContent === '') el.textContent = '-'; } + }); } } @@ -3546,8 +3616,12 @@ async function loadBannerAlerts() { if (noExpiryDismissed[pid]) return; // user said "no expiry needed" // Only flag perishable-looking categories or items with opened_at const cat = (item.category || '').toLowerCase(); + // Also infer category from name for items with missing/generic category + const guessedCat = guessCategoryFromName(item.name || ''); + const perishableGuessed = ['latticini','carne','pesce','frutta','verdura','surgelati'].includes(guessedCat); const likelyPerishable = item.opened_at || - PERISHABLE_CATS.some(c => cat.includes(c)); + PERISHABLE_CATS.some(c => cat.includes(c)) || + perishableGuessed; if (!likelyPerishable) return; _bannerQueue.push({ type: 'no_expiry', data: item }); }); @@ -8498,6 +8572,7 @@ function toggleShoppingTag(itemIdx, tag) { // ===== SCAN FROM SHOPPING LIST ===== function openScanForItem(idx) { + loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list const item = shoppingItems[idx]; if (!item) return; _spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx }; @@ -8660,6 +8735,284 @@ async function forceSyncBring() { showToast(`🔄 ${t('shopping.sync_done')}`, 'success'); } +// ───────────────────────────────────────────────────────────────── +// SHOPPING LIST PRICE ESTIMATION +// ───────────────────────────────────────────────────────────────── +let _pricesFetching = false; +/** In-memory price cache: survives list re-renders in the same session */ +// Price cache persisted in sessionStorage — survives SPA navigation, cleared on tab close. +// Each entry includes _qty/_unit metadata so stale estimated_totals auto-invalidate when qty changes. +let _cachedPrices = (() => { + try { return JSON.parse(sessionStorage.getItem('_pricecache') || '{}'); } catch { return {}; } +})(); + +/** + * Build the items payload for the price API from the current shoppingItems array. + * Tries to parse quantity/unit from the Bring! specification field. + */ +function _buildPricePayload() { + return shoppingItems.map((item) => { + // Look up the matching smart shopping item to get reliable qty/unit data. + // Bring! spec strings can be stale or free-text — don't trust them for calculations. + const nameLower = item.name.toLowerCase(); + const smart = (smartShoppingItems || []).find(s => + s.name.toLowerCase() === nameLower || + (s.shopping_name || '').toLowerCase() === nameLower + ); + + let quantity = smart?.suggested_qty || 1; + let unit = smart?.suggested_unit || smart?.unit || 'pz'; + let default_quantity = smart?.default_qty || 0; + let package_unit = smart?.package_unit || ''; + + // If no smart match, fall back to parsing the Bring! spec (last resort) + if (!smart) { + const spec = item.specification || ''; + const qtyMatch = spec.match(/(\d+(?:[.,]\d+)?)\s*(g|kg|ml|l|pz|conf|lt|liter|litre)\b/i); + if (qtyMatch) { + quantity = parseFloat(qtyMatch[1].replace(',', '.')); + unit = qtyMatch[2].toLowerCase(); + } + } + + return { name: item.name, quantity, unit, default_quantity, package_unit }; + }); +} + +/** + * Build HTML for a price badge column. + * @param {Object} entry — API response (price_per_unit, unit_label, estimated_total, source_note) + * @param {string} sym — currency symbol like "€" + */ +function _buildPriceBadgeHTML(entry, sym) { + const mainLabel = entry.estimated_total != null + ? `${sym}${entry.estimated_total.toFixed(2)}` + : `${sym}${entry.price_per_unit.toFixed(2)}`; + const unitLabel = entry.unit_label || ''; + const unitLine = unitLabel && entry.price_per_unit != null + ? `${sym}${entry.price_per_unit.toFixed(2)}/${unitLabel}` + : ''; + const title = entry.source_note || ''; + return `
${mainLabel}
` + + (unitLine ? `
${unitLine}
` : ''); +} + +/** + * Apply price badges from in-memory cache (_cachedPrices) to the current DOM. + * Returns { total, count } of items successfully applied. + * Skips entries whose cached qty/unit no longer matches current suggested qty. + */ +function _applyPriceBadgesFromCache() { + const s = getSettings(); + const sym = _currencySymbol(s.price_currency || 'EUR'); + let total = 0, count = 0; + // Build a quick name→{quantity,unit} map from current smart data + const qtyMap = {}; + for (const p of _buildPricePayload()) qtyMap[p.name] = p; + shoppingItems.forEach((item, idx) => { + const badge = document.getElementById(`price-badge-${idx}`); + if (!badge) return; + const entry = _cachedPrices[item.name]; + if (!entry) return; + // Validate qty/unit — if smart data changed, treat as uncached + const current = qtyMap[item.name]; + if (current && (entry._qty !== current.quantity || entry._unit !== current.unit)) return; + badge.innerHTML = _buildPriceBadgeHTML(entry, sym); + if (entry.estimated_total != null) { total += entry.estimated_total; count++; } + }); + return { total, count }; +} + +/** + * Apply price badges to shopping items in the DOM (legacy batch variant). + * @param {Object} prices — name → price entry from API + * @param {string} currency — currency symbol fallback + */ +function _applyPriceBadges(prices, currency) { + const sym = _currencySymbol(currency); + shoppingItems.forEach((item, idx) => { + const badge = document.getElementById(`price-badge-${idx}`); + if (!badge) return; + const entry = prices[item.name]; + if (!entry || entry.error) { + badge.innerHTML = ``; + return; + } + badge.innerHTML = _buildPriceBadgeHTML(entry, _currencySymbol(entry.currency || currency)); + }); +} + +function _currencySymbol(currency) { + const map = { + EUR: '€', USD: '$', GBP: '£', CHF: 'CHF ', + CAD: 'CA$', AUD: 'A$', BRL: 'R$', JPY: '¥', + SEK: 'kr', NOK: 'kr', DKK: 'kr', PLN: 'zł', + CZK: 'Kč', HUF: 'Ft', RON: 'lei', + }; + return map[currency?.toUpperCase()] || currency || '€'; +} + +/** + * Fetch prices for all shopping list items, one by one (real-time updates). + * Uses _cachedPrices for items already fetched this session (no API call needed). + * @param {boolean} forceRefresh — bypass all caches, re-fetch everything + */ +async function fetchAllPrices(forceRefresh = false) { + // Disable buttons immediately — even if we bail early, they stay disabled until + // the active fetch finishes and re-enables them in its finally block. + const fetchBtn = document.getElementById('btn-fetch-prices'); + const refreshBtn = document.getElementById('btn-price-refresh'); + if (fetchBtn) fetchBtn.disabled = true; + if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.textContent = '⏳'; } + + if (_pricesFetching) return; // already running — buttons will be re-enabled by active fetch + if (!shoppingItems.length) { + if (fetchBtn) fetchBtn.disabled = false; + if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } + return; + } + + const s = getSettings(); + if (!s.price_enabled) { + if (fetchBtn) fetchBtn.disabled = false; + if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } + return; + } + + _pricesFetching = true; + + const priceBar = document.getElementById('shopping-price-bar'); + const loadingBar = document.getElementById('price-loading-bar'); + const loadingInner = loadingBar ? loadingBar.querySelector('.price-loading-inner') : null; + const totalEl = document.getElementById('price-total-value'); + + if (priceBar) priceBar.style.display = 'block'; + + if (forceRefresh) { + // Full refresh: clear in-memory + sessionStorage cache, reset all badges to loading + _cachedPrices = {}; + try { sessionStorage.removeItem('_pricecache'); } catch { /* ignore */ } + shoppingItems.forEach((_, idx) => { + const badge = document.getElementById(`price-badge-${idx}`); + if (badge) badge.innerHTML = ``; + }); + if (totalEl) totalEl.textContent = t('shopping.price_loading'); + if (loadingBar) loadingBar.style.display = 'block'; + if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; } + } else { + // Incremental: apply cached prices instantly, mark uncached as loading + const { total: cachedTotal, count: cachedCount } = _applyPriceBadgesFromCache(); + shoppingItems.forEach((item, idx) => { + if (!_cachedPrices[item.name]) { + const badge = document.getElementById(`price-badge-${idx}`); + if (badge) badge.innerHTML = ``; + } + }); + const uncachedCount = shoppingItems.filter(i => !_cachedPrices[i.name]).length; + if (uncachedCount === 0) { + // All already cached — just show total and done + if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`; + _pricesFetching = false; + if (fetchBtn) fetchBtn.disabled = false; + if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } + return; + } + if (totalEl && cachedCount > 0) totalEl.textContent = `ca. ${_currencySymbol(s.price_currency || 'EUR')}${cachedTotal.toFixed(2)}`; + if (loadingBar) loadingBar.style.display = 'block'; + if (loadingInner) { loadingInner.style.transition = 'none'; loadingInner.style.width = '0%'; } + } + + const lang = s.language || 'it'; + const country = s.price_country || 'Italia'; + const currency = s.price_currency || 'EUR'; + const sym = _currencySymbol(currency); + const items = _buildPricePayload(); + const total = items.length; + // Running totals: only count items in the CURRENT shopping list with matching qty + let runningTotal = shoppingItems.reduce((sum, item) => { + const e = _cachedPrices[item.name]; + const pi = items.find(x => x.name === item.name); + if (!e || !pi || e._qty !== pi.quantity || e._unit !== pi.unit) return sum; + return sum + (e?.estimated_total || 0); + }, 0); + let pricesFound = shoppingItems.filter(i => { + const e = _cachedPrices[i.name]; + const pi = items.find(x => x.name === i.name); + return e && pi && e._qty === pi.quantity && e._unit === pi.unit && e.estimated_total != null; + }).length; + let processed = 0; + + try { + for (let i = 0; i < items.length; i++) { + if (!_pricesFetching) break; // guard: list was reloaded mid-fetch + + const item = items[i]; + const badge = document.getElementById(`price-badge-${i}`); + + // Skip if already cached with same qty/unit (and not forceRefresh) + const cached = _cachedPrices[item.name]; + if (!forceRefresh && cached && cached._qty === item.quantity && cached._unit === item.unit) { + processed++; + const progress = Math.round((processed / total) * 100); + if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; } + continue; + } + + try { + const data = await api('get_shopping_price', {}, 'POST', { + ...item, country, currency, lang, force_refresh: forceRefresh, + }); + + if (data && data.success) { + _cachedPrices[item.name] = { ...data, _qty: item.quantity, _unit: item.unit }; + if (badge) badge.innerHTML = _buildPriceBadgeHTML(data, sym); + if (data.estimated_total != null) { + runningTotal += data.estimated_total; + pricesFound++; + } + } else { + if (badge) badge.innerHTML = ``; + } + } catch (_err) { + if (badge) badge.innerHTML = ``; + } + + processed++; + const progress = Math.round((processed / total) * 100); + if (loadingInner) { loadingInner.style.transition = 'width 0.3s ease'; loadingInner.style.width = `${progress}%`; } + if (totalEl) { + totalEl.textContent = pricesFound > 0 + ? `ca. ${sym}${runningTotal.toFixed(2)}` + : t('shopping.price_loading'); + } + } + } finally { + _pricesFetching = false; + // Persist to sessionStorage so prices survive page navigation + try { sessionStorage.setItem('_pricecache', JSON.stringify(_cachedPrices)); } catch { /* quota */ } + if (loadingBar) loadingBar.style.display = 'none'; + if (totalEl) totalEl.textContent = pricesFound > 0 ? `ca. ${sym}${runningTotal.toFixed(2)}` : '–'; + if (fetchBtn) fetchBtn.disabled = false; + if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; } + } +} + +/** + const btn = document.getElementById('btn-force-sync'); + if (btn) { btn.disabled = true; btn.textContent = `⏳ ${t('shopping.syncing')}`; } + // 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('_autoAddedBring'); + logOperation('force_sync_bring', {}); + // Reload everything from scratch + await loadShoppingList(); + if (btn) { btn.disabled = false; btn.textContent = `🔄 ${t('shopping.force_sync')}`; } + showToast(`🔄 ${t('shopping.sync_done')}`, 'success'); +} + /** * One-time cleanup: remove items from Bring! that were auto-added but the algorithm no * longer considers relevant. CONSERVATIVE: only removes items that match a known product @@ -8912,7 +9265,8 @@ async function loadSmartShopping() { ); smartShoppingItems = data.items; _smartShoppingLastFetch = Date.now(); - // If the set of critical items changed, reset autoAdd/cleanup timers so + // NOTE: do NOT clear _cachedPrices here — qty validation (_qty/_unit metadata) + // handles stale entries automatically item by item. // they run with fresh data on next shopping page load const newCriticalNames = new Set(data.items.filter(i => i.urgency === 'critical').map(i => i.name)); const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) || @@ -9187,17 +9541,23 @@ async function addSmartToBring() { // Load just the shopping count for dashboard stat card async function loadShoppingCount() { + const el = document.getElementById('stat-spesa'); + if (el) el.classList.add('stat-loading'); try { const data = await api('bring_list'); - const el = document.getElementById('stat-spesa'); - if (data.success && data.purchase) { - el.textContent = data.purchase.length; - } else { - el.textContent = '-'; + if (el) { + if (data.success && data.purchase) { + el.textContent = data.purchase.length; + } else { + el.textContent = '-'; + } + el.classList.remove('stat-loading'); } } catch { - const el = document.getElementById('stat-spesa'); - el.textContent = '-'; + if (el) { + el.textContent = '-'; + el.classList.remove('stat-loading'); + } } // Smart urgency badge: use cached data if fresh (< 2 min), else fetch if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) { @@ -9321,6 +9681,36 @@ async function loadShoppingList() { const statusEl = document.getElementById('bring-status'); const currentEl = document.getElementById('shopping-current'); const suggestionsEl = document.getElementById('shopping-suggestions'); + + // Track last user interaction timestamp to avoid disrupting active use + if (!loadShoppingList._lastUserInteraction) loadShoppingList._lastUserInteraction = 0; + + // Background refresh: ALWAYS do a silent update — never show spinner or rebuild DOM + const isBackgroundCall = loadShoppingList._bgCall === true; + loadShoppingList._bgCall = false; + if (isBackgroundCall) { + try { + const data = await api('bring_list'); + if (data.success) { + const newItems = data.purchase || []; + const newNames = new Set(newItems.map(i => i.name.toLowerCase())); + const prevNames = new Set((shoppingItems || []).map(i => i.name.toLowerCase())); + const hasChanges = newItems.length !== shoppingItems.length || + [...newNames].some(n => !prevNames.has(n)) || + [...prevNames].some(n => !newNames.has(n)); + if (hasChanges) { + shoppingItems = newItems; + for (const name of Object.keys(_cachedPrices)) { + if (!newNames.has(name.toLowerCase())) delete _cachedPrices[name]; + } + _syncTagsFromBringSpec(); + renderShoppingItems(); + } + loadShoppingCount(); + } + } catch(_e) {} + return; + } statusEl.style.display = 'block'; statusEl.innerHTML = `
${t('shopping.bring_loading')}
`; @@ -9374,6 +9764,10 @@ async function loadShoppingList() { if (removedNames.length) _markBringPurchased(removedNames); } shoppingItems = newItems; + // Evict removed items from price cache so stale prices don't reappear + for (const name of Object.keys(_cachedPrices)) { + if (!newNames.has(name.toLowerCase())) delete _cachedPrices[name]; + } // Sync urgente local tags from Bring specification (items marked urgent by us or manually) _syncTagsFromBringSpec(); @@ -9515,6 +9909,8 @@ async function renderShoppingItems() { ).join('')} `; + const priceEnabled = getSettings().price_enabled; + html += `
${catIcon} @@ -9528,6 +9924,7 @@ async function renderShoppingItems() { ${_specDisplayText(item.specification) ? `
${escapeHtml(_specDisplayText(item.specification))}
` : ''} ${(urgencyBadge || freqBadge || localTagHtml) ? `
${urgencyBadge}${freqBadge}${localTagHtml}
` : ''}
+ ${priceEnabled ? `
` : ''}
@@ -9540,9 +9937,26 @@ async function renderShoppingItems() { } container.innerHTML = html; + + // Trigger async price loading if enabled + const s2 = getSettings(); + if (s2.price_enabled && shoppingItems.length > 0) { + document.getElementById('shopping-price-bar').style.display = 'block'; + document.getElementById('btn-fetch-prices').style.display = 'inline-flex'; + // Allow a new fetch (re-render may have happened while old fetch was running) + _pricesFetching = false; + // Immediately apply any prices already fetched this session — no flicker, no loading bar + _applyPriceBadgesFromCache(); + // Fetch only items not yet priced (or all if none are cached yet) + fetchAllPrices(false); + } else { + document.getElementById('shopping-price-bar').style.display = 'none'; + document.getElementById('btn-fetch-prices').style.display = 'none'; + } } function toggleShoppingTagMenu(btn) { + loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container'); if (!container) return; const isOpen = container.style.display !== 'none'; @@ -9552,6 +9966,7 @@ function toggleShoppingTagMenu(btn) { } async function removeBringItem(idx) { + loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list const item = shoppingItems[idx]; if (!item) return; try { @@ -10074,6 +10489,51 @@ async function _doUndoTransaction(id, type, name) { // ===== WEEKLY MEAL PLAN ===== +/** +/** + * Keywords to check in inventory names for each meal plan type. + * Mirror of PHP $typeKeywords in api/index.php. + */ +const MEAL_PLAN_TYPE_KEYWORDS = { + pesce: ['tonno','salmone','merluzzo','branzino','orata','sardine','acciughe','alici','gamberi','cozze','vongole','polpo','calamari','seppia','sgombro','trota','baccalà','dentice','spigola','pesce'], + carne: ['pollo','manzo','maiale','vitello','agnello','tacchino','salsiccia','hamburger','bistecca','cotoletta','pancetta','speck','carne','arrosto','filetto','lonza','braciola'], + pasta: ['pasta','spaghetti','penne','rigatoni','fusilli','tagliatelle','lasagne','farfalle','orecchiette','bucatini','linguine','maccheroni','gnocchi','pennette','bavette'], + riso: ['riso','basmati','arborio','carnaroli','parboiled'], + legumi: ['fagioli','ceci','lenticchie','piselli','fave','lupini','soia','legumi','borlotti','cannellini','azuki'], + uova: ['uova','uovo'], + formaggio: ['formaggio','parmigiano','mozzarella','ricotta','pecorino','grana','gorgonzola','scamorza','fontina','emmental','asiago','provola','provolone','taleggio','stracchino'], + pizza: ['farina','lievito','pizza','focaccia'], + affettati: ['prosciutto','salame','bresaola','mortadella','speck','coppa','affettati','wurstel','piadina'], + verdure: ['zucchine','zucchina','melanzane','peperoni','spinaci','cavolfiore','broccoli','carote','zucca','bietole','cavolo','carciofi','asparagi','lattuga','rucola','radicchio','finocchio','cipolla','porri','verdure'], + zuppa: ['brodo','zuppa','minestra','minestrone','orzo','farro','fagioli','ceci','lenticchie'], + insalata: ['insalata','lattuga','rucola','spinaci','radicchio','misticanza','valeriana','songino'], + pane: ['pane','pancarrè','baguette','toast','tramezzino','crackers','grissini','ciabatta'], + dolce: ['cioccolato','cacao','zucchero','miele','marmellata','nutella','savoiardi','biscotti','panna'], +}; + +/** + * Check if today's meal plan type has at least one ingredient in the inventory. + * Returns true if available (or type is unknown/libero), false if definitely missing. + */ +async function _checkMealPlanIngredientAvailable(typeId) { + if (!typeId || typeId === 'libero') return true; + const keywords = MEAL_PLAN_TYPE_KEYWORDS[typeId]; + if (!keywords || keywords.length === 0) return true; + try { + const data = await api('inventory_list'); + const items = (data.inventory || []).filter(i => parseFloat(i.quantity) > 0); + for (const item of items) { + const nameLower = (item.name + ' ' + (item.brand || '')).toLowerCase(); + for (const kw of keywords) { + if (nameLower.includes(kw)) return true; + } + } + return false; + } catch { + return true; // on error, assume available to avoid blocking UI + } +} + /** * All selectable meal categories per slot. * id must be URL-safe; icon + label shown in UI. @@ -10157,6 +10617,21 @@ function onMealPlanEnabledChange(el) { if (picker) picker.style.display = 'none'; } +function onPriceCountryChange() { + // Auto-suggest currency based on country + const countryEl = document.getElementById('setting-price-country'); + const currencyEl = document.getElementById('setting-price-currency'); + if (!countryEl || !currencyEl) return; + const map = { + 'USA': 'USD', 'UK': 'GBP', 'Switzerland': 'CHF', 'Canada': 'CAD', + 'Australia': 'AUD', 'Brazil': 'BRL', 'Japan': 'JPY', 'Sweden': 'SEK', + 'Norway': 'NOK', 'Denmark': 'DKK', 'Poland': 'PLN', + }; + const suggested = map[countryEl.value]; + if (suggested) currencyEl.value = suggested; + // Default to EUR for EU countries +} + /** * Render the weekly meal plan editor into #meal-plan-grid. * Each cell shows the current type badge + a picker dropdown. @@ -11572,6 +12047,17 @@ function _renderMealPlanHint(mealSlot) { if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label}`; if (chipCb) chipCb.checked = true; } + + // Async: check if the required ingredient is actually in inventory. + // If not, disable the chip and warn the user. + _checkMealPlanIngredientAvailable(typeId).then(available => { + if (!available && chipWrap && chipWrap.style.display !== 'none') { + if (chipCb) { chipCb.checked = false; chipCb.disabled = true; } + if (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label} ⚠️ ${t('meal_plan.not_available') || 'non disponibile'}`; + chipWrap.style.opacity = '0.5'; + if (banner) banner.style.display = 'none'; + } + }).catch(() => {/* ignore */}); } function regenerateRecipe() { @@ -12841,6 +13327,8 @@ async function _initApp() { if (missing.length > 0 && !_demoMode) { showSetupWizard(missing); } + // Reuse the already-fetched serverSettings to avoid a second get_settings request + _applySyncedSettings(serverSettings); } // Migrate old session-based flags to time-based @@ -12852,6 +13340,7 @@ async function _initApp() { localStorage.removeItem('_bgBringSyncTs'); localStorage.setItem('_bgBringSyncReset_v1', '1'); } + // syncSettingsFromDB only needs to fetch app_settings_get for review flags now syncSettingsFromDB().then(() => { scaleInit(); // connect to smart scale gateway if configured (needs settings) initInactivityWatcher(); @@ -12883,6 +13372,7 @@ async function _initApp() { setInterval(() => { if (_screensaverActive) return; if (_currentPageId === 'shopping') { + loadShoppingList._bgCall = true; loadShoppingList(); } else { loadShoppingCount(); @@ -12892,6 +13382,8 @@ async function _initApp() { // 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app) document.addEventListener('visibilitychange', () => { if (!document.hidden) { + // Always treat visibility restore as a background call for shopping + if (_currentPageId === 'shopping') loadShoppingList._bgCall = true; refreshCurrentPage(); _checkWebappUpdate(); // also check for app updates when user returns to tab } diff --git a/data/bring_catalog.json b/data/bring_catalog.json deleted file mode 100644 index 0beb04a..0000000 --- a/data/bring_catalog.json +++ /dev/null @@ -1,645 +0,0 @@ -{ - "de2it": { - "Getreideriegel": "Barretta ai cereali", - "Glasreiniger": "Pulizia vetri", - "Gartenwerkzeug": "Atrezzi da giardino", - "Getränke": "Bibite", - "Hackfleisch": "Carne macinata", - "Baumarkt & Garten": "Fai da te & Giardino", - "Kekse": "Biscotti", - "Salami": "Salame", - "Lippenpomade": "Burrocacao", - "Putzmittel": "Detergente", - "Samen": "Sementi", - "Wassermelone": "Anguria", - "Schokolade": "Cioccolato", - "Fertig- & Tiefkühlprodukte": "Piatti Pronti & Surgelati", - "Käse": "Formaggio", - "Giesskanne": "Annaffiatoio", - "Bratwurst": "Wurstel", - "Fenchel": "Finocchio", - "Fruchtsaft": "Succo di frutta", - "Grissini": "Grissini", - "Brokkoli": "Broccoli", - "Eistee": "Tè freddo", - "Haarspray": "Spray", - "Pflaumen": "Susina", - "Pommes Chips": "Patatine", - "Schweinefleisch": "Carne di maiale", - "Backpapier": "Carta da forno", - "Brot": "Pane", - "Orangensaft": "Succo d'arancia", - "Geschirrsalz": "Sale Lavastoviglie", - "Gipfeli": "Cornetti", - "Birnen": "Pere", - "Eier": "Uova", - "Makeup Entferner": "Struccante", - "Steinpilze": "Porcini", - "Kartoffeln": "Patate", - "Rasierklingen": "Ricambi rasoio", - "Gemüse": "Verdure", - "Kaffee": "Caffè", - "Frischkäse": "Formaggio cremoso", - "Zutaten & Gewürze": "Ingredienti & Spezie", - "Öl": "Olio", - "Trauben": "Uva", - "Salz": "Sale", - "Balsamico": "Aceto Balsamico", - "Fisch": "Pesce", - "Radicchio": "Radicchio", - "Geschenk": "Regalo", - "Blumen": "Fiori", - "Limonade": "Bibite", - "Schwamm": "Spugna", - "Limette": "Limone verde", - "Aubergine": "Melanzana", - "Schinken": "Prosciutto cotto", - "Zucchetti": "Zucchine", - "Rum": "Rum", - "Frühlingszwiebeln": "Cipollotti", - "Spargel": "Asparagi", - "Sonnencreme": "Crema da sole", - "Gnocchi": "Gnocchi", - "Handcreme": "Crema mani", - "Schnittlauch": "Erba Cipollina", - "Snacks & Süsswaren": "Snack & Dolci", - "Pelati": "Pelati", - "Fischstäbli": "Bastoncini di pesce", - "Margarine": "Margarina", - "Fleisch & Fisch": "Carne & Pesce", - "Zigaretten": "Sigarette", - "Oregano": "Origano", - "Basmatireis": "Riso Basmati", - "Zahnseide": "Filo interdentale", - "Tofu": "Tofu", - "Energy Drink": "Energy Drink", - "Peperoni": "Peperoni", - "Sirup": "Sciroppo", - "Feigen": "Fichi", - "Haselnüsse": "Nocciole", - "Mehl": "Farina", - "Haferflocken": "Avena", - "Apfelmus": "Composta di mele", - "Reis": "Riso", - "Mascarpone": "Mascarpone", - "Rasenmäher": "Tosaerba", - "Schnitzel": "Scaloppine", - "Grill": "Griglia", - "Ketchup": "Ketchup", - "Lachs": "Salmone", - "Zwiebeln": "Cipolle", - "Beeren": "Bacche", - "Pflaster": "Cerotti", - "Fischfutter": "Mangime per pesci", - "Kerzen": "Candele", - "Früchte": "Frutta", - "Kalbfleisch": "Carne di vitello", - "Rasierschaum": "Schiuma da barba", - "Ingwer": "Zenzero", - "Pfirsich": "Pesca", - "Sauerrahm": "Panna Acidula", - "Früchte & Gemüse": "Frutta & Verdura", - "Lasagne": "Lasagne", - "Pinsel": "Pennello", - "Hefe": "Lievito", - "Kuchen": "Torta", - "Prosecco": "Prosecco", - "Tampons": "Assorbenti", - "Thunfisch": "Pesce Tonno", - "Zucker": "Zucchero", - "Piadina": "Piadina", - "Pouletbrüstli": "Petto di pollo", - "Kastanien": "Castagne", - "Blumenkohl": "Cavolfiore", - "Salat": "Insalata", - "Fleisch": "Carne", - "Corn Flakes": "Cereali colazione", - "Merendina": "Merendina", - "Knoblauch": "Aglio", - "Karotten": "Carote", - "Toast": "Toast", - "Waschmittel": "Detersivo lavatrice", - "Salatsauce": "Condimento insalata", - "Hundefutter": "Cibo per cani", - "Vanillezucker": "Zucchero vanigliato", - "Mundspülung": "Collutorio", - "Babynahrung": "Alimenti Bimbi", - "Windeln": "Pannolini", - "Kondome": "Preservativo", - "Couscous": "Couscous", - "Geschirrglanz": "Brillantante", - "Aprikosen": "Albicocche", - "Himbeeren": "Lamponi", - "Oliven": "Olive", - "Lebkuchen": "Panpepato", - "Getreideprodukte": "Pasta, Riso & Cereali", - "Kürbis": "Zucca", - "Tonic Water": "Tonic", - "Nektarine": "Pesche noci", - "Penne": "Penne", - "Shampoo": "Shampoo", - "Whisky": "Whisky", - "Datteln": "Datteri", - "Kakao": "Cacao", - "Olivenöl": "Olio d'oliva", - "Bohnen": "Fagioli", - "Pizza": "Pizza", - "Kiwi": "Kiwi", - "Poulet": "Pollo", - "Wasser": "Acqua", - "Pasta": "Pasta", - "Milch": "Latte", - "Kirschen": "Ciliegie", - "Mandeln": "Mandorle", - "Milch & Käse": "Latte & Formaggi", - "Kichererbsen": "Ceci", - "Kosmetiktücher": "Kleenex", - "Kaugummi": "Gomma da masticare", - "Gesichtscreme": "Crema viso", - "getrocknete Tomaten": "Pomodori secchi", - "Champignons": "Champignons", - "Cola Light": "Cola Light", - "Orange": "Arance", - "Alufolie": "Foglio di alluminio", - "Melone": "Melone", - "Bananen": "Banane", - "Zahnbürsten": "Spazzolino", - "Zimt": "Cannella", - "Äpfel": "Mele", - "Cola": "Cola", - "Bouillon": "Brodo", - "Salbei": "Salvia", - "Soyasauce": "Salsa di soia", - "Rohschinken": "Prosciutto crudo", - "Reibkäse": "Formaggio grattugiato", - "Aufschnitt": "Affettato", - "Geschirrtabs": "Past. Lavastoviglie", - "Sonnenschirm": "Ombrellone", - "Bresaola": "Bresaola", - "Mineralwasser": "Acqua minerale", - "Taschentücher": "Fazzoletti", - "Haushalt & Gesundheit": "Casa & Igiene", - "Feuchttücher": "Salviette", - "Erbsen": "Piselli", - "Parmesan": "Parmigiano", - "Nougatcreme": "Crema gianduia", - "Speck": "Pancetta", - "Tierbedarf": "Animali", - "Avocado": "Avocado", - "Paprikapulver": "Paprica", - "Abfallsäcke": "Sacchi della spazzatura", - "Essig": "Aceto", - "Dünger": "Concime", - "Pilze": "Funghi", - "Batterien": "Batterie", - "Tomatensauce": "Sugo di pomodoro", - "Rucola": "Rucola", - "Bier": "Birra", - "Blumenerde": "Terriccio", - "Rhabarber": "Rabarbaro", - "Artischocken": "Carciofi", - "Rosmarin": "Rosmarino", - "Thon": "Tonno", - "Linsenmittel": "Soluzione lenti", - "Nagellackentferner": "Acetone", - "Bodylotion": "Crema corpo", - "Apfelsaft": "Succo di mela", - "Guetzli": "Biscotti di Natale", - "Tomatenmark": "Concentrato Pomodoro", - "Gurke": "Cetriolo", - "Holzkohle": "Carbonella", - "Basilikum": "Basilico", - "Joghurt": "Yogurt", - "Getränke & Tabak": "Bevande & Tabacco", - "Pop Corn": "Popcorn", - "Weichspüler": "Ammorbidente", - "Butter": "Burro", - "Rotwein": "Vino Rosso", - "Frankfurter": "Luganega", - "Schnaps": "Grappa", - "Tomaten": "Pomodori", - "Ricotta": "Ricotta", - "Watterondellen": "Dischetti di cotone", - "Erdbeeren": "Fragole", - "Vogelfutter": "Cibo per uccelli", - "Thymian": "Timo", - "Puderzucker": "Zucchero a velo", - "Kräuterbutter": "Burro alle erbe", - "Kaki": "Cachi", - "Erdnüsse": "Arachidi", - "Pfefferkörner": "Grani di pepe", - "Schrauben": "Viti", - "Sardellen": "Acciughe", - "Rindfleisch": "Carne di manzo", - "Conditioner": "Balsamo", - "Pizzateig": "Pasta per pizza", - "Zitrone": "Limone", - "Nägel": "Chiodi", - "Peperoncini": "Peperoncini", - "Senf": "Senape", - "Brötchen": "Panini", - "Baumnüsse": "Noci", - "Nudeln": "Tagliatelle", - "Wurst": "Salsiccia", - "Pudding": "Pudding", - "Griess": "Semolino", - "Mandarinen": "Mandarini", - "Weisswein": "Vino bianco", - "Blätterteig": "Pasta Sfoglia", - "Cherrytomaten": "Pomodorini", - "Pfefferminze": "Menta", - "Katzenstreu": "Sabbia gatti", - "Zwetschgen": "Prugne", - "Brombeeren": "More", - "Gin": "Gin", - "Vodka": "Vodka", - "Honig": "Miele", - "WC-Papier": "Carta igienica", - "Brot & Gebäck": "Panetteria", - "Paniermehl": "Pangrattato", - "Abwaschmittel": "Detersivo Piatti", - "Rahm": "Panna", - "Mayonnaise": "Maionese", - "Spülmittel": "Detersivo", - "Sellerie": "Sedano", - "Lauch": "Porro", - "Rindsgeschnetzeltes": "Sminuzzato manzo", - "WC-Reiniger": "Detergente per WC", - "Baguette": "Baguette", - "Konfitüre": "Marmellata", - "Schmerzmittel": "Analgesico", - "Badreiniger": "Pulizia bagno", - "Mango": "Mango", - "Mozzarella": "Mozzarella", - "Ananas": "Ananas", - "Propangas": "Propano", - "Bratensauce": "Salsa per arrosto", - "Orecchiette": "Orecchiette", - "Lamm": "Agnello", - "Frischhaltefolie": "Pellicole", - "Zahnpasta": "Dentifricio", - "Spaghetti": "Spaghetti", - "Haargel": "Gel Styling", - "Snacks": "Snack", - "Petersilie": "Prezzemolo", - "Grapefruit": "Pompelmo", - "Grana Padano": "Grana Padano", - "Servietten": "Tovaglioli", - "Töpfe": "Vasi", - "Linsen": "Lenticchie", - "Duschmittel": "Crema doccia", - "Gorgonzola": "Gorgonzola", - "Spinat": "Spinaci", - "Backpulver": "Bicarbonato", - "Risottoreis": "Risotto", - "Rasierer": "Rasoio", - "Pommes Frites": "Patate fritte", - "Deo": "Deodorante", - "Pflanzen": "Piante", - "Katzenfutter": "Cibo per gatti", - "Geschenkpapier": "Carta da regalo", - "Tee": "Tè", - "Wattestäbchen": "Bastoncini cotonati", - "Kräuter": "Erbe", - "Seife": "Sapone", - "Glacé": "Gelato", - "Mais": "Mais", - "Haushaltspapier": "Carta domestica", - "Polenta": "Polenta", - "Eigene Artikel": "Tuoi articoli", - "Zuletzt verwendet": "Utilizzato per ultimo" - }, - "it2de": { - "barretta ai cereali": "Getreideriegel", - "pulizia vetri": "Glasreiniger", - "atrezzi da giardino": "Gartenwerkzeug", - "bibite": "Limonade", - "carne macinata": "Hackfleisch", - "fai da te & giardino": "Baumarkt & Garten", - "biscotti": "Kekse", - "salame": "Salami", - "burrocacao": "Lippenpomade", - "detergente": "Putzmittel", - "sementi": "Samen", - "anguria": "Wassermelone", - "cioccolato": "Schokolade", - "piatti pronti & surgelati": "Fertig- & Tiefkühlprodukte", - "formaggio": "Käse", - "annaffiatoio": "Giesskanne", - "wurstel": "Bratwurst", - "finocchio": "Fenchel", - "succo di frutta": "Fruchtsaft", - "grissini": "Grissini", - "broccoli": "Brokkoli", - "tè freddo": "Eistee", - "spray": "Haarspray", - "susina": "Pflaumen", - "patatine": "Pommes Chips", - "carne di maiale": "Schweinefleisch", - "carta da forno": "Backpapier", - "pane": "Brot", - "succo d'arancia": "Orangensaft", - "sale lavastoviglie": "Geschirrsalz", - "cornetti": "Gipfeli", - "pere": "Birnen", - "uova": "Eier", - "struccante": "Makeup Entferner", - "porcini": "Steinpilze", - "patate": "Kartoffeln", - "ricambi rasoio": "Rasierklingen", - "verdure": "Gemüse", - "caffè": "Kaffee", - "formaggio cremoso": "Frischkäse", - "ingredienti & spezie": "Zutaten & Gewürze", - "olio": "Öl", - "uva": "Trauben", - "sale": "Salz", - "aceto balsamico": "Balsamico", - "pesce": "Fisch", - "radicchio": "Radicchio", - "regalo": "Geschenk", - "fiori": "Blumen", - "spugna": "Schwamm", - "limone verde": "Limette", - "melanzana": "Aubergine", - "prosciutto cotto": "Schinken", - "zucchine": "Zucchetti", - "rum": "Rum", - "cipollotti": "Frühlingszwiebeln", - "asparagi": "Spargel", - "crema da sole": "Sonnencreme", - "gnocchi": "Gnocchi", - "crema mani": "Handcreme", - "erba cipollina": "Schnittlauch", - "snack & dolci": "Snacks & Süsswaren", - "pelati": "Pelati", - "bastoncini di pesce": "Fischstäbli", - "margarina": "Margarine", - "carne & pesce": "Fleisch & Fisch", - "sigarette": "Zigaretten", - "origano": "Oregano", - "riso basmati": "Basmatireis", - "filo interdentale": "Zahnseide", - "tofu": "Tofu", - "energy drink": "Energy Drink", - "peperoni": "Peperoni", - "sciroppo": "Sirup", - "fichi": "Feigen", - "nocciole": "Haselnüsse", - "farina": "Mehl", - "avena": "Haferflocken", - "composta di mele": "Apfelmus", - "riso": "Reis", - "mascarpone": "Mascarpone", - "tosaerba": "Rasenmäher", - "scaloppine": "Schnitzel", - "griglia": "Grill", - "ketchup": "Ketchup", - "salmone": "Lachs", - "cipolle": "Zwiebeln", - "bacche": "Beeren", - "cerotti": "Pflaster", - "mangime per pesci": "Fischfutter", - "candele": "Kerzen", - "frutta": "Früchte", - "carne di vitello": "Kalbfleisch", - "schiuma da barba": "Rasierschaum", - "zenzero": "Ingwer", - "pesca": "Pfirsich", - "panna acidula": "Sauerrahm", - "frutta & verdura": "Früchte & Gemüse", - "lasagne": "Lasagne", - "pennello": "Pinsel", - "lievito": "Hefe", - "torta": "Kuchen", - "prosecco": "Prosecco", - "assorbenti": "Tampons", - "pesce tonno": "Thunfisch", - "zucchero": "Zucker", - "piadina": "Piadina", - "petto di pollo": "Pouletbrüstli", - "castagne": "Kastanien", - "cavolfiore": "Blumenkohl", - "insalata": "Salat", - "carne": "Fleisch", - "cereali colazione": "Corn Flakes", - "merendina": "Merendina", - "aglio": "Knoblauch", - "carote": "Karotten", - "toast": "Toast", - "detersivo lavatrice": "Waschmittel", - "condimento insalata": "Salatsauce", - "cibo per cani": "Hundefutter", - "zucchero vanigliato": "Vanillezucker", - "collutorio": "Mundspülung", - "alimenti bimbi": "Babynahrung", - "pannolini": "Windeln", - "preservativo": "Kondome", - "couscous": "Couscous", - "brillantante": "Geschirrglanz", - "albicocche": "Aprikosen", - "lamponi": "Himbeeren", - "olive": "Oliven", - "panpepato": "Lebkuchen", - "pasta, riso & cereali": "Getreideprodukte", - "zucca": "Kürbis", - "tonic": "Tonic Water", - "pesche noci": "Nektarine", - "penne": "Penne", - "shampoo": "Shampoo", - "whisky": "Whisky", - "datteri": "Datteln", - "cacao": "Kakao", - "olio d'oliva": "Olivenöl", - "fagioli": "Bohnen", - "pizza": "Pizza", - "kiwi": "Kiwi", - "pollo": "Poulet", - "acqua": "Wasser", - "pasta": "Pasta", - "latte": "Milch", - "ciliegie": "Kirschen", - "mandorle": "Mandeln", - "latte & formaggi": "Milch & Käse", - "ceci": "Kichererbsen", - "kleenex": "Kosmetiktücher", - "gomma da masticare": "Kaugummi", - "crema viso": "Gesichtscreme", - "pomodori secchi": "getrocknete Tomaten", - "champignons": "Champignons", - "cola light": "Cola Light", - "arance": "Orange", - "foglio di alluminio": "Alufolie", - "melone": "Melone", - "banane": "Bananen", - "spazzolino": "Zahnbürsten", - "cannella": "Zimt", - "mele": "Äpfel", - "cola": "Cola", - "brodo": "Bouillon", - "salvia": "Salbei", - "salsa di soia": "Soyasauce", - "prosciutto crudo": "Rohschinken", - "formaggio grattugiato": "Reibkäse", - "affettato": "Aufschnitt", - "past. lavastoviglie": "Geschirrtabs", - "ombrellone": "Sonnenschirm", - "bresaola": "Bresaola", - "acqua minerale": "Mineralwasser", - "fazzoletti": "Taschentücher", - "casa & igiene": "Haushalt & Gesundheit", - "salviette": "Feuchttücher", - "piselli": "Erbsen", - "parmigiano": "Parmesan", - "crema gianduia": "Nougatcreme", - "pancetta": "Speck", - "animali": "Tierbedarf", - "avocado": "Avocado", - "paprica": "Paprikapulver", - "sacchi della spazzatura": "Abfallsäcke", - "aceto": "Essig", - "concime": "Dünger", - "funghi": "Pilze", - "batterie": "Batterien", - "sugo di pomodoro": "Tomatensauce", - "rucola": "Rucola", - "birra": "Bier", - "terriccio": "Blumenerde", - "rabarbaro": "Rhabarber", - "carciofi": "Artischocken", - "rosmarino": "Rosmarin", - "tonno": "Thon", - "soluzione lenti": "Linsenmittel", - "acetone": "Nagellackentferner", - "crema corpo": "Bodylotion", - "succo di mela": "Apfelsaft", - "biscotti di natale": "Guetzli", - "concentrato pomodoro": "Tomatenmark", - "cetriolo": "Gurke", - "carbonella": "Holzkohle", - "basilico": "Basilikum", - "yogurt": "Joghurt", - "bevande & tabacco": "Getränke & Tabak", - "popcorn": "Pop Corn", - "ammorbidente": "Weichspüler", - "burro": "Butter", - "vino rosso": "Rotwein", - "luganega": "Frankfurter", - "grappa": "Schnaps", - "pomodori": "Tomaten", - "ricotta": "Ricotta", - "dischetti di cotone": "Watterondellen", - "fragole": "Erdbeeren", - "cibo per uccelli": "Vogelfutter", - "timo": "Thymian", - "zucchero a velo": "Puderzucker", - "burro alle erbe": "Kräuterbutter", - "cachi": "Kaki", - "arachidi": "Erdnüsse", - "grani di pepe": "Pfefferkörner", - "viti": "Schrauben", - "acciughe": "Sardellen", - "carne di manzo": "Rindfleisch", - "balsamo": "Conditioner", - "pasta per pizza": "Pizzateig", - "limone": "Zitrone", - "chiodi": "Nägel", - "peperoncini": "Peperoncini", - "senape": "Senf", - "panini": "Brötchen", - "noci": "Baumnüsse", - "tagliatelle": "Nudeln", - "salsiccia": "Wurst", - "pudding": "Pudding", - "semolino": "Griess", - "mandarini": "Mandarinen", - "vino bianco": "Weisswein", - "pasta sfoglia": "Blätterteig", - "pomodorini": "Cherrytomaten", - "menta": "Pfefferminze", - "sabbia gatti": "Katzenstreu", - "prugne": "Zwetschgen", - "more": "Brombeeren", - "gin": "Gin", - "vodka": "Vodka", - "miele": "Honig", - "carta igienica": "WC-Papier", - "panetteria": "Brot & Gebäck", - "pangrattato": "Paniermehl", - "detersivo piatti": "Abwaschmittel", - "panna": "Rahm", - "maionese": "Mayonnaise", - "detersivo": "Spülmittel", - "sedano": "Sellerie", - "porro": "Lauch", - "sminuzzato manzo": "Rindsgeschnetzeltes", - "detergente per wc": "WC-Reiniger", - "baguette": "Baguette", - "marmellata": "Konfitüre", - "analgesico": "Schmerzmittel", - "pulizia bagno": "Badreiniger", - "mango": "Mango", - "mozzarella": "Mozzarella", - "ananas": "Ananas", - "propano": "Propangas", - "salsa per arrosto": "Bratensauce", - "orecchiette": "Orecchiette", - "agnello": "Lamm", - "pellicole": "Frischhaltefolie", - "dentifricio": "Zahnpasta", - "spaghetti": "Spaghetti", - "gel styling": "Haargel", - "snack": "Snacks", - "prezzemolo": "Petersilie", - "pompelmo": "Grapefruit", - "grana padano": "Grana Padano", - "tovaglioli": "Servietten", - "vasi": "Töpfe", - "lenticchie": "Linsen", - "crema doccia": "Duschmittel", - "gorgonzola": "Gorgonzola", - "spinaci": "Spinat", - "bicarbonato": "Backpulver", - "risotto": "Risottoreis", - "rasoio": "Rasierer", - "patate fritte": "Pommes Frites", - "deodorante": "Deo", - "piante": "Pflanzen", - "cibo per gatti": "Katzenfutter", - "carta da regalo": "Geschenkpapier", - "tè": "Tee", - "bastoncini cotonati": "Wattestäbchen", - "erbe": "Kräuter", - "sapone": "Seife", - "gelato": "Glacé", - "mais": "Mais", - "carta domestica": "Haushaltspapier", - "polenta": "Polenta", - "tuoi articoli": "Eigene Artikel", - "utilizzato per ultimo": "Zuletzt verwendet", - "aroma": "Zutaten & Gewürze", - "ingredienti spezie": "Zutaten & Gewürze", - "bevande": "Getränke & Tabak", - "camomilla": "Tee", - "cioccolata calda": "Kakao", - "cipolla": "Zwiebeln", - "cracker": "Snacks & Süsswaren", - "farina integrale": "Mehl", - "fette biscottate": "Toast", - "filetto": "Fleisch", - "liquore": "Getränke & Tabak", - "muesli": "Corn Flakes", - "panna da cucina": "Rahm", - "passata": "Pelati", - "piatti pronti": "Fertig- & Tiefkühlprodukte", - "polpa di pomodoro": "Pelati", - "purè": "Fertig- & Tiefkühlprodukte", - "salsa": "Zutaten & Gewürze", - "sfornatini": "Fertig- & Tiefkühlprodukte", - "snack dolci": "Snacks & Süsswaren", - "succo": "Fruchtsaft", - "taralli": "Snacks & Süsswaren", - "vino": "Rotwein", - "zucchero di canna": "Zucker" - } -} \ No newline at end of file diff --git a/data/bring_migrate_ts.json b/data/bring_migrate_ts.json deleted file mode 100644 index e6fcf3f..0000000 --- a/data/bring_migrate_ts.json +++ /dev/null @@ -1 +0,0 @@ -{"ts":1777391782} \ No newline at end of file diff --git a/index.html b/index.html index f37bccc..dc2138d 100644 --- a/index.html +++ b/index.html @@ -630,6 +630,17 @@
+ + @@ -785,6 +799,67 @@
+ +
+

💰 Stima Prezzi (AI)

+

Mostra il costo stimato di ogni prodotto nella lista della spesa usando l'AI.

+
+ +
+ +
diff --git a/translations/de.json b/translations/de.json index d09a314..687121d 100644 --- a/translations/de.json +++ b/translations/de.json @@ -103,11 +103,13 @@ "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_no_expiry_title": "Ablaufdatum fehlt: {name}", + "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_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", @@ -133,8 +135,7 @@ "banner_anomaly_phantom_title": "mehr Bestand als erwartet", "banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?", "banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht", - "banner_anomaly_untracked_detail": "Du hast {inv_qty} {unit} im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach." -, + "banner_anomaly_untracked_detail": "Du hast {inv_qty} {unit} im Bestand, aber die gebuchten Abgänge übersteigen die Eingänge — der Anfangsbestand wurde wahrscheinlich nie als \"Eingang\" erfasst. Bitte korrigiere die Menge oder trage die fehlenden Eingänge nach.", "banner_anomaly_ghost_title": "weniger Bestand als erwartet", "banner_anomaly_ghost_detail": "Laut Buchungen solltest du {expected_qty} {unit} von {name} haben, aber der Bestand zeigt nur {inv_qty} {unit}. Hast du etwas ohne Buchung entnommen?", "consumed": "Verbraucht: {n} ({pct}%)", @@ -161,7 +162,8 @@ "label_quantity": "📦 Menge", "label_added": "📅 Hinzugefügt", "empty_text": "Keine Produkte hier.
Scanne ein Produkt, um es hinzuzufügen!", - "empty_db": "Keine Produkte in der Datenbank.
Scanne ein Produkt, um loszulegen!" + "empty_db": "Keine Produkte in der Datenbank.
Scanne ein Produkt, um loszulegen!", + "qty_trace": "< 1" }, "scan": { "title": "Produkt scannen", @@ -344,7 +346,7 @@ "suggestions_title": "💡 KI-Vorschläge", "suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen", "search_prices": "🔍 Alle Preise suchen", - "suggest_btn": "🤖 Einkaufsvorschläge", + "suggest_btn": "Einkaufsvorschläge", "smart_title": "🧠 Intelligente Vorhersagen", "smart_empty": "Keine Vorhersagen verfügbar.
Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.", "smart_filter_all": "Alle", @@ -416,6 +418,10 @@ "savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten", "searching_progress": "Suche {current}/{total}...", "remove_error": "Fehler beim Entfernen", + "btn_fetch_prices": "Preise suchen", + "price_total_label": "💰 Geschätzter Gesamtpreis:", + "price_loading": "Preise werden gesucht…", + "price_not_found": "Preis n/v", "suggest_loading": "Analyse läuft...", "suggest_error": "Fehler bei der Vorschlagserstellung", "priority_high": "Hoch", @@ -508,6 +514,15 @@ "email_label": "📧 Bring! E-Mail", "password_label": "🔒 Bring! Passwort" }, + "price": { + "title": "💰 Preisschätzung (KI)", + "hint": "Zeigt geschätzte Kosten pro Produkt in der Einkaufsliste mithilfe von KI an.", + "enabled_label": "Preisschätzung aktivieren", + "country_label": "🌍 Referenzland", + "currency_label": "💱 Währung", + "update_label": "🔄 Preise aktualisieren alle", + "update_suffix": "Monate" + }, "recipe": { "title": "🍳 Rezept-Einstellungen", "hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.", @@ -906,6 +921,7 @@ }, "meal_plan": { "reset_success": "Wochenplan zurückgesetzt", + "not_available": "nicht im Vorrat verfügbar", "suggested_by": "vom Wochenplan vorgeschlagen" }, "kiosk_session": { diff --git a/translations/en.json b/translations/en.json index 74908a8..582126c 100644 --- a/translations/en.json +++ b/translations/en.json @@ -162,7 +162,8 @@ "label_quantity": "📦 Quantity", "label_added": "📅 Added", "empty_text": "No products here.
Scan a product to add it!", - "empty_db": "No products in the database.
Scan a product to get started!" + "empty_db": "No products in the database.
Scan a product to get started!", + "qty_trace": "< 1" }, "scan": { "title": "Scan Product", @@ -345,7 +346,7 @@ "suggestions_title": "💡 AI Suggestions", "suggestions_add": "✅ Add selected to Bring!", "search_prices": "🔍 Search all prices", - "suggest_btn": "🤖 Suggest what to buy", + "suggest_btn": "Suggest what to buy", "smart_title": "🧠 Smart Predictions", "smart_empty": "No predictions available.
Add products to your pantry to receive smart predictions.", "smart_filter_all": "All", @@ -417,6 +418,10 @@ "savings_offers": "· 🏷️ You save €{amount} with offers", "searching_progress": "Searching {current}/{total}...", "remove_error": "Removal error", + "btn_fetch_prices": "Find prices", + "price_total_label": "💰 Estimated total:", + "price_loading": "Looking up prices…", + "price_not_found": "price n/a", "suggest_loading": "Analyzing...", "suggest_error": "Suggestion generation error", "priority_high": "High", @@ -509,6 +514,15 @@ "email_label": "📧 Bring! Email", "password_label": "🔒 Bring! Password" }, + "price": { + "title": "💰 Price Estimation (AI)", + "hint": "Show estimated cost per product in the shopping list using AI.", + "enabled_label": "Enable price estimation", + "country_label": "🌍 Reference country", + "currency_label": "💱 Currency", + "update_label": "🔄 Refresh prices every", + "update_suffix": "months" + }, "recipe": { "title": "🍳 Recipe Preferences", "hint": "Configure the default options for recipe generation.", @@ -907,6 +921,7 @@ }, "meal_plan": { "reset_success": "Weekly plan reset", + "not_available": "not available in pantry", "suggested_by": "suggested by weekly plan" }, "kiosk_session": { diff --git a/translations/it.json b/translations/it.json index a30331a..7bf0f2b 100644 --- a/translations/it.json +++ b/translations/it.json @@ -162,7 +162,8 @@ "label_quantity": "📦 Quantità", "label_added": "📅 Aggiunto", "empty_text": "Nessun prodotto qui.
Scansiona un prodotto per aggiungerlo!", - "empty_db": "Nessun prodotto nel database.
Scansiona un prodotto per iniziare!" + "empty_db": "Nessun prodotto nel database.
Scansiona un prodotto per iniziare!", + "qty_trace": "< 1" }, "scan": { "title": "Scansiona Prodotto", @@ -345,7 +346,7 @@ "suggestions_title": "💡 Suggerimenti AI", "suggestions_add": "✅ Aggiungi selezionati a Bring!", "search_prices": "🔍 Cerca tutti i prezzi", - "suggest_btn": "🤖 Suggerisci cosa comprare", + "suggest_btn": "Suggerisci cosa comprare", "smart_title": "🧠 Previsioni intelligenti", "smart_empty": "Nessuna previsione disponibile.
Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.", "smart_filter_all": "Tutti", @@ -417,6 +418,10 @@ "savings_offers": "· 🏷️ Risparmi €{amount} con le offerte", "searching_progress": "Cerco {current}/{total}...", "remove_error": "Errore nella rimozione", + "btn_fetch_prices": "Cerca i prezzi", + "price_total_label": "💰 Spesa stimata:", + "price_loading": "Ricerca prezzi…", + "price_not_found": "prezzo n/d", "suggest_loading": "Analisi in corso...", "suggest_error": "Errore nella generazione", "priority_high": "Alta", @@ -509,6 +514,15 @@ "email_label": "📧 Email Bring!", "password_label": "🔒 Password Bring!" }, + "price": { + "title": "💰 Stima Prezzi (AI)", + "hint": "Mostra il costo stimato di ogni prodotto nella lista della spesa usando l'AI.", + "enabled_label": "Attiva stima prezzi", + "country_label": "🌍 Paese di riferimento", + "currency_label": "💱 Valuta", + "update_label": "🔄 Aggiorna prezzi ogni", + "update_suffix": "mesi" + }, "recipe": { "title": "🍳 Preferenze Ricette", "hint": "Configura le opzioni predefinite per la generazione delle ricette.", @@ -907,7 +921,8 @@ }, "meal_plan": { "reset_success": "Piano settimanale ripristinato", - "suggested_by": "suggerito dal piano settimanale" + "suggested_by": "suggerito dal piano settimanale", + "not_available": "non disponibile in dispensa" }, "kiosk_session": { "first_item": "Primo prodotto: {name}!",