feat: AI price estimation for shopping list with per-item real-time display

- Add get_shopping_price / get_all_shopping_prices API endpoints
- AI (Gemini) estimates retail price per natural unit (pack, piece, bunch)
  instead of always per-kg — avoids absurd totals like €1609
- _calcEstimatedTotal: proper g/ml→package conversion using defQty + regex
  on unit_label; only 'kg'/'l' labels trigger weight/volume math
- Cache key bumped to v2 to invalidate old per-kg cached entries
- Suggested quantity cap lowered from 20 to 10 conf/pz
- Unit mismatch guard: if totalUsed >> buyCount*5 for unit=conf, use
  purchase frequency instead of raw consumption rate
- JS _buildPricePayload: use smartShoppingItems for qty/unit (not Bring! spec)
- JS _cachedPrices: persist in sessionStorage (survives navigation);
  validated by _qty/_unit metadata so stale totals auto-invalidate
- Price display redesigned: right-side column per row (price-col-main +
  price-col-unit) instead of small inline badge
- fetchAllPrices: buttons disabled immediately before guard check;
  running total uses only current shoppingItems (not Object.values cache)
- Background refresh: always silent (removed 90s interaction condition)
- visibilitychange: sets _bgCall=true for shopping before refreshCurrentPage
- .gitignore: add runtime data files (bring_migrate_ts, shopping_price_cache,
  anomaly_dismissed, opened_shelf_cache, shopping_name_cache)
- Remove bring_catalog.json and bring_migrate_ts.json from tracking
This commit is contained in:
dadaloop82
2026-05-07 17:31:23 +00:00
parent 4196130835
commit 5f510c0451
11 changed files with 1249 additions and 743 deletions
+5
View File
@@ -11,6 +11,11 @@ data/cron.log
data/smart_shopping_cache.json data/smart_shopping_cache.json
data/bring_token.json data/bring_token.json
data/bring_catalog.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/client_debug.log
data/rate_limits/ data/rate_limits/
+3
View File
@@ -16,6 +16,9 @@ function getDB(): PDO {
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); $db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
$db->exec("PRAGMA journal_mode=WAL"); $db->exec("PRAGMA journal_mode=WAL");
$db->exec("PRAGMA foreign_keys=ON"); $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) { if ($isNew) {
initializeDB($db); initializeDB($db);
+423 -25
View File
@@ -16,6 +16,7 @@
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004'); define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26'); define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf'); define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
/** Decode the XOR-obfuscated GitHub token at runtime. */ /** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string { function _ghToken(): string {
@@ -410,6 +411,14 @@ try {
geminiAnomalyExplain(); geminiAnomalyExplain();
break; break;
case 'get_shopping_price':
getShoppingPrice($db);
break;
case 'get_all_shopping_prices':
getAllShoppingPrices($db);
break;
default: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]); 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 = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$existing['id']]); $stmt->execute([$existing['id']]);
} else { } 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']); $wasOpened = !empty($existing['opened_at']);
$isNowOpened = false; $isNowOpened = false;
$unit = $prodInfo['unit'] ?? 'pz'; $unit = $prodInfo['unit'] ?? 'pz';
$defQty = (float)($prodInfo['default_quantity'] ?? 0); $defQty = (float)($prodInfo['default_quantity'] ?? 0);
if ($unit === 'conf') { if ($unit === 'conf') {
$w = floor($newQty + 0.001); // Opened = a fractional (non-integer) quantity remains
$f = round($newQty - $w, 6); $f = round($newQty - floor($newQty + 0.001), 6);
if ($f > 0.001) $isNowOpened = true; if ($f > 0.001) $isNowOpened = true;
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) { } elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) {
$isNowOpened = true; // 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) { if ($isNowOpened && !$wasOpened) {
@@ -1238,8 +1249,45 @@ function useFromInventory(PDO $db): void {
if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) { if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) {
$openedExpiry = $existing['expiry_date']; $openedExpiry = $existing['expiry_date'];
} }
// 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 = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$newQty, $openedExpiry, $existing['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 { } else {
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$newQty, $existing['id']]); $stmt->execute([$newQty, $existing['id']]);
@@ -1260,12 +1308,16 @@ function useFromInventory(PDO $db): void {
$remaining = $newQty; $remaining = $newQty;
// Check if opened part remains (for non-split path) // Check if opened part remains (for non-split path, only when not already set by split above)
if ($remaining > 0 && $prodInfo && $prodInfo['unit'] === 'conf') { if ($openedId === null && $remaining > 0 && $prodInfo) {
$w = floor($remaining + 0.001); $unitFb = $prodInfo['unit'] ?? '';
$f = round($remaining - $w, 6); $defQtyFb = (float)($prodInfo['default_quantity'] ?? 0);
if ($f > 0.001) { if ($unitFb === 'conf') {
$openedId = (int)$existing['id']; $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). // Compute opened shelf-life using AI (with rule-based fallback + persistent cache).
// The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays. // 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; $computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
// Always respect the manufacturer date: if the package expires before our estimate, // 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). // 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', 'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true', 'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true',
'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'), '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', 'camera_facing' => 'CAMERA_FACING',
'dietary' => 'DIETARY', 'dietary' => 'DIETARY',
'scale_gateway_url' => 'SCALE_GATEWAY_URL', 'scale_gateway_url' => 'SCALE_GATEWAY_URL',
'price_country' => 'PRICE_COUNTRY',
'price_currency' => 'PRICE_CURRENCY',
]; ];
// Boolean keys // Boolean keys
$boolMap = [ $boolMap = [
@@ -2125,11 +2183,13 @@ function saveSettings(): void {
'scale_enabled' => 'SCALE_ENABLED', 'scale_enabled' => 'SCALE_ENABLED',
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED', 'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
'screensaver_enabled' => 'SCREENSAVER_ENABLED', 'screensaver_enabled' => 'SCREENSAVER_ENABLED',
'price_enabled' => 'PRICE_ENABLED',
]; ];
// Integer keys // Integer keys
$intMap = [ $intMap = [
'default_persons' => 'DEFAULT_PERSONS', 'default_persons' => 'DEFAULT_PERSONS',
'screensaver_timeout' => 'SCREENSAVER_TIMEOUT', 'screensaver_timeout' => 'SCREENSAVER_TIMEOUT',
'price_update_months' => 'PRICE_UPDATE_MONTHS',
]; ];
foreach ($keyMap as $inKey => $envKey) { foreach ($keyMap as $inKey => $envKey) {
@@ -2266,25 +2326,30 @@ 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. * 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. * 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'; $cacheFile = __DIR__ . '/../data/opened_shelf_cache.json';
$cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location)); $cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location));
// Load cache // 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 = []; $cache = [];
if (file_exists($cacheFile)) { if (file_exists($cacheFile)) {
$cache = json_decode(file_get_contents($cacheFile), true) ?: []; $cache = json_decode(file_get_contents($cacheFile), true) ?: [];
} }
}
if (isset($cache[$cacheKey]['days'])) { if (isset($cache[$cacheKey]['days'])) {
$days = (int)$cache[$cacheKey]['days']; $days = (int)$cache[$cacheKey]['days'];
return $vacuumSealed ? (int)round($days * 1.5) : $days; 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'); $apiKey = env('GEMINI_API_KEY');
$days = 0; $days = 0;
if (!empty($apiKey)) { if ($allowAI && !empty($apiKey)) {
$locLabel = match($location) { $locLabel = match($location) {
'frigo' => 'refrigerator (4 °C / 39 °F)', 'frigo' => 'refrigerator (4 °C / 39 °F)',
'freezer' => 'freezer (-18 °C / 0 °F)', 'freezer' => 'freezer (-18 °C / 0 °F)',
@@ -2324,8 +2389,10 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location
$source = 'ai'; $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()]; $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)); @file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $vacuumSealed ? (int)round($days * 1.5) : $days; return $vacuumSealed ? (int)round($days * 1.5) : $days;
@@ -3045,7 +3112,7 @@ function generateRecipe(PDO $db): void {
$extraRulesText = ''; $extraRulesText = '';
if (!empty($extraRules)) { 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 // Appliances
@@ -3189,7 +3256,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per
REGOLE: REGOLE:
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto. {$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). 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. 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). 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). 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: REGOLE:
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto. {$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). 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. 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). 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). 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; $need14 = $dailyRate * 14;
if ($unit === 'conf') { if ($unit === 'conf') {
// dailyRate already in conf/day // Guard against unit mismatch: transactions may have been recorded in g/ml
$suggestedQty = (int) max(1, min(20, (int)($need14 + 0.3))); // (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'; $suggestedUnit = 'conf';
} elseif ($pkgUnit !== '' && $defQty > 0) { } elseif ($pkgUnit !== '' && $defQty > 0) {
// Real package info available → express in confezioni (definitive) // 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; $suggestedQty = $pkgs;
$suggestedUnit = 'conf'; $suggestedUnit = 'conf';
@@ -5393,7 +5466,7 @@ function smartShopping(PDO $db): void {
// use defQty as the minimum purchase unit and round to nearest multiple. // use defQty as the minimum purchase unit and round to nearest multiple.
// This ensures we never suggest less than one "reference pack". // This ensures we never suggest less than one "reference pack".
$pkgs = (int) max(1, (int)($need14 / $defQty + 0.3)); $pkgs = (int) max(1, (int)($need14 / $defQty + 0.3));
$pkgs = min(20, $pkgs); $pkgs = min(10, $pkgs);
$suggestedQty = $pkgs * (int)$defQty; $suggestedQty = $pkgs * (int)$defQty;
$suggestedUnit = $unit; $suggestedUnit = $unit;
$suggestedApprox = true; // always "almeno" — no confirmed pkg size $suggestedApprox = true; // always "almeno" — no confirmed pkg size
@@ -5416,7 +5489,7 @@ function smartShopping(PDO $db): void {
} elseif ($unit === 'pz') { } elseif ($unit === 'pz') {
// No package info → raw pz count, approximate // 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'; $suggestedUnit = 'pz';
$suggestedApprox = ($suggestedQty > 1); $suggestedApprox = ($suggestedQty > 1);
} }
@@ -6389,3 +6462,328 @@ function geminiAnomalyExplain(): void {
echo json_encode(['success' => true, 'explanation' => $explanation]); 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 = <<<PROMPT
You are a grocery price assistant. Estimate the typical retail price for "{$name}" in {$country}, currency {$currency}.
Return the price for the MOST NATURAL RETAIL UNIT that is, the smallest standard unit a shopper would actually buy:
- Products in standard packages (pasta, flour, frozen food, yogurt, canned goods): price per typical package (e.g. "pacco 500g", "barattolo 400g", "confezione")
- Products sold by the piece or bunch (fresh herbs, eggs, individual fruits/vegetables, single portions): price per piece/bunch (e.g. "mazzo", "uovo", "pz")
- Liquids in bottles or cartons: price per typical container (e.g. "bottiglia 1L", "brick 1L")
- Deli counter items sold loose by weight (prosciutto, salami, fresh fish): price per kg
Rules:
1. Use mid-range supermarket prices (not premium, not discount).
2. Round to 2 decimal places.
3. NEVER return per-kg for items normally sold in packages or by the piece.
4. Respond ONLY with valid JSON no markdown, no explanation:
{"price_per_unit": 1.50, "unit_label": "mazzo", "currency": "{$currency}", "source_note": "Basilico fresco ~€1.50/mazzo in {$country}"}
If truly unknown, return: {"price_per_unit": null, "unit_label": null, "currency": "{$currency}", "source_note": "prezzo non disponibile"}
PROMPT;
$payload = ['contents' => [['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, '.', '');
}
+133
View File
@@ -516,6 +516,24 @@ body {
color: var(--primary); 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 { .stat-label {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-light); color: var(--text-light);
@@ -1861,6 +1879,121 @@ body {
.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; } .badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; }
.badge-local-tag:hover { background: #bae6fd; } .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 */ /* Tag add button */
.shopping-item-tag-btn { .shopping-item-tag-btn {
background: none; background: none;
+531 -39
View File
@@ -1258,34 +1258,35 @@ function guessCategoryFromName(name) {
if (!name) return 'altro'; if (!name) return 'altro';
const n = name.toLowerCase(); const n = name.toLowerCase();
// Pasta & Rice // 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 // 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 // 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) // 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'; 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 // Bevande (after latticini to avoid latte conflict)
if (/acqua|birra|vino|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|latte\b/.test(n)) return 'bevande'; 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';
// Latticini (include eggs/uova)
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg/.test(n)) return 'latticini';
// Carne (include salumi) // 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 // 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 // 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 // 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 // Surgelati
if (/surgelat|frozen|findus|4.salti|gelato/.test(n)) return 'surgelati'; if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati';
// Snack // Snack & Dolci
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini/.test(n)) return 'snack'; if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack';
// Cereali // Cereali
if (/cereali|muesli|fiocchi|granola|polenta/.test(n)) return 'cereali'; if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.test(n)) return 'cereali';
// Igiene / Pulizia // Igiene personale
if (/sapone|shampoo|dentifricio|deodorante/.test(n)) return 'igiene'; if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene';
if (/detersivo|pulito|sgrassatore/.test(n)) return 'pulizia'; // Pulizia casa
if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
return 'altro'; return 'altro';
} }
@@ -1871,8 +1872,25 @@ function debounce(fn, ms) {
async function syncSettingsFromDB() { async function syncSettingsFromDB() {
try { try {
// Primary: load from server .env // Primary: load from server .env (only when not already done via _applySyncedSettings)
const serverSettings = await api('get_settings'); const serverSettings = await api('get_settings');
_applySyncedSettings(serverSettings);
// Also load review_confirmed from DB
const res = await api('app_settings_get');
if (res.success && res.settings) {
if (res.settings.review_confirmed) {
_reviewConfirmedCache = res.settings.review_confirmed;
}
}
} 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); _geminiAvailable = !!(serverSettings.gemini_key_set);
_demoMode = !!serverSettings.demo_mode; _demoMode = !!serverSettings.demo_mode;
_updateGeminiButtonState(); _updateGeminiButtonState();
@@ -1883,23 +1901,20 @@ async function syncSettingsFromDB() {
'camera_facing','scale_enabled','scale_gateway_url', 'camera_facing','scale_enabled','scale_gateway_url',
'meal_plan_enabled','tts_enabled','tts_url','tts_token', 'meal_plan_enabled','tts_enabled','tts_url','tts_token',
'tts_method','tts_auth_type','tts_content_type','tts_payload_key', 'tts_method','tts_auth_type','tts_content_type','tts_payload_key',
'screensaver_enabled','screensaver_timeout']; 'screensaver_enabled','screensaver_timeout',
'price_enabled','price_country','price_currency','price_update_months'];
let changed = false;
for (const key of serverKeys) { for (const key of serverKeys) {
if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') { if (serverSettings[key] !== undefined && serverSettings[key] !== null && serverSettings[key] !== '') {
s[key] = serverSettings[key]; s[key] = serverSettings[key];
changed = true;
} }
} }
if (changed) {
_settingsCache = s; _settingsCache = s;
localStorage.setItem('evershelf_settings', JSON.stringify(s)); localStorage.setItem('evershelf_settings', JSON.stringify(s));
// Also load review_confirmed from DB
const res = await api('app_settings_get');
if (res.success && res.settings) {
if (res.settings.review_confirmed) {
_reviewConfirmedCache = res.settings.review_confirmed;
} }
} }
} catch(e) { /* offline, use local */ }
}
async function loadSettingsUI() { async function loadSettingsUI() {
const s = getSettings(); const s = getSettings();
@@ -1993,7 +2008,8 @@ async function loadSettingsUI() {
'camera_facing','scale_enabled','scale_gateway_url', 'camera_facing','scale_enabled','scale_gateway_url',
'meal_plan_enabled', 'meal_plan_enabled',
'tts_enabled','tts_url','tts_token','tts_method','tts_auth_type', '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 // Note: gemini_key is never sent from server; settings_token_set is metadata only
const settingsTokenRequired = !!serverSettings.settings_token_set; const settingsTokenRequired = !!serverSettings.settings_token_set;
const tokenHintEl = document.getElementById('settings-token-status-hint'); const tokenHintEl = document.getElementById('settings-token-status-hint');
@@ -2035,8 +2051,34 @@ async function loadSettingsUI() {
if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp; if (mpEnabledEl) mpEnabledEl.checked = mpEnabledUp;
if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none'; if (mpConfigSection) mpConfigSection.style.display = mpEnabledUp ? '' : 'none';
if (mpLegendCard) mpLegendCard.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 */ } } 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 // Scale settings
const scaleEnabledUiEl = document.getElementById('setting-scale-enabled'); const scaleEnabledUiEl = document.getElementById('setting-scale-enabled');
if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled; if (scaleEnabledUiEl) scaleEnabledUiEl.checked = !!s.scale_enabled;
@@ -2358,6 +2400,15 @@ async function saveSettings() {
if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked; if (scaleEnabledEl) s.scale_enabled = scaleEnabledEl.checked;
const scaleUrlEl = document.getElementById('setting-scale-url'); const scaleUrlEl = document.getElementById('setting-scale-url');
if (scaleUrlEl) s.scale_gateway_url = scaleUrlEl.value.trim(); 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); saveSettingsToStorage(s);
// Save ALL settings to server .env // Save ALL settings to server .env
@@ -2389,6 +2440,10 @@ async function saveSettings() {
tts_auth_type: s.tts_auth_type, tts_auth_type: s.tts_auth_type,
tts_content_type: s.tts_content_type, tts_content_type: s.tts_content_type,
tts_payload_key: s.tts_payload_key, 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); }, tokenHeader);
const statusEl = document.getElementById('settings-status'); const statusEl = document.getElementById('settings-status');
if (result.success) { if (result.success) {
@@ -2481,7 +2536,10 @@ function refreshCurrentPage() {
switch(_currentPageId) { switch(_currentPageId) {
case 'dashboard': loadDashboard(); break; case 'dashboard': loadDashboard(); break;
case 'inventory': loadInventory(); break; case 'inventory': loadInventory(); break;
case 'shopping': loadShoppingList(); break; case 'shopping':
loadShoppingList._bgCall = true;
loadShoppingList();
break;
case 'products': loadAllProducts(); break; case 'products': loadAllProducts(); break;
case 'recipe': loadRecipeArchive(); break; case 'recipe': loadRecipeArchive(); break;
case 'log': loadLog(); break; case 'log': loadLog(); break;
@@ -3117,6 +3175,12 @@ function _applyInsightPhase() {
// ===== DASHBOARD ===== // ===== DASHBOARD =====
async function loadDashboard() { 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 { try {
const [summaryData, statsData] = await Promise.all([ const [summaryData, statsData] = await Promise.all([
api('inventory_summary'), api('inventory_summary'),
@@ -3131,6 +3195,7 @@ async function loadDashboard() {
const count = s ? s.product_count : 0; const count = s ? s.product_count : 0;
const el = document.getElementById(`stat-${loc}`); const el = document.getElementById(`stat-${loc}`);
el.textContent = count; el.textContent = count;
el.classList.remove('stat-loading');
total += count; total += count;
}); });
// Add non-standard locations // Add non-standard locations
@@ -3218,8 +3283,11 @@ async function loadDashboard() {
// Banner alerts (suspicious quantities + consumption predictions) // Banner alerts (suspicious quantities + consumption predictions)
loadBannerAlerts(); loadBannerAlerts();
// Anti-waste section (load facts first so rotation has full dataset) // Anti-waste section + Nutrition section: load in parallel
await _awLoadFacts(); const [, invForNutr] = await Promise.all([
_awLoadFacts(),
api('inventory_list').then(d => d.inventory || []).catch(() => []),
]);
_renderAntiWasteSection( _renderAntiWasteSection(
statsData.used_30d || 0, statsData.wasted_30d || 0, statsData.used_30d || 0, statsData.wasted_30d || 0,
statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0, statsData.used_prev_30d || 0, statsData.wasted_prev_30d || 0,
@@ -3229,10 +3297,7 @@ async function loadDashboard() {
_startAntiWasteAutoRefresh(); _startAntiWasteAutoRefresh();
// Nutrition section — built from the full inventory list // Nutrition section — built from the full inventory list
try {
const invForNutr = (await api('inventory_list')).inventory || [];
_renderNutritionSection(invForNutr); _renderNutritionSection(invForNutr);
} catch(_e) {}
_startInsightAlternation(); _startInsightAlternation();
// Opened (partially used products with known package capacity) // Opened (partially used products with known package capacity)
@@ -3334,6 +3399,11 @@ async function loadDashboard() {
} catch (err) { } catch (err) {
console.error('Dashboard load error:', 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" if (noExpiryDismissed[pid]) return; // user said "no expiry needed"
// Only flag perishable-looking categories or items with opened_at // Only flag perishable-looking categories or items with opened_at
const cat = (item.category || '').toLowerCase(); 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 || const likelyPerishable = item.opened_at ||
PERISHABLE_CATS.some(c => cat.includes(c)); PERISHABLE_CATS.some(c => cat.includes(c)) ||
perishableGuessed;
if (!likelyPerishable) return; if (!likelyPerishable) return;
_bannerQueue.push({ type: 'no_expiry', data: item }); _bannerQueue.push({ type: 'no_expiry', data: item });
}); });
@@ -8498,6 +8572,7 @@ function toggleShoppingTag(itemIdx, tag) {
// ===== SCAN FROM SHOPPING LIST ===== // ===== SCAN FROM SHOPPING LIST =====
function openScanForItem(idx) { function openScanForItem(idx) {
loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list
const item = shoppingItems[idx]; const item = shoppingItems[idx];
if (!item) return; if (!item) return;
_spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx }; _spesaScanTarget = { name: item.name, rawName: item.rawName || '', idx };
@@ -8660,6 +8735,284 @@ async function forceSyncBring() {
showToast(`🔄 ${t('shopping.sync_done')}`, 'success'); 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 `<div class="price-col-main" title="${escapeHtml(title)}">${mainLabel}</div>`
+ (unitLine ? `<div class="price-col-unit">${unitLine}</div>` : '');
}
/**
* 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 = `<span class="price-col-error"></span>`;
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 = `<span class="price-col-loading">…</span>`;
});
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 = `<span class="price-col-loading">…</span>`;
}
});
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 = `<span class="price-col-error"></span>`;
}
} catch (_err) {
if (badge) badge.innerHTML = `<span class="price-col-error"></span>`;
}
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 * 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 * longer considers relevant. CONSERVATIVE: only removes items that match a known product
@@ -8912,7 +9265,8 @@ async function loadSmartShopping() {
); );
smartShoppingItems = data.items; smartShoppingItems = data.items;
_smartShoppingLastFetch = Date.now(); _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 // 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 newCriticalNames = new Set(data.items.filter(i => i.urgency === 'critical').map(i => i.name));
const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) || const criticalChanged = [...prevCriticalNames].some(n => !newCriticalNames.has(n)) ||
@@ -9187,17 +9541,23 @@ async function addSmartToBring() {
// Load just the shopping count for dashboard stat card // Load just the shopping count for dashboard stat card
async function loadShoppingCount() { async function loadShoppingCount() {
const el = document.getElementById('stat-spesa');
if (el) el.classList.add('stat-loading');
try { try {
const data = await api('bring_list'); const data = await api('bring_list');
const el = document.getElementById('stat-spesa'); if (el) {
if (data.success && data.purchase) { if (data.success && data.purchase) {
el.textContent = data.purchase.length; el.textContent = data.purchase.length;
} else { } else {
el.textContent = '-'; el.textContent = '-';
} }
el.classList.remove('stat-loading');
}
} catch { } catch {
const el = document.getElementById('stat-spesa'); if (el) {
el.textContent = '-'; el.textContent = '-';
el.classList.remove('stat-loading');
}
} }
// Smart urgency badge: use cached data if fresh (< 2 min), else fetch // Smart urgency badge: use cached data if fresh (< 2 min), else fetch
if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) { if (smartShoppingItems.length > 0 && (Date.now() - _smartShoppingLastFetch) < 2 * 60 * 1000) {
@@ -9322,6 +9682,36 @@ async function loadShoppingList() {
const currentEl = document.getElementById('shopping-current'); const currentEl = document.getElementById('shopping-current');
const suggestionsEl = document.getElementById('shopping-suggestions'); 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.style.display = 'block';
statusEl.innerHTML = `<div class="bring-loading"><div class="loading-spinner"></div> ${t('shopping.bring_loading')}</div>`; statusEl.innerHTML = `<div class="bring-loading"><div class="loading-spinner"></div> ${t('shopping.bring_loading')}</div>`;
currentEl.style.display = 'none'; currentEl.style.display = 'none';
@@ -9374,6 +9764,10 @@ async function loadShoppingList() {
if (removedNames.length) _markBringPurchased(removedNames); if (removedNames.length) _markBringPurchased(removedNames);
} }
shoppingItems = newItems; 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) // Sync urgente local tags from Bring specification (items marked urgent by us or manually)
_syncTagsFromBringSpec(); _syncTagsFromBringSpec();
@@ -9515,6 +9909,8 @@ async function renderShoppingItems() {
).join('')} ).join('')}
</div>`; </div>`;
const priceEnabled = getSettings().price_enabled;
html += ` html += `
<div class="shopping-item" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}> <div class="shopping-item" id="shop-item-${idx}" onclick="openScanForItem(${idx})" title="${t('shopping.tap_to_scan')}"${bgStyle}>
<span class="shopping-item-icon">${catIcon}</span> <span class="shopping-item-icon">${catIcon}</span>
@@ -9528,6 +9924,7 @@ async function renderShoppingItems() {
${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''} ${_specDisplayText(item.specification) ? `<div class="shopping-item-spec">${escapeHtml(_specDisplayText(item.specification))}</div>` : ''}
${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''} ${(urgencyBadge || freqBadge || localTagHtml) ? `<div class="shopping-item-badges">${urgencyBadge}${freqBadge}${localTagHtml}</div>` : ''}
</div> </div>
${priceEnabled ? `<div class="shopping-item-price-col" id="price-badge-${idx}"><span class="price-col-loading">…</span></div>` : ''}
<div class="shopping-item-right" onclick="event.stopPropagation()"> <div class="shopping-item-right" onclick="event.stopPropagation()">
<button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="${t('shopping.tag_title')}">🏷</button> <button class="shopping-item-tag-btn" onclick="toggleShoppingTagMenu(this)" title="${t('shopping.tag_title')}">🏷</button>
<button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}"></button> <button class="shopping-item-remove" onclick="removeBringItem(${idx})" title="${t('shopping.remove_title')}"></button>
@@ -9540,9 +9937,26 @@ async function renderShoppingItems() {
} }
container.innerHTML = html; 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) { 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'); const container = btn.closest('.shopping-item-body').querySelector('.shopping-tag-menu-container');
if (!container) return; if (!container) return;
const isOpen = container.style.display !== 'none'; const isOpen = container.style.display !== 'none';
@@ -9552,6 +9966,7 @@ function toggleShoppingTagMenu(btn) {
} }
async function removeBringItem(idx) { async function removeBringItem(idx) {
loadShoppingList._lastUserInteraction = Date.now(); // user is actively using the list
const item = shoppingItems[idx]; const item = shoppingItems[idx];
if (!item) return; if (!item) return;
try { try {
@@ -10074,6 +10489,51 @@ async function _doUndoTransaction(id, type, name) {
// ===== WEEKLY MEAL PLAN ===== // ===== 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. * All selectable meal categories per slot.
* id must be URL-safe; icon + label shown in UI. * id must be URL-safe; icon + label shown in UI.
@@ -10157,6 +10617,21 @@ function onMealPlanEnabledChange(el) {
if (picker) picker.style.display = 'none'; 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. * Render the weekly meal plan editor into #meal-plan-grid.
* Each cell shows the current type badge + a picker dropdown. * 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 (chipLabel) chipLabel.textContent = `${mpt.icon} ${mpt.label}`;
if (chipCb) chipCb.checked = true; 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() { function regenerateRecipe() {
@@ -12841,6 +13327,8 @@ async function _initApp() {
if (missing.length > 0 && !_demoMode) { if (missing.length > 0 && !_demoMode) {
showSetupWizard(missing); showSetupWizard(missing);
} }
// Reuse the already-fetched serverSettings to avoid a second get_settings request
_applySyncedSettings(serverSettings);
} }
// Migrate old session-based flags to time-based // Migrate old session-based flags to time-based
@@ -12852,6 +13340,7 @@ async function _initApp() {
localStorage.removeItem('_bgBringSyncTs'); localStorage.removeItem('_bgBringSyncTs');
localStorage.setItem('_bgBringSyncReset_v1', '1'); localStorage.setItem('_bgBringSyncReset_v1', '1');
} }
// syncSettingsFromDB only needs to fetch app_settings_get for review flags now
syncSettingsFromDB().then(() => { syncSettingsFromDB().then(() => {
scaleInit(); // connect to smart scale gateway if configured (needs settings) scaleInit(); // connect to smart scale gateway if configured (needs settings)
initInactivityWatcher(); initInactivityWatcher();
@@ -12883,6 +13372,7 @@ async function _initApp() {
setInterval(() => { setInterval(() => {
if (_screensaverActive) return; if (_screensaverActive) return;
if (_currentPageId === 'shopping') { if (_currentPageId === 'shopping') {
loadShoppingList._bgCall = true;
loadShoppingList(); loadShoppingList();
} else { } else {
loadShoppingCount(); loadShoppingCount();
@@ -12892,6 +13382,8 @@ async function _initApp() {
// 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app) // 3) Aggiorna immediatamente quando la tab torna visibile (es. torni da Bring! app)
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (!document.hidden) { if (!document.hidden) {
// Always treat visibility restore as a background call for shopping
if (_currentPageId === 'shopping') loadShoppingList._bgCall = true;
refreshCurrentPage(); refreshCurrentPage();
_checkWebappUpdate(); // also check for app updates when user returns to tab _checkWebappUpdate(); // also check for app updates when user returns to tab
} }
-645
View File
@@ -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"
}
}
-1
View File
@@ -1 +0,0 @@
{"ts":1777391782}
+76 -1
View File
@@ -630,6 +630,17 @@
<!-- Tab panel: Da comprare --> <!-- Tab panel: Da comprare -->
<div id="tab-panel-acquisto" class="tab-panel-shopping active"> <div id="tab-panel-acquisto" class="tab-panel-shopping active">
<!-- Price summary bar (shown when prices are enabled) -->
<div id="shopping-price-bar" style="display:none">
<div class="shopping-price-total-row">
<span class="price-total-label" data-i18n="shopping.price_total_label">💰 Spesa stimata:</span>
<span class="price-total-value" id="price-total-value"></span>
<button class="btn-price-refresh" id="btn-price-refresh" onclick="fetchAllPrices(false)" title="Aggiorna prezzi">🔄</button>
</div>
<div id="price-loading-bar" style="display:none" class="price-loading-bar">
<div class="price-loading-inner"></div>
</div>
</div>
<div class="shopping-current" id="shopping-current" style="display:none"> <div class="shopping-current" id="shopping-current" style="display:none">
<div class="shopping-section-header"> <div class="shopping-section-header">
<h3 data-i18n="shopping.section_to_buy">🛍️ Da comprare</h3> <h3 data-i18n="shopping.section_to_buy">🛍️ Da comprare</h3>
@@ -651,11 +662,14 @@
</div> </div>
<div class="shopping-actions"> <div class="shopping-actions">
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest" data-i18n="shopping.suggest_btn"> <button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest" data-i18n="shopping.suggest_btn">
🤖 Suggerisci cosa comprare Suggerisci cosa comprare
</button> </button>
<button class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px" data-i18n="shopping.force_sync"> <button class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px" data-i18n="shopping.force_sync">
🔄 Forza sincronizzazione Bring! 🔄 Forza sincronizzazione Bring!
</button> </button>
<button class="btn btn-secondary" id="btn-fetch-prices" onclick="fetchAllPrices(false)" style="margin-top:4px;display:none" data-i18n="shopping.btn_fetch_prices">
Cerca i prezzi
</button>
</div> </div>
</div> </div>
@@ -785,6 +799,67 @@
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button> <button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button>
</div> </div>
</div> </div>
<!-- Price Estimation Settings -->
<div class="settings-card" style="margin-top:12px">
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
<p class="settings-hint" data-i18n="settings.price.hint">Mostra il costo stimato di ogni prodotto nella lista della spesa usando l'AI.</p>
<div class="form-group">
<label class="toggle-row">
<span data-i18n="settings.price.enabled_label">Attiva stima prezzi</span>
<span class="toggle-switch">
<input type="checkbox" id="setting-price-enabled">
<span class="toggle-slider"></span>
</span>
</label>
</div>
<div id="price-settings-sub" style="display:none">
<div class="form-group">
<label data-i18n="settings.price.country_label">🌍 Paese di riferimento</label>
<select id="setting-price-country" class="form-input" onchange="onPriceCountryChange()">
<option value="Italia">🇮🇹 Italia</option>
<option value="USA">🇺🇸 USA</option>
<option value="Germany">🇩🇪 Germania</option>
<option value="France">🇫🇷 Francia</option>
<option value="Spain">🇪🇸 Spagna</option>
<option value="UK">🇬🇧 Regno Unito</option>
<option value="Switzerland">🇨🇭 Svizzera</option>
<option value="Austria">🇦🇹 Austria</option>
<option value="Netherlands">🇳🇱 Olanda</option>
<option value="Belgium">🇧🇪 Belgio</option>
<option value="Canada">🇨🇦 Canada</option>
<option value="Australia">🇦🇺 Australia</option>
<option value="Brazil">🇧🇷 Brasile</option>
<option value="Japan">🇯🇵 Giappone</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.price.currency_label">💱 Valuta</label>
<select id="setting-price-currency" class="form-input">
<option value="EUR">€ Euro (EUR)</option>
<option value="USD">$ Dollaro USA (USD)</option>
<option value="GBP">£ Sterlina (GBP)</option>
<option value="CHF">CHF Franco Svizzero</option>
<option value="CAD">CA$ Dollaro Canadese</option>
<option value="AUD">A$ Dollaro Australiano</option>
<option value="BRL">R$ Real Brasiliano</option>
<option value="JPY">¥ Yen Giapponese</option>
<option value="SEK">kr Corona Svedese</option>
<option value="NOK">kr Corona Norvegese</option>
<option value="DKK">kr Corona Danese</option>
<option value="PLN">zł Zloty Polacco</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustQty('setting-price-update-months', -1, 1, 24)"></button>
<input type="number" id="setting-price-update-months" value="3" min="1" max="24" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustQty('setting-price-update-months', 1, 1, 24)">+</button>
</div>
<span class="settings-hint" style="display:inline;margin-left:8px" data-i18n="settings.price.update_suffix">mesi</span>
</div>
</div>
</div>
</div> </div>
<!-- Recipe Tab --> <!-- Recipe Tab -->
<div class="settings-panel" id="tab-recipe"> <div class="settings-panel" id="tab-recipe">
+22 -6
View File
@@ -103,11 +103,13 @@
"banner_expired_action_throw": "Habe ich weggeworfen", "banner_expired_action_throw": "Habe ich weggeworfen",
"banner_expired_action_edit": "Datum korrigieren", "banner_expired_action_edit": "Datum korrigieren",
"banner_anomaly_action_edit": "Bestand 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_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_set": "Ablaufdatum setzen",
"banner_no_expiry_action_dismiss": "Läuft nicht ab ✓", "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_today": "Läuft heute ab!",
"banner_expiring_tomorrow": "Läuft morgen ab", "banner_expiring_tomorrow": "Läuft morgen ab",
"banner_expiring_days": "Läuft in {days} Tagen 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_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_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_title": "Anfangsbestand nicht als Eingang gebucht",
"banner_anomaly_untracked_detail": "Du hast <strong>{inv_qty} {unit}</strong> 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 <strong>{inv_qty} {unit}</strong> 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_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?", "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}%)", "consumed": "Verbraucht: {n} ({pct}%)",
@@ -161,7 +162,8 @@
"label_quantity": "📦 Menge", "label_quantity": "📦 Menge",
"label_added": "📅 Hinzugefügt", "label_added": "📅 Hinzugefügt",
"empty_text": "Keine Produkte hier.<br>Scanne ein Produkt, um es hinzuzufügen!", "empty_text": "Keine Produkte hier.<br>Scanne ein Produkt, um es hinzuzufügen!",
"empty_db": "Keine Produkte in der Datenbank.<br>Scanne ein Produkt, um loszulegen!" "empty_db": "Keine Produkte in der Datenbank.<br>Scanne ein Produkt, um loszulegen!",
"qty_trace": "< 1"
}, },
"scan": { "scan": {
"title": "Produkt scannen", "title": "Produkt scannen",
@@ -344,7 +346,7 @@
"suggestions_title": "💡 KI-Vorschläge", "suggestions_title": "💡 KI-Vorschläge",
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen", "suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
"search_prices": "🔍 Alle Preise suchen", "search_prices": "🔍 Alle Preise suchen",
"suggest_btn": "🤖 Einkaufsvorschläge", "suggest_btn": "Einkaufsvorschläge",
"smart_title": "🧠 Intelligente Vorhersagen", "smart_title": "🧠 Intelligente Vorhersagen",
"smart_empty": "Keine Vorhersagen verfügbar.<br>Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.", "smart_empty": "Keine Vorhersagen verfügbar.<br>Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.",
"smart_filter_all": "Alle", "smart_filter_all": "Alle",
@@ -416,6 +418,10 @@
"savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten", "savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten",
"searching_progress": "Suche {current}/{total}...", "searching_progress": "Suche {current}/{total}...",
"remove_error": "Fehler beim Entfernen", "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_loading": "Analyse läuft...",
"suggest_error": "Fehler bei der Vorschlagserstellung", "suggest_error": "Fehler bei der Vorschlagserstellung",
"priority_high": "Hoch", "priority_high": "Hoch",
@@ -508,6 +514,15 @@
"email_label": "📧 Bring! E-Mail", "email_label": "📧 Bring! E-Mail",
"password_label": "🔒 Bring! Passwort" "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": { "recipe": {
"title": "🍳 Rezept-Einstellungen", "title": "🍳 Rezept-Einstellungen",
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.", "hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.",
@@ -906,6 +921,7 @@
}, },
"meal_plan": { "meal_plan": {
"reset_success": "Wochenplan zurückgesetzt", "reset_success": "Wochenplan zurückgesetzt",
"not_available": "nicht im Vorrat verfügbar",
"suggested_by": "vom Wochenplan vorgeschlagen" "suggested_by": "vom Wochenplan vorgeschlagen"
}, },
"kiosk_session": { "kiosk_session": {
+17 -2
View File
@@ -162,7 +162,8 @@
"label_quantity": "📦 Quantity", "label_quantity": "📦 Quantity",
"label_added": "📅 Added", "label_added": "📅 Added",
"empty_text": "No products here.<br>Scan a product to add it!", "empty_text": "No products here.<br>Scan a product to add it!",
"empty_db": "No products in the database.<br>Scan a product to get started!" "empty_db": "No products in the database.<br>Scan a product to get started!",
"qty_trace": "< 1"
}, },
"scan": { "scan": {
"title": "Scan Product", "title": "Scan Product",
@@ -345,7 +346,7 @@
"suggestions_title": "💡 AI Suggestions", "suggestions_title": "💡 AI Suggestions",
"suggestions_add": "✅ Add selected to Bring!", "suggestions_add": "✅ Add selected to Bring!",
"search_prices": "🔍 Search all prices", "search_prices": "🔍 Search all prices",
"suggest_btn": "🤖 Suggest what to buy", "suggest_btn": "Suggest what to buy",
"smart_title": "🧠 Smart Predictions", "smart_title": "🧠 Smart Predictions",
"smart_empty": "No predictions available.<br>Add products to your pantry to receive smart predictions.", "smart_empty": "No predictions available.<br>Add products to your pantry to receive smart predictions.",
"smart_filter_all": "All", "smart_filter_all": "All",
@@ -417,6 +418,10 @@
"savings_offers": "· 🏷️ You save €{amount} with offers", "savings_offers": "· 🏷️ You save €{amount} with offers",
"searching_progress": "Searching {current}/{total}...", "searching_progress": "Searching {current}/{total}...",
"remove_error": "Removal error", "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_loading": "Analyzing...",
"suggest_error": "Suggestion generation error", "suggest_error": "Suggestion generation error",
"priority_high": "High", "priority_high": "High",
@@ -509,6 +514,15 @@
"email_label": "📧 Bring! Email", "email_label": "📧 Bring! Email",
"password_label": "🔒 Bring! Password" "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": { "recipe": {
"title": "🍳 Recipe Preferences", "title": "🍳 Recipe Preferences",
"hint": "Configure the default options for recipe generation.", "hint": "Configure the default options for recipe generation.",
@@ -907,6 +921,7 @@
}, },
"meal_plan": { "meal_plan": {
"reset_success": "Weekly plan reset", "reset_success": "Weekly plan reset",
"not_available": "not available in pantry",
"suggested_by": "suggested by weekly plan" "suggested_by": "suggested by weekly plan"
}, },
"kiosk_session": { "kiosk_session": {
+18 -3
View File
@@ -162,7 +162,8 @@
"label_quantity": "📦 Quantità", "label_quantity": "📦 Quantità",
"label_added": "📅 Aggiunto", "label_added": "📅 Aggiunto",
"empty_text": "Nessun prodotto qui.<br>Scansiona un prodotto per aggiungerlo!", "empty_text": "Nessun prodotto qui.<br>Scansiona un prodotto per aggiungerlo!",
"empty_db": "Nessun prodotto nel database.<br>Scansiona un prodotto per iniziare!" "empty_db": "Nessun prodotto nel database.<br>Scansiona un prodotto per iniziare!",
"qty_trace": "< 1"
}, },
"scan": { "scan": {
"title": "Scansiona Prodotto", "title": "Scansiona Prodotto",
@@ -345,7 +346,7 @@
"suggestions_title": "💡 Suggerimenti AI", "suggestions_title": "💡 Suggerimenti AI",
"suggestions_add": "✅ Aggiungi selezionati a Bring!", "suggestions_add": "✅ Aggiungi selezionati a Bring!",
"search_prices": "🔍 Cerca tutti i prezzi", "search_prices": "🔍 Cerca tutti i prezzi",
"suggest_btn": "🤖 Suggerisci cosa comprare", "suggest_btn": "Suggerisci cosa comprare",
"smart_title": "🧠 Previsioni intelligenti", "smart_title": "🧠 Previsioni intelligenti",
"smart_empty": "Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.", "smart_empty": "Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.",
"smart_filter_all": "Tutti", "smart_filter_all": "Tutti",
@@ -417,6 +418,10 @@
"savings_offers": "· 🏷️ Risparmi €{amount} con le offerte", "savings_offers": "· 🏷️ Risparmi €{amount} con le offerte",
"searching_progress": "Cerco {current}/{total}...", "searching_progress": "Cerco {current}/{total}...",
"remove_error": "Errore nella rimozione", "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_loading": "Analisi in corso...",
"suggest_error": "Errore nella generazione", "suggest_error": "Errore nella generazione",
"priority_high": "Alta", "priority_high": "Alta",
@@ -509,6 +514,15 @@
"email_label": "📧 Email Bring!", "email_label": "📧 Email Bring!",
"password_label": "🔒 Password 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": { "recipe": {
"title": "🍳 Preferenze Ricette", "title": "🍳 Preferenze Ricette",
"hint": "Configura le opzioni predefinite per la generazione delle ricette.", "hint": "Configura le opzioni predefinite per la generazione delle ricette.",
@@ -907,7 +921,8 @@
}, },
"meal_plan": { "meal_plan": {
"reset_success": "Piano settimanale ripristinato", "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": { "kiosk_session": {
"first_item": "Primo prodotto: {name}!", "first_item": "Primo prodotto: {name}!",