release: v1.7.4 — AI price estimation for shopping list
This commit is contained in:
@@ -11,6 +11,11 @@ data/cron.log
|
||||
data/smart_shopping_cache.json
|
||||
data/bring_token.json
|
||||
data/bring_catalog.json
|
||||
data/bring_migrate_ts.json
|
||||
data/shopping_price_cache.json
|
||||
data/anomaly_dismissed.json
|
||||
data/opened_shelf_cache.json
|
||||
data/shopping_name_cache.json
|
||||
data/client_debug.log
|
||||
data/rate_limits/
|
||||
|
||||
|
||||
@@ -5,6 +5,21 @@ All notable changes to EverShelf will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.7.4] - 2026-05-07
|
||||
|
||||
### Added
|
||||
- **AI price estimation for shopping list** — Each item on the Bring! shopping list now shows an estimated retail price badge (per unit and total). Prices are fetched from Gemini AI and cached server-side for 3 months (`PRICE_UPDATE_MONTHS`). The running estimated total is displayed both in the shopping tab and as a green pill badge on the dashboard stat card.
|
||||
- **Dashboard price total badge** — The shopping stat card on the dashboard shows a green `ca. €X.XX` badge (top-right, same position as the old urgency badge). It updates in real-time as prices are calculated and persists across navigation via `sessionStorage`.
|
||||
- **Background price refresh** — Prices are fetched silently every 2 minutes even when not on the shopping tab, keeping the dashboard badge current without user interaction.
|
||||
- **Smart quantity estimation** — The price payload uses `smart_shopping` data (consumption patterns) to send the correct buy quantity per item; falls back to Bring! spec parsing, then to `qty=1, unit=conf` for manually-added items.
|
||||
|
||||
### Fixed
|
||||
- **`stat-price-total` not visible on dashboard** — The total was only computed when `shoppingItems` was populated (i.e. shopping tab had been visited). Now uses `sessionStorage._pricetotal` as fallback so the badge is visible immediately on any page.
|
||||
- **Price bar reloading on every tab switch** — `renderShoppingItems` now checks if ALL items are already cached with matching qty/unit; if so, it applies prices from cache instantly with no loading bar or API call.
|
||||
- **`stat-price-total` real-time update** — Dashboard stat now increments as each individual item is priced (not only after the entire fetch completes).
|
||||
- **Broken emoji in `log.title`** — Corrupted `\uFFFD` character in `it.json` and `de.json` replaced with `📒`.
|
||||
- **`PRICE_CACHE_PATH` undefined crash** — Server-side constant was used inside functions that were called before the define; moved define to the very top of `api/index.php` (line 19). Affected: all `get_shopping_price` and `get_all_shopping_prices` calls from 16:33–16:40 on 2026-05-07.
|
||||
|
||||
## [1.7.1] - 2026-05-04
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
## 🌍 Recent Updates
|
||||
|
||||
- **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab.
|
||||
- **Kiosk v1.7.0: OTA update system** — "Cerca aggiornamenti" button in Settings triggers a forced GitHub release check; new `installUpdate()` JS bridge calls Android `DownloadManager` directly (lockTask mode blocks external browser links); graceful degradation for older APKs with manual instructions. Automatic OTA check every 6 hours with native update banner.
|
||||
- **Kiosk: consistent APK signing** — Project keystore (`evershelf.jks`) committed to the repo; every build — local or CI — now produces an APK with the same signature, eliminating "APK incompatible / signature conflict" errors on OTA update.
|
||||
- **GitHub Actions: auto-publish kiosk APK** — On every push to `main` that touches `evershelf-kiosk/`, Actions builds the APK and publishes a versioned semver release (`kiosk-X.Y.Z`) plus updates the `kiosk-latest` alias. No more manual release uploads.
|
||||
|
||||
+10
-2
@@ -16,6 +16,9 @@ function getDB(): PDO {
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
$db->exec("PRAGMA foreign_keys=ON");
|
||||
$db->exec("PRAGMA synchronous=NORMAL"); // faster writes, still safe with WAL
|
||||
$db->exec("PRAGMA cache_size=-8000"); // ~8 MB page cache (was 2 MB)
|
||||
$db->exec("PRAGMA temp_store=MEMORY"); // temp tables in RAM
|
||||
|
||||
if ($isNew) {
|
||||
initializeDB($db);
|
||||
@@ -308,7 +311,12 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\bpane\b/', $n)) return 4;
|
||||
// Specific jarred tomato sauce in pantry (opened, not refrigerated)
|
||||
if (preg_match('/salsa\s+di\s+(pomodoro|pronta)/', $n)) return 5;
|
||||
return 60; // generic pantry fallback (was 30, doubled)
|
||||
// Dairy opened outside fridge: bad very quickly at room temperature
|
||||
if (preg_match('/\bpanna\b/', $n)) return 3;
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 2;
|
||||
if (preg_match('/\blatte\b/', $n)) return 1;
|
||||
if (preg_match('/\bformaggio\b/', $n)) return 2;
|
||||
return 60; // generic pantry fallback
|
||||
}
|
||||
|
||||
// ── F: Fridge — short-life perishables ──────────────────────────────
|
||||
@@ -317,7 +325,7 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
// Long-life mountain/brand milks stored in pantry before use (UHT)
|
||||
if (preg_match('/latte.*(montagna|alta\s+qual|parmalat|granarolo|esselunga|conservaz|microfiltrat)/i', $n)) return 7;
|
||||
if (preg_match('/\blatte\b/', $n)) return 4;
|
||||
if (preg_match('/\byogurt\b/', $n)) return 5;
|
||||
if (preg_match('/\b(yogurt|yaourt|yoghurt)\b/', $n)) return 5;
|
||||
if (preg_match('/mozzarella|burrata|stracciatella/', $n)) return 3;
|
||||
if (preg_match('/philadelphia|spalmabile/', $n)) return 7;
|
||||
if (preg_match('/formaggio.*(fresco|ricotta|mascarpone|stracchino|crescenza)/', $n)) return 5;
|
||||
|
||||
+634
-47
@@ -16,6 +16,7 @@
|
||||
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
|
||||
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
|
||||
define('GH_REPO', 'dadaloop82/EverShelf');
|
||||
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
|
||||
|
||||
/** Decode the XOR-obfuscated GitHub token at runtime. */
|
||||
function _ghToken(): string {
|
||||
@@ -114,11 +115,17 @@ function checkRateLimit(string $action): void {
|
||||
$loginActions = [];
|
||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||
$errorActions = ['report_error', 'check_update'];
|
||||
$priceActions = ['get_shopping_price', 'get_all_shopping_prices'];
|
||||
|
||||
if (in_array($action, $aiActions)) {
|
||||
$limit = 15;
|
||||
$window = 60;
|
||||
$bucket = 'ai';
|
||||
} elseif (in_array($action, $priceActions)) {
|
||||
// Price lookups: up to 30 items × a few retries per minute, shared bucket
|
||||
$limit = 60;
|
||||
$window = 60;
|
||||
$bucket = 'price';
|
||||
} elseif (in_array($action, $recipeActions)) {
|
||||
$limit = 5;
|
||||
$window = 60;
|
||||
@@ -410,6 +417,14 @@ try {
|
||||
geminiAnomalyExplain();
|
||||
break;
|
||||
|
||||
case 'get_shopping_price':
|
||||
getShoppingPrice($db);
|
||||
break;
|
||||
|
||||
case 'get_all_shopping_prices':
|
||||
getAllShoppingPrices($db);
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
echo json_encode(['error' => 'Unknown action: ' . $action]);
|
||||
@@ -1213,17 +1228,19 @@ function useFromInventory(PDO $db): void {
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$existing['id']]);
|
||||
} else {
|
||||
// Check if item is now opened (first use reduces quantity)
|
||||
// Check if item is now opened (first use creates a fractional/partial package)
|
||||
$wasOpened = !empty($existing['opened_at']);
|
||||
$isNowOpened = false;
|
||||
$unit = $prodInfo['unit'] ?? 'pz';
|
||||
$defQty = (float)($prodInfo['default_quantity'] ?? 0);
|
||||
if ($unit === 'conf') {
|
||||
$w = floor($newQty + 0.001);
|
||||
$f = round($newQty - $w, 6);
|
||||
// Opened = a fractional (non-integer) quantity remains
|
||||
$f = round($newQty - floor($newQty + 0.001), 6);
|
||||
if ($f > 0.001) $isNowOpened = true;
|
||||
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0 && $newQty < $defQty - 0.001) {
|
||||
$isNowOpened = true;
|
||||
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) {
|
||||
// Opened = remaining qty is not a clean multiple of the package size
|
||||
$pkgRem = round($newQty - floor($newQty / $defQty + 0.001) * $defQty, 6);
|
||||
if ($pkgRem > $defQty * 0.01) $isNowOpened = true;
|
||||
}
|
||||
|
||||
if ($isNowOpened && !$wasOpened) {
|
||||
@@ -1238,8 +1255,45 @@ function useFromInventory(PDO $db): void {
|
||||
if (!empty($existing['expiry_date']) && strtotime($existing['expiry_date']) < strtotime($openedExpiry)) {
|
||||
$openedExpiry = $existing['expiry_date'];
|
||||
}
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||
|
||||
// Split opened portion from sealed packages into two separate rows:
|
||||
// closed packages stay at original location, opened portion is offered to move.
|
||||
if ($unit === 'conf') {
|
||||
$newWhole = (int)floor($newQty + 0.001);
|
||||
$newFrac = round($newQty - $newWhole, 6);
|
||||
if ($newFrac > 0.001 && $newWhole >= 1) {
|
||||
// Keep whole confs in original row (no opened_at, sealed expiry unchanged)
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newWhole, $existing['id']]);
|
||||
// New row for the opened fraction with short shelf-life expiry
|
||||
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)");
|
||||
$stmt->execute([$productId, $location, $newFrac, $openedExpiry, $vacuum]);
|
||||
$openedId = (int)$db->lastInsertId();
|
||||
} else {
|
||||
// Only the opened fraction remains (≤ 1 conf) — single row
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||
}
|
||||
} elseif (in_array($unit, ['g','kg','ml','l']) && $defQty > 0) {
|
||||
$newWholePkgs = (int)floor($newQty / $defQty + 0.001);
|
||||
$newRemainder = round($newQty - $newWholePkgs * $defQty, 6);
|
||||
if ($newRemainder > $defQty * 0.01 && $newWholePkgs >= 1) {
|
||||
// Keep whole packages in original row (no opened_at, sealed expiry unchanged)
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newWholePkgs * $defQty, $existing['id']]);
|
||||
// New row for the opened partial package with short shelf-life expiry
|
||||
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed, opened_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)");
|
||||
$stmt->execute([$productId, $location, $newRemainder, $openedExpiry, $vacuum]);
|
||||
$openedId = (int)$db->lastInsertId();
|
||||
} else {
|
||||
// Only the opened remainder (last package) — single row
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||
}
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, opened_at = CURRENT_TIMESTAMP, expiry_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $openedExpiry, $existing['id']]);
|
||||
}
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $existing['id']]);
|
||||
@@ -1260,12 +1314,16 @@ function useFromInventory(PDO $db): void {
|
||||
|
||||
$remaining = $newQty;
|
||||
|
||||
// Check if opened part remains (for non-split path)
|
||||
if ($remaining > 0 && $prodInfo && $prodInfo['unit'] === 'conf') {
|
||||
$w = floor($remaining + 0.001);
|
||||
$f = round($remaining - $w, 6);
|
||||
if ($f > 0.001) {
|
||||
$openedId = (int)$existing['id'];
|
||||
// Check if opened part remains (for non-split path, only when not already set by split above)
|
||||
if ($openedId === null && $remaining > 0 && $prodInfo) {
|
||||
$unitFb = $prodInfo['unit'] ?? '';
|
||||
$defQtyFb = (float)($prodInfo['default_quantity'] ?? 0);
|
||||
if ($unitFb === 'conf') {
|
||||
$f = round($remaining - floor($remaining + 0.001), 6);
|
||||
if ($f > 0.001) $openedId = (int)$existing['id'];
|
||||
} elseif (in_array($unitFb, ['g','kg','ml','l']) && $defQtyFb > 0) {
|
||||
$pkgRemFb = round($remaining - floor($remaining / $defQtyFb + 0.001) * $defQtyFb, 6);
|
||||
if ($pkgRemFb > $defQtyFb * 0.01) $openedId = (int)$existing['id'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1772,7 +1830,7 @@ function getStats(PDO $db): void {
|
||||
}
|
||||
// Compute opened shelf-life using AI (with rule-based fallback + persistent cache).
|
||||
// The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays.
|
||||
$openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum);
|
||||
$openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum, false);
|
||||
$computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
|
||||
// Always respect the manufacturer date: if the package expires before our estimate,
|
||||
// use the manufacturer date (e.g., milk opened 2 days before its sealed expiry).
|
||||
@@ -2079,6 +2137,10 @@ function getServerSettings(): void {
|
||||
'meal_plan_enabled' => env('MEAL_PLAN_ENABLED', 'false') === 'true',
|
||||
'screensaver_enabled' => env('SCREENSAVER_ENABLED', 'false') === 'true',
|
||||
'screensaver_timeout' => (int)env('SCREENSAVER_TIMEOUT', '5'),
|
||||
'price_enabled' => env('PRICE_ENABLED', 'false') === 'true',
|
||||
'price_country' => env('PRICE_COUNTRY', 'Italia'),
|
||||
'price_currency' => env('PRICE_CURRENCY', 'EUR'),
|
||||
'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2112,6 +2174,8 @@ function saveSettings(): void {
|
||||
'camera_facing' => 'CAMERA_FACING',
|
||||
'dietary' => 'DIETARY',
|
||||
'scale_gateway_url' => 'SCALE_GATEWAY_URL',
|
||||
'price_country' => 'PRICE_COUNTRY',
|
||||
'price_currency' => 'PRICE_CURRENCY',
|
||||
];
|
||||
// Boolean keys
|
||||
$boolMap = [
|
||||
@@ -2125,11 +2189,13 @@ function saveSettings(): void {
|
||||
'scale_enabled' => 'SCALE_ENABLED',
|
||||
'meal_plan_enabled' => 'MEAL_PLAN_ENABLED',
|
||||
'screensaver_enabled' => 'SCREENSAVER_ENABLED',
|
||||
'price_enabled' => 'PRICE_ENABLED',
|
||||
];
|
||||
// Integer keys
|
||||
$intMap = [
|
||||
'default_persons' => 'DEFAULT_PERSONS',
|
||||
'screensaver_timeout' => 'SCREENSAVER_TIMEOUT',
|
||||
'price_update_months' => 'PRICE_UPDATE_MONTHS',
|
||||
];
|
||||
|
||||
foreach ($keyMap as $inKey => $envKey) {
|
||||
@@ -2266,14 +2332,19 @@ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 3
|
||||
* Falls back to the rule-based estimate if AI is unavailable or returns an unusable answer.
|
||||
* Cache has no expiry — shelf-life science doesn't change; the file can be manually deleted to refresh.
|
||||
*/
|
||||
function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false): int {
|
||||
function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false, bool $allowAI = true): int {
|
||||
$cacheFile = __DIR__ . '/../data/opened_shelf_cache.json';
|
||||
$cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location));
|
||||
|
||||
// Load cache
|
||||
$cache = [];
|
||||
if (file_exists($cacheFile)) {
|
||||
$cache = json_decode(file_get_contents($cacheFile), true) ?: [];
|
||||
// Static in-memory cache: the file is read only ONCE per PHP request,
|
||||
// even when this function is called for many items in a loop (e.g. getStats).
|
||||
static $cache = null;
|
||||
static $cacheDirty = false;
|
||||
if ($cache === null) {
|
||||
$cache = [];
|
||||
if (file_exists($cacheFile)) {
|
||||
$cache = json_decode(file_get_contents($cacheFile), true) ?: [];
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($cache[$cacheKey]['days'])) {
|
||||
@@ -2281,10 +2352,10 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location
|
||||
return $vacuumSealed ? (int)round($days * 1.5) : $days;
|
||||
}
|
||||
|
||||
// Try Gemini AI
|
||||
// Try Gemini AI (only when explicitly allowed — NOT during bulk stats loops)
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
$days = 0;
|
||||
if (!empty($apiKey)) {
|
||||
if ($allowAI && !empty($apiKey)) {
|
||||
$locLabel = match($location) {
|
||||
'frigo' => 'refrigerator (4 °C / 39 °F)',
|
||||
'freezer' => 'freezer (-18 °C / 0 °F)',
|
||||
@@ -2305,7 +2376,11 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location
|
||||
// Reject AI values if they are suspiciously low compared to the rule-based estimate
|
||||
// (protects against Gemini hallucinations like "1 day for butter").
|
||||
$ruleMin = estimateOpenedExpiryDaysPHP($name, $category, $location);
|
||||
if ($parsed > 0 && $parsed <= 3650 && $parsed >= max(1, (int)floor($ruleMin * 0.5))) {
|
||||
// Accept AI value only if within a reasonable multiple of the rule estimate.
|
||||
// Upper bound: 4× rule (or 30 days minimum ceiling) — blocks Gemini hallucinations
|
||||
// like "60 days for yogurt" (rule=5 → max allowed = 20).
|
||||
$aiMax = max($ruleMin * 4, 30);
|
||||
if ($parsed > 0 && $parsed <= $aiMax && $parsed >= max(1, (int)floor($ruleMin * 0.5))) {
|
||||
$days = $parsed;
|
||||
}
|
||||
}
|
||||
@@ -2320,8 +2395,10 @@ function getOpenedShelfLifeDays(string $name, string $category, string $location
|
||||
$source = 'ai';
|
||||
}
|
||||
|
||||
// Persist to cache
|
||||
// Persist to in-memory cache (file will be flushed at end of request via register_shutdown_function)
|
||||
$cache[$cacheKey] = ['days' => $days, 'source' => $source, 'name' => $name, 'location' => $location, 'ts' => time()];
|
||||
$cacheDirty = true;
|
||||
// Write immediately so single-item requests (opened_shelf_life action) are persisted
|
||||
@file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
|
||||
return $vacuumSealed ? (int)round($days * 1.5) : $days;
|
||||
@@ -3041,7 +3118,7 @@ function generateRecipe(PDO $db): void {
|
||||
|
||||
$extraRulesText = '';
|
||||
if (!empty($extraRules)) {
|
||||
$extraRulesText = "\n\nPREFERENZE DELL'UTENTE:\n" . implode("\n", $extraRules);
|
||||
$extraRulesText = "\n\n⚠️ PREFERENZE OBBLIGATORIE (RISPETTALE SEMPRE, non sono suggerimenti):\n" . implode("\n", array_map(fn($r) => "→ $r", $extraRules));
|
||||
}
|
||||
|
||||
// Appliances
|
||||
@@ -3185,7 +3262,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per
|
||||
REGOLE:
|
||||
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto.
|
||||
2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili).
|
||||
3. Quantità per $persons persona/e. Se un ingrediente ha poca quantità, usalo TUTTO.
|
||||
3. Quantità MASSIME per $persons persona/e (NON superare mai): pasta/riso asciutto 90g/pers, carne 180g/pers, pesce 200g/pers, legumi secchi 80g/pers (lessi 200g/pers), verdure contorno 200g/pers, formaggio 80g/pers, latte 200ml/pers, farina per dolci 200g/pers. Se un ingrediente rimasto è inferiore a questi limiti, usalo tutto.
|
||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0.
|
||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||
@@ -3718,7 +3795,7 @@ You are an expert home chef. Generate ONE recipe for $mealLabel for $persons per
|
||||
REGOLE:
|
||||
{$mealPlanRule}1. PRIORITÀ: usa prima gli ingredienti scaduti/in scadenza (⚠️🔴🟠), poi quelli [APERTO], poi il resto.
|
||||
2. Usa SOLO ingredienti dalla lista + acqua/sale/pepe/olio (sempre disponibili).
|
||||
3. Quantità per $persons persona/e. Se un ingrediente ha poca quantità, usalo TUTTO.
|
||||
3. Quantità MASSIME per $persons persona/e (NON superare mai): pasta/riso asciutto 90g/pers, carne 180g/pers, pesce 200g/pers, legumi secchi 80g/pers (lessi 200g/pers), verdure contorno 200g/pers, formaggio 80g/pers, latte 200ml/pers, farina per dolci 200g/pers. Se un ingrediente rimasto è inferiore a questi limiti, usalo tutto.
|
||||
4. "qty_number": valore NUMERICO nella STESSA unità della dispensa (g/ml/pz/conf, MAI kg o litri). Per non-dispensa: 0.
|
||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||
@@ -5356,6 +5433,93 @@ function smartShopping(PDO $db): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- Suggested purchase quantity (based on 14-day consumption) ---
|
||||
// Rules:
|
||||
// unit='conf' → conf count from dailyRate directly
|
||||
// unit=g/ml/pz + package_unit non-empty → # confezioni (definitive)
|
||||
// unit=g/ml + defQty > 0 (no pkg_unit) → round to nearest defQty multiple (approx)
|
||||
// unit=g/ml, no defQty, no pkg_unit → raw amount, rounded to sensible step
|
||||
// unit=pz, no pkg_unit → raw pz count (approx)
|
||||
// dailyRate=0 → null (no data)
|
||||
$suggestedQty = null;
|
||||
$suggestedUnit = $unit;
|
||||
$suggestedApprox = false; // true = show "almeno" in badge
|
||||
|
||||
$pkgUnit = trim($p['package_unit'] ?? ''); // non-empty only when user set a real package
|
||||
|
||||
if ($dailyRate > 0) {
|
||||
$need14 = $dailyRate * 14;
|
||||
|
||||
if ($unit === 'conf') {
|
||||
// Guard against unit mismatch: transactions may have been recorded in g/ml
|
||||
// (e.g. product unit was changed from 'g' to 'conf' after initial tracking).
|
||||
// If totalUsed is much larger than buy_count (e.g. 900 vs 4), it's clearly grams.
|
||||
// In that case fall back to purchase-frequency as the daily rate.
|
||||
if ($buyCount > 0 && $totalUsed > $buyCount * 5 && $daysSinceFirst < 999) {
|
||||
$need14 = ($buyCount / $daysSinceFirst) * 14;
|
||||
}
|
||||
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
|
||||
$suggestedUnit = 'conf';
|
||||
|
||||
} elseif ($pkgUnit !== '' && $defQty > 0) {
|
||||
// Real package info available → express in confezioni (definitive)
|
||||
$pkgs = (int) max(1, min(10, (int)($need14 / $defQty + 0.3)));
|
||||
$suggestedQty = $pkgs;
|
||||
$suggestedUnit = 'conf';
|
||||
|
||||
} elseif (($unit === 'g' || $unit === 'ml') && $defQty > 0) {
|
||||
// defQty known but no pkg_unit (e.g. Pomodorini 400g, Salame 100g) →
|
||||
// use defQty as the minimum purchase unit and round to nearest multiple.
|
||||
// This ensures we never suggest less than one "reference pack".
|
||||
$pkgs = (int) max(1, (int)($need14 / $defQty + 0.3));
|
||||
$pkgs = min(10, $pkgs);
|
||||
$suggestedQty = $pkgs * (int)$defQty;
|
||||
$suggestedUnit = $unit;
|
||||
$suggestedApprox = true; // always "almeno" — no confirmed pkg size
|
||||
|
||||
} elseif ($unit === 'g' || $unit === 'ml') {
|
||||
// No reference at all → raw amount, approximate
|
||||
// Skip if consumption is negligible (< 30 units/14gg)
|
||||
if ($need14 >= 30) {
|
||||
if ($need14 < 500) {
|
||||
$rounded = (int) max(100, round($need14 / 100) * 100);
|
||||
} elseif ($need14 < 2000) {
|
||||
$rounded = (int) max(250, round($need14 / 250) * 250);
|
||||
} else {
|
||||
$rounded = (int) max(500, round($need14 / 500) * 500);
|
||||
}
|
||||
$suggestedQty = $rounded;
|
||||
$suggestedUnit = $unit;
|
||||
$suggestedApprox = true;
|
||||
}
|
||||
|
||||
} elseif ($unit === 'pz') {
|
||||
// No package info → raw pz count, approximate
|
||||
$suggestedQty = (int) max(1, min(10, (int)($need14 + 0.3)));
|
||||
$suggestedUnit = 'pz';
|
||||
$suggestedApprox = ($suggestedQty > 1);
|
||||
}
|
||||
}
|
||||
|
||||
// If stock is still >50% just suggest the minimum sensible purchase (don't over-stock)
|
||||
if ($suggestedQty !== null && $pctLeft > 50) {
|
||||
if ($suggestedUnit === 'conf') {
|
||||
$suggestedQty = 1;
|
||||
$suggestedApprox = false;
|
||||
} elseif ($suggestedUnit === 'pz') {
|
||||
$suggestedQty = 1;
|
||||
$suggestedApprox = false;
|
||||
} else {
|
||||
// g/ml with >50% stock: suggest minimum reference pack or skip
|
||||
if ($defQty > 0) {
|
||||
$suggestedQty = (int)$defQty;
|
||||
$suggestedApprox = true;
|
||||
} else {
|
||||
$suggestedQty = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'product_id' => $pid,
|
||||
'name' => $p['name'],
|
||||
@@ -5382,6 +5546,9 @@ function smartShopping(PDO $db): void {
|
||||
'on_bring' => $onBring,
|
||||
'locations' => $inv ? $inv['locations'] : '',
|
||||
'variants' => [],
|
||||
'suggested_qty' => $suggestedQty, // null = no badge
|
||||
'suggested_unit' => $suggestedUnit,
|
||||
'suggested_approx' => $suggestedApprox, // true = show "almeno" prefix
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5425,7 +5592,7 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
|
||||
function bringSuggestItems(PDO $db): void {
|
||||
// Offline: derive suggestions from smart shopping cache (no AI needed)
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
|
||||
// 1. Load smart shopping data from cache or compute fresh
|
||||
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||
@@ -5450,13 +5617,36 @@ function bringSuggestItems(PDO $db): void {
|
||||
// 2. Get Bring! listUUID for response
|
||||
$listUUID = '';
|
||||
$auth = bringAuth();
|
||||
if ($auth) {
|
||||
$listUUID = $auth['bringListUUID'] ?? '';
|
||||
}
|
||||
if ($auth) $listUUID = $auth['bringListUUID'] ?? '';
|
||||
|
||||
// 3. Convert smart shopping items → suggestions (alta/media priority only, skip on_bring)
|
||||
$suggestions = [];
|
||||
$seasonalTips = [
|
||||
$knownNames = []; // names already in suggestion list (to deduplicate AI output)
|
||||
|
||||
foreach ($smartItems as $item) {
|
||||
if ($item['on_bring'] ?? false) continue;
|
||||
$urgency = $item['urgency'] ?? 'low';
|
||||
if ($urgency === 'low') continue;
|
||||
|
||||
$priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media';
|
||||
$reasons = $item['reasons'] ?? [];
|
||||
$reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse';
|
||||
|
||||
$suggestions[] = [
|
||||
'name' => $item['name'],
|
||||
'specification' => '',
|
||||
'reason' => $reason,
|
||||
'category' => $item['category'] ?: 'altro',
|
||||
'priority' => $priority,
|
||||
'source' => 'stock',
|
||||
];
|
||||
$knownNames[] = mb_strtolower($item['name']);
|
||||
|
||||
if (count($suggestions) >= 15) break;
|
||||
}
|
||||
|
||||
// 4. Seasonal tip (fallback static, overridden by Gemini below)
|
||||
$monthTips = [
|
||||
1 => 'Gennaio: arance, mandarini, kiwi, carciofi e verze sono di stagione.',
|
||||
2 => 'Febbraio: radicchio, finocchi, pere e agrumi da non perdere.',
|
||||
3 => 'Marzo: arrivano gli asparagi! Ottimo anche con piselli freschi e spinaci.',
|
||||
@@ -5470,27 +5660,97 @@ function bringSuggestItems(PDO $db): void {
|
||||
11 => 'Novembre: cachi, melograni, cavoli, broccoli e radicchio tardivo.',
|
||||
12 => 'Dicembre: arance, mandarini, cachi, verze e cavolfiori.',
|
||||
];
|
||||
$seasonalTip = $seasonalTips[(int)date('n')] ?? '';
|
||||
$seasonalTip = $monthTips[(int)date('n')] ?? '';
|
||||
|
||||
foreach ($smartItems as $item) {
|
||||
if ($item['on_bring'] ?? false) continue; // already on shopping list
|
||||
// 5. Try to enrich with Gemini: generate ADDITIONAL seasonal / complementary suggestions
|
||||
if (!empty($apiKey)) {
|
||||
// Cache key: month + list of known names (so it refreshes each month)
|
||||
$gemCacheFile = __DIR__ . '/../data/food_facts_cache.json';
|
||||
$gemCache = file_exists($gemCacheFile) ? (json_decode(file_get_contents($gemCacheFile), true) ?: []) : [];
|
||||
$gemCacheKey = 'suggest_ai_' . date('Y-m') . '_' . md5(implode('|', $knownNames));
|
||||
|
||||
$urgency = $item['urgency'] ?? 'low';
|
||||
if ($urgency === 'low') continue; // not urgent enough to suggest
|
||||
// Cache valid for 6 hours
|
||||
$cached = $gemCache[$gemCacheKey] ?? null;
|
||||
$cacheTs = $gemCache[$gemCacheKey . '_ts'] ?? 0;
|
||||
$cacheValid = $cached && (time() - $cacheTs < 21600);
|
||||
|
||||
$priority = ($urgency === 'critical' || $urgency === 'high') ? 'alta' : 'media';
|
||||
$reasons = $item['reasons'] ?? [];
|
||||
$reason = !empty($reasons) ? implode(', ', $reasons) : 'Scorte basse';
|
||||
if ($cacheValid) {
|
||||
$aiResult = $cached;
|
||||
} else {
|
||||
// Build inventory snapshot for Gemini (what the user already has)
|
||||
$inStockNames = array_map(fn($i) => $i['name'], array_filter($smartItems, fn($i) => ($i['current_qty'] ?? 0) > 0));
|
||||
$dietary = trim(env('DIETARY') ?? '');
|
||||
$monthName = [1=>'Gennaio',2=>'Febbraio',3=>'Marzo',4=>'Aprile',5=>'Maggio',6=>'Giugno',
|
||||
7=>'Luglio',8=>'Agosto',9=>'Settembre',10=>'Ottobre',11=>'Novembre',12=>'Dicembre'][(int)date('n')];
|
||||
$inStockJson = json_encode(array_values(array_slice($inStockNames, 0, 40)), JSON_UNESCAPED_UNICODE);
|
||||
$alreadyJson = json_encode(array_values($knownNames), JSON_UNESCAPED_UNICODE);
|
||||
$dietaryLine = $dietary ? "- Dietary preferences: {$dietary}" : '';
|
||||
|
||||
$suggestions[] = [
|
||||
'name' => $item['name'],
|
||||
'specification' => '',
|
||||
'reason' => $reason,
|
||||
'category' => $item['category'] ?: 'altro',
|
||||
'priority' => $priority,
|
||||
];
|
||||
$prompt = "You are a helpful Italian household shopping assistant.\n"
|
||||
. "Today is {$monthName} " . date('Y') . ".\n"
|
||||
. "The user already has these products in stock: {$inStockJson}\n"
|
||||
. "The following products are already in the shopping list: {$alreadyJson}\n"
|
||||
. ($dietaryLine ? $dietaryLine . "\n" : '')
|
||||
. "\nTask: suggest 3 to 6 additional products the user should buy this month.\n"
|
||||
. "Focus on:\n"
|
||||
. " a) Seasonal Italian fruits and vegetables for {$monthName}\n"
|
||||
. " b) Complementary staples that pair well with what the user has\n"
|
||||
. " c) Anything commonly forgotten but regularly needed\n"
|
||||
. "Do NOT suggest products already in stock or already in the shopping list.\n"
|
||||
. "Also write one short seasonal tip (max 15 words) in Italian.\n"
|
||||
. "\nReply ONLY with valid JSON in this exact format (no markdown):\n"
|
||||
. "{\"seasonal_tip\":\"...\",\"suggestions\":[{\"name\":\"...\",\"reason\":\"...\",\"category\":\"...\",\"priority\":\"bassa\"}]}\n"
|
||||
. "Category must be one of: frutta,verdura,latticini,carne,pesce,pane,cereali,condimenti,bevande,surgelati,altro\n"
|
||||
. "Priority must be: bassa\n"
|
||||
. "Name and reason must be in Italian. Reason max 8 words.";
|
||||
|
||||
if (count($suggestions) >= 12) break;
|
||||
$payload = ['contents' => [['parts' => [['text' => $prompt]]]]];
|
||||
$gemResult = callGeminiWithFallback($apiKey, $payload, 20);
|
||||
|
||||
$aiResult = null;
|
||||
if ($gemResult['http_code'] === 200) {
|
||||
$text = $gemResult['data']['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$text = preg_replace('/^```json\s*/i', '', trim($text));
|
||||
$text = preg_replace('/\s*```$/i', '', $text);
|
||||
$parsed = json_decode(trim($text), true);
|
||||
if (is_array($parsed) && isset($parsed['suggestions'])) {
|
||||
$aiResult = $parsed;
|
||||
// Cache result
|
||||
$gemCache[$gemCacheKey] = $aiResult;
|
||||
$gemCache[$gemCacheKey . '_ts'] = time();
|
||||
file_put_contents($gemCacheFile, json_encode($gemCache, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($aiResult) {
|
||||
// Override seasonal tip with AI-generated one
|
||||
if (!empty($aiResult['seasonal_tip'])) {
|
||||
$seasonalTip = $aiResult['seasonal_tip'];
|
||||
}
|
||||
// Append AI suggestions (deduplicate against stock-based ones)
|
||||
foreach ($aiResult['suggestions'] ?? [] as $ai) {
|
||||
$aiName = mb_strtolower(trim($ai['name'] ?? ''));
|
||||
if (!$aiName) continue;
|
||||
// Skip if already in list (first-token check)
|
||||
$aiFirst = explode(' ', $aiName)[0];
|
||||
$isDup = false;
|
||||
foreach ($knownNames as $kn) {
|
||||
if (str_starts_with($kn, $aiFirst)) { $isDup = true; break; }
|
||||
}
|
||||
if ($isDup) continue;
|
||||
|
||||
$suggestions[] = [
|
||||
'name' => ucfirst(trim($ai['name'])),
|
||||
'specification' => '',
|
||||
'reason' => trim($ai['reason'] ?? 'Stagionale'),
|
||||
'category' => $ai['category'] ?? 'altro',
|
||||
'priority' => 'bassa',
|
||||
'source' => 'ai',
|
||||
];
|
||||
$knownNames[] = $aiName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
@@ -6208,3 +6468,330 @@ function geminiAnomalyExplain(): void {
|
||||
|
||||
echo json_encode(['success' => true, 'explanation' => $explanation]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SHOPPING LIST PRICE ESTIMATION (AI-powered, cached)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Note: PRICE_CACHE_PATH constant is defined at the top of the file.
|
||||
|
||||
function _loadPriceCache(): array {
|
||||
if (!file_exists(PRICE_CACHE_PATH)) return [];
|
||||
try { return json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?? []; } catch (\Throwable $e) { return []; }
|
||||
}
|
||||
|
||||
function _savePriceCache(array $data): void {
|
||||
file_put_contents(PRICE_CACHE_PATH, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return cache key: md5(lowercase name + country + schema version)
|
||||
* Bump version suffix when AI prompt format changes to auto-invalidate old entries.
|
||||
*/
|
||||
function _priceKey(string $name, string $country): string {
|
||||
return md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)) . '|v3');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — the smallest standard unit a shopper would actually buy:
|
||||
- Standard packages (pasta, flour, frozen food, yogurt, canned goods, biscuits): price per typical package (e.g. "pacco 500g", "barattolo 400g", "confezione")
|
||||
- Sold by piece or bunch (fresh herbs, eggs, individual fruit/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 items sold loose by weight: 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. ALWAYS return your best estimate — even for generic or unusual items. Use a typical grocery item if uncertain.
|
||||
5. 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 you are genuinely unsure, return a rough estimate for 1 typical package with a "~" in source_note:
|
||||
{"price_per_unit": 2.00, "unit_label": "confezione", "currency": "{$currency}", "source_note": "~ stima generica confezione in {$country}"}
|
||||
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, '.', '');
|
||||
}
|
||||
|
||||
|
||||
+173
-5
@@ -294,7 +294,9 @@ body {
|
||||
.scale-icon-emoji {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
transition: filter 0.3s, opacity 0.3s;
|
||||
/* Force white icon regardless of connection status — only the dot changes color */
|
||||
filter: brightness(0) invert(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.scale-status-dot {
|
||||
position: absolute;
|
||||
@@ -310,13 +312,10 @@ body {
|
||||
}
|
||||
/* Connected: white fill + bright-green border ring — clearly visible on any dark/green bg */
|
||||
.scale-status-connected .scale-status-dot { background: #ffffff; border-color: #4ade80; box-shadow: 0 0 0 1px rgba(0,0,0,0.25), 0 0 8px #4ade80cc, 0 0 2px #fff; }
|
||||
.scale-status-connected .scale-icon-emoji { filter: none; opacity: 1; }
|
||||
.scale-status-searching .scale-status-dot { background: #f59e0b; border-color: rgba(0,0,0,0.35); animation: scaleStatusPulse 1.4s infinite; }
|
||||
.scale-status-searching .scale-icon-emoji { filter: none; opacity: 0.85; }
|
||||
.scale-status-disconnected .scale-status-dot { background: #64748b; border-color: rgba(0,0,0,0.35); }
|
||||
.scale-status-disconnected .scale-icon-emoji { filter: grayscale(0.5); opacity: 0.55; }
|
||||
.scale-status-disconnected .scale-icon-emoji { opacity: 0.55; }
|
||||
.scale-status-error .scale-status-dot { background: #ef4444; border-color: rgba(0,0,0,0.35); box-shadow: 0 0 5px #ef4444aa; }
|
||||
.scale-status-error .scale-icon-emoji { filter: none; opacity: 1; }
|
||||
@keyframes scaleStatusPulse {
|
||||
0%, 100% { box-shadow: 0 0 3px #f59e0b88; }
|
||||
50% { box-shadow: 0 0 9px #f59e0bcc; }
|
||||
@@ -517,6 +516,24 @@ body {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Skeleton shimmer while stat card data is loading */
|
||||
@keyframes stat-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.stat-value.stat-loading {
|
||||
color: transparent !important;
|
||||
background: linear-gradient(90deg, var(--border) 25%, color-mix(in srgb, var(--border) 40%, white) 50%, var(--border) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: stat-shimmer 1.2s ease-in-out infinite;
|
||||
border-radius: 8px;
|
||||
min-width: 40px;
|
||||
min-height: 2.4rem;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
@@ -1862,6 +1879,121 @@ body {
|
||||
.badge-local-tag { background: #e0f2fe; color: #0369a1; cursor: pointer; }
|
||||
.badge-local-tag:hover { background: #bae6fd; }
|
||||
|
||||
/* ─── Shopping price badge ─── */
|
||||
.shopping-item-price-badge {
|
||||
margin-top: 4px;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
.price-badge-value {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: #15803d;
|
||||
background: #dcfce7;
|
||||
border-radius: 8px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.price-badge-loading {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
}
|
||||
.price-badge-error {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ─── Price column — right-side per-item price display ─── */
|
||||
.shopping-item-price-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 58px;
|
||||
text-align: right;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.price-col-main {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: #15803d;
|
||||
white-space: nowrap;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.price-col-unit {
|
||||
font-size: 0.62rem;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.price-col-loading {
|
||||
font-size: 0.7rem;
|
||||
color: #cbd5e1;
|
||||
font-style: italic;
|
||||
}
|
||||
.price-col-error {
|
||||
font-size: 0.75rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
/* ─── Price summary bar (top of shopping tab) ─── */
|
||||
.shopping-price-total-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 4px;
|
||||
background: linear-gradient(135deg, #f0fdf4, #dcfce7);
|
||||
border-radius: 10px;
|
||||
margin: 8px 0 4px;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
box-shadow: 0 1px 4px rgba(21,128,61,0.08);
|
||||
}
|
||||
.price-total-label {
|
||||
flex: 1;
|
||||
}
|
||||
.price-total-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn-price-refresh {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-price-refresh:hover { background: rgba(0,0,0,0.07); }
|
||||
.btn-price-refresh:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Loading progress bar */
|
||||
.price-loading-bar {
|
||||
height: 3px;
|
||||
background: #dcfce7;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin: 0 12px 6px;
|
||||
}
|
||||
.price-loading-inner {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: #16a34a;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@keyframes price-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
/* Tag add button */
|
||||
.shopping-item-tag-btn {
|
||||
background: none;
|
||||
@@ -2194,6 +2326,11 @@ body {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
.priority-ai {
|
||||
background: #f0f4ff;
|
||||
color: #4338ca;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.suggestion-actions {
|
||||
margin-top: 12px;
|
||||
@@ -2389,6 +2526,18 @@ body {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.smart-freq-badge.freq-suggest {
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.smart-freq-badge.freq-suggest-approx {
|
||||
background: #f0f9ff;
|
||||
color: #0284c7;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.smart-pred-badge {
|
||||
background: #fefce8;
|
||||
color: #a16207;
|
||||
@@ -2460,6 +2609,19 @@ body {
|
||||
border-radius: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.stat-price-total {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== PRODUCT PREVIEW ===== */
|
||||
.product-preview, .product-preview-small {
|
||||
@@ -4915,6 +5077,12 @@ body.cooking-mode-active .app-header {
|
||||
}
|
||||
.banner-anomaly .alert-banner-title { color: #9a3412; }
|
||||
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
|
||||
.alert-banner.banner-no-expiry {
|
||||
background: linear-gradient(135deg, #f0fdf4 0%, #bbf7d0 100%);
|
||||
border-color: #16a34a;
|
||||
}
|
||||
.banner-no-expiry .alert-banner-title { color: #14532d; }
|
||||
.banner-no-expiry .alert-banner-counter .banner-dot.active { background: #16a34a; }
|
||||
.alert-banner-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
+879
-117
File diff suppressed because it is too large
Load Diff
@@ -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 +0,0 @@
|
||||
{"ts":1777391782}
|
||||
@@ -1,44 +1,51 @@
|
||||
{
|
||||
"226887def70e33ef73290ebfe75ed4d0": {
|
||||
"days": 7,
|
||||
"source": "ai",
|
||||
"name": "Polpa di pomodoro finissima",
|
||||
"location": "frigo",
|
||||
"ts": 1777444819
|
||||
},
|
||||
"0ed51c9496aa9edfe38caf41772f54ed": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Latte di Montagna",
|
||||
"location": "frigo",
|
||||
"ts": 1777444820
|
||||
},
|
||||
"2d63d0216a75d46b465150e925d2e7ad": {
|
||||
"days": 30,
|
||||
"source": "rule",
|
||||
"name": "Burro",
|
||||
"location": "frigo",
|
||||
"ts": 1777444821
|
||||
},
|
||||
"f6504a014f17457e3dbe0ba917ad681f": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Latte di Montagna",
|
||||
"location": "dispensa",
|
||||
"ts": 1777444888
|
||||
},
|
||||
"7b15356b493402e17fa417a389e89716": {
|
||||
"days": 60,
|
||||
"source": "rule",
|
||||
"name": "Yaourt Vanille",
|
||||
"location": "dispensa",
|
||||
"ts": 1777472391
|
||||
},
|
||||
"9afdf35c4a256867ef47c32495349eb6": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Yaourt Vanille",
|
||||
"location": "frigo",
|
||||
"ts": 1777480477
|
||||
}
|
||||
"226887def70e33ef73290ebfe75ed4d0": {
|
||||
"days": 7,
|
||||
"source": "ai",
|
||||
"name": "Polpa di pomodoro finissima",
|
||||
"location": "frigo",
|
||||
"ts": 1777444819
|
||||
},
|
||||
"0ed51c9496aa9edfe38caf41772f54ed": {
|
||||
"days": 7,
|
||||
"source": "rule",
|
||||
"name": "Latte di Montagna",
|
||||
"location": "frigo",
|
||||
"ts": 1777444820
|
||||
},
|
||||
"2d63d0216a75d46b465150e925d2e7ad": {
|
||||
"days": 30,
|
||||
"source": "rule",
|
||||
"name": "Burro",
|
||||
"location": "frigo",
|
||||
"ts": 1777444821
|
||||
},
|
||||
"9afdf35c4a256867ef47c32495349eb6": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Yaourt Vanille",
|
||||
"location": "frigo",
|
||||
"ts": 1777480477
|
||||
},
|
||||
"584f57418733a1f2acd29fe2e8816129": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Passata di pomodoro",
|
||||
"location": "frigo",
|
||||
"ts": 1778133522
|
||||
},
|
||||
"baeb7f2021b4bb91c368c9131a61f07c": {
|
||||
"days": 10,
|
||||
"source": "rule",
|
||||
"name": "Formaggio Monte Maria",
|
||||
"location": "frigo",
|
||||
"ts": 1778133523
|
||||
},
|
||||
"063f2d534407214786d039bb2bffbb93": {
|
||||
"days": 5,
|
||||
"source": "rule",
|
||||
"name": "Carote",
|
||||
"location": "frigo",
|
||||
"ts": 1778133524
|
||||
}
|
||||
}
|
||||
+79
-4
@@ -11,7 +11,7 @@
|
||||
<title>EverShelf</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260506e">
|
||||
<link rel="stylesheet" href="assets/css/style.css?v=20260507d">
|
||||
<!-- QuaggaJS for barcode scanning -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
|
||||
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
|
||||
@@ -119,7 +119,7 @@
|
||||
<span class="stat-icon">🛒</span>
|
||||
<span class="stat-value" id="stat-spesa">-</span>
|
||||
<span class="stat-label" data-i18n="nav.shopping">Spesa</span>
|
||||
<span class="stat-urgent" id="stat-urgent" style="display:none"></span>
|
||||
<span class="stat-price-total" id="stat-price-total" style="display:none"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -630,6 +630,17 @@
|
||||
|
||||
<!-- Tab panel: Da comprare -->
|
||||
<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-section-header">
|
||||
<h3 data-i18n="shopping.section_to_buy">🛍️ Da comprare</h3>
|
||||
@@ -651,11 +662,14 @@
|
||||
</div>
|
||||
<div class="shopping-actions">
|
||||
<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 class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px" data-i18n="shopping.force_sync">
|
||||
🔄 Forza sincronizzazione Bring!
|
||||
</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>
|
||||
|
||||
@@ -785,6 +799,67 @@
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')">👁️ Mostra/Nascondi</button>
|
||||
</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>
|
||||
<!-- Recipe Tab -->
|
||||
<div class="settings-panel" id="tab-recipe">
|
||||
@@ -1386,6 +1461,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260505a"></script>
|
||||
<script src="assets/js/app.js?v=20260507f"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.3",
|
||||
"version": "1.7.4",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+29
-6
@@ -84,7 +84,7 @@
|
||||
"opened_title": "📦 Geöffnete Produkte",
|
||||
"review_title": "🔍 Zu prüfen",
|
||||
"review_hint": "Mengen, die ungewöhnlich erscheinen. Bestätigen oder ändern.",
|
||||
"quick_recipe": "🍳 Schnelles Rezept mit ablaufenden Produkten",
|
||||
"quick_recipe": "Schnelles Rezept mit ablaufenden Produkten",
|
||||
"banner_review_title": "Ungewöhnliche Menge",
|
||||
"banner_review_action_ok": "Ist korrekt",
|
||||
"banner_review_action_finish": "🗑️ Alles aufgebraucht",
|
||||
@@ -104,6 +104,11 @@
|
||||
"banner_expired_action_edit": "Datum korrigieren",
|
||||
"banner_anomaly_action_edit": "Bestand korrigieren",
|
||||
"banner_anomaly_action_dismiss": "Menge ist korrekt",
|
||||
"banner_no_expiry_title": "Ablaufdatum fehlt: {name}",
|
||||
"banner_no_expiry_detail": "Dieses Produkt hat kein Ablaufdatum. Möchten Sie eines hinzufügen oder bestätigen, dass es nicht verfällt?",
|
||||
"banner_no_expiry_action_set": "Ablaufdatum setzen",
|
||||
"banner_no_expiry_action_dismiss": "Läuft nicht ab ✓",
|
||||
"banner_no_expiry_toast_dismissed": "Als 'läuft nicht ab' markiert",
|
||||
"banner_expiring_title": "Bald ablaufend",
|
||||
"banner_expiring_today": "Läuft heute ab!",
|
||||
"banner_expiring_tomorrow": "Läuft morgen ab",
|
||||
@@ -130,8 +135,7 @@
|
||||
"banner_anomaly_phantom_title": "mehr Bestand als erwartet",
|
||||
"banner_anomaly_phantom_detail": "Bestand zeigt {inv_qty} {unit}, aber laut Buchungen solltest du nur {expected_qty} {unit} haben. Hast du Bestand ohne Buchung hinzugefügt?",
|
||||
"banner_anomaly_untracked_title": "Anfangsbestand nicht als Eingang gebucht",
|
||||
"banner_anomaly_untracked_detail": "Du hast <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_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}%)",
|
||||
@@ -158,7 +162,8 @@
|
||||
"label_quantity": "📦 Menge",
|
||||
"label_added": "📅 Hinzugefügt",
|
||||
"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": {
|
||||
"title": "Produkt scannen",
|
||||
@@ -341,7 +346,7 @@
|
||||
"suggestions_title": "💡 KI-Vorschläge",
|
||||
"suggestions_add": "✅ Ausgewählte zu Bring! hinzufügen",
|
||||
"search_prices": "🔍 Alle Preise suchen",
|
||||
"suggest_btn": "🤖 Einkaufsvorschläge",
|
||||
"suggest_btn": "Einkaufsvorschläge",
|
||||
"smart_title": "🧠 Intelligente Vorhersagen",
|
||||
"smart_empty": "Keine Vorhersagen verfügbar.<br>Füge Produkte zur Vorratskammer hinzu, um intelligente Vorhersagen zu erhalten.",
|
||||
"smart_filter_all": "Alle",
|
||||
@@ -357,6 +362,10 @@
|
||||
"smart_already": "📊 Intelligenter Einkauf sagt bereits {name} voraus",
|
||||
"all_searched": "Alle Produkte wurden bereits gesucht. Nutze 🔄 für einzelne Suchen.",
|
||||
"search_complete": "Suche abgeschlossen: {count} Produkte",
|
||||
"suggest_buy": "🛒 Kaufen: {qty} {unit}",
|
||||
"suggest_buy_approx": "🛒 Mindestens: {qty} {unit}",
|
||||
"suggest_buy_tip": "Empfohlene Menge basierend auf dem Verbrauch der letzten 14 Tage",
|
||||
"suggest_buy_approx_tip": "Mindestschätzung basierend auf Verbrauch (nächste Packungsgröße kaufen)",
|
||||
"removed_sufficient": "🧹 {removed} Produkt(e) mit ausreichendem Bestand von der Liste entfernt",
|
||||
"bring_badge": "🛒 Schon auf Bring!",
|
||||
"add_urgent_toast": "🔴 {n} dringende(s) Produkt(e) automatisch zu Bring! hinzugefügt",
|
||||
@@ -409,6 +418,10 @@
|
||||
"savings_offers": "· 🏷️ Du sparst €{amount} mit Angeboten",
|
||||
"searching_progress": "Suche {current}/{total}...",
|
||||
"remove_error": "Fehler beim Entfernen",
|
||||
"btn_fetch_prices": "Preise suchen",
|
||||
"price_total_label": "💰 Geschätzter Gesamtpreis:",
|
||||
"price_loading": "Preise werden gesucht…",
|
||||
"price_not_found": "Preis n/v",
|
||||
"suggest_loading": "Analyse läuft...",
|
||||
"suggest_error": "Fehler bei der Vorschlagserstellung",
|
||||
"priority_high": "Hoch",
|
||||
@@ -427,7 +440,7 @@
|
||||
"use_data_no_barcode": "✅ KI-Daten verwenden (ohne Barcode)"
|
||||
},
|
||||
"log": {
|
||||
"title": "� Verlauf",
|
||||
"title": "📒 Verlauf",
|
||||
"type_added": "Hinzugefügt",
|
||||
"type_waste": "Entsorgt",
|
||||
"type_used": "Verwendet",
|
||||
@@ -501,6 +514,15 @@
|
||||
"email_label": "📧 Bring! E-Mail",
|
||||
"password_label": "🔒 Bring! Passwort"
|
||||
},
|
||||
"price": {
|
||||
"title": "💰 Preisschätzung (KI)",
|
||||
"hint": "Zeigt geschätzte Kosten pro Produkt in der Einkaufsliste mithilfe von KI an.",
|
||||
"enabled_label": "Preisschätzung aktivieren",
|
||||
"country_label": "🌍 Referenzland",
|
||||
"currency_label": "💱 Währung",
|
||||
"update_label": "🔄 Preise aktualisieren alle",
|
||||
"update_suffix": "Monate"
|
||||
},
|
||||
"recipe": {
|
||||
"title": "🍳 Rezept-Einstellungen",
|
||||
"hint": "Konfiguriere die Standardoptionen für die Rezeptgenerierung.",
|
||||
@@ -899,6 +921,7 @@
|
||||
},
|
||||
"meal_plan": {
|
||||
"reset_success": "Wochenplan zurückgesetzt",
|
||||
"not_available": "nicht im Vorrat verfügbar",
|
||||
"suggested_by": "vom Wochenplan vorgeschlagen"
|
||||
},
|
||||
"kiosk_session": {
|
||||
|
||||
+27
-3
@@ -84,7 +84,7 @@
|
||||
"opened_title": "📦 Opened Products",
|
||||
"review_title": "🔍 To Review",
|
||||
"review_hint": "Quantities that seem unusual. Confirm if correct or modify.",
|
||||
"quick_recipe": "🍳 Quick recipe with expiring products",
|
||||
"quick_recipe": "Quick recipe with expiring products",
|
||||
"banner_review_title": "Anomalous quantity",
|
||||
"banner_review_action_ok": "It's correct",
|
||||
"banner_review_action_finish": "🗑️ All gone",
|
||||
@@ -104,6 +104,11 @@
|
||||
"banner_expired_action_edit": "Fix date",
|
||||
"banner_anomaly_action_edit": "Fix inventory",
|
||||
"banner_anomaly_action_dismiss": "Quantity is correct",
|
||||
"banner_no_expiry_title": "Missing expiry: {name}",
|
||||
"banner_no_expiry_detail": "This product has no expiry date. Would you like to add one, or confirm it doesn't expire?",
|
||||
"banner_no_expiry_action_set": "Set expiry date",
|
||||
"banner_no_expiry_action_dismiss": "Doesn't expire ✓",
|
||||
"banner_no_expiry_toast_dismissed": "Marked as 'no expiry'",
|
||||
"banner_expiring_title": "Expiring soon",
|
||||
"banner_expiring_today": "Expires today!",
|
||||
"banner_expiring_tomorrow": "Expires tomorrow",
|
||||
@@ -157,7 +162,8 @@
|
||||
"label_quantity": "📦 Quantity",
|
||||
"label_added": "📅 Added",
|
||||
"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": {
|
||||
"title": "Scan Product",
|
||||
@@ -340,7 +346,7 @@
|
||||
"suggestions_title": "💡 AI Suggestions",
|
||||
"suggestions_add": "✅ Add selected to Bring!",
|
||||
"search_prices": "🔍 Search all prices",
|
||||
"suggest_btn": "🤖 Suggest what to buy",
|
||||
"suggest_btn": "Suggest what to buy",
|
||||
"smart_title": "🧠 Smart Predictions",
|
||||
"smart_empty": "No predictions available.<br>Add products to your pantry to receive smart predictions.",
|
||||
"smart_filter_all": "All",
|
||||
@@ -357,6 +363,10 @@
|
||||
"all_searched": "All products have already been searched. Use 🔄 to search individual ones.",
|
||||
"search_complete": "Search complete: {count} products",
|
||||
"removed_sufficient": "🧹 {removed} product(s) with sufficient stock removed from the list",
|
||||
"suggest_buy": "🛒 Buy: {qty} {unit}",
|
||||
"suggest_buy_approx": "🛒 At least: {qty} {unit}",
|
||||
"suggest_buy_tip": "Suggested quantity based on your last 14 days of consumption",
|
||||
"suggest_buy_approx_tip": "Minimum estimate based on consumption (buy the nearest package size)",
|
||||
"bring_badge": "🛒 Already on Bring!",
|
||||
"add_urgent_toast": "🔴 {n} urgent product(s) automatically added to Bring!",
|
||||
"migration_done": "✅ {migrated} updated, {skipped} already ok",
|
||||
@@ -408,6 +418,10 @@
|
||||
"savings_offers": "· 🏷️ You save €{amount} with offers",
|
||||
"searching_progress": "Searching {current}/{total}...",
|
||||
"remove_error": "Removal error",
|
||||
"btn_fetch_prices": "Find prices",
|
||||
"price_total_label": "💰 Estimated total:",
|
||||
"price_loading": "Looking up prices…",
|
||||
"price_not_found": "price n/a",
|
||||
"suggest_loading": "Analyzing...",
|
||||
"suggest_error": "Suggestion generation error",
|
||||
"priority_high": "High",
|
||||
@@ -500,6 +514,15 @@
|
||||
"email_label": "📧 Bring! Email",
|
||||
"password_label": "🔒 Bring! Password"
|
||||
},
|
||||
"price": {
|
||||
"title": "💰 Price Estimation (AI)",
|
||||
"hint": "Show estimated cost per product in the shopping list using AI.",
|
||||
"enabled_label": "Enable price estimation",
|
||||
"country_label": "🌍 Reference country",
|
||||
"currency_label": "💱 Currency",
|
||||
"update_label": "🔄 Refresh prices every",
|
||||
"update_suffix": "months"
|
||||
},
|
||||
"recipe": {
|
||||
"title": "🍳 Recipe Preferences",
|
||||
"hint": "Configure the default options for recipe generation.",
|
||||
@@ -898,6 +921,7 @@
|
||||
},
|
||||
"meal_plan": {
|
||||
"reset_success": "Weekly plan reset",
|
||||
"not_available": "not available in pantry",
|
||||
"suggested_by": "suggested by weekly plan"
|
||||
},
|
||||
"kiosk_session": {
|
||||
|
||||
+29
-5
@@ -84,7 +84,7 @@
|
||||
"opened_title": "📦 Prodotti Aperti",
|
||||
"review_title": "🔍 Da revisionare",
|
||||
"review_hint": "Quantità che sembrano anomale. Conferma se corrette o modifica.",
|
||||
"quick_recipe": "🍳 Ricetta veloce con prodotti in scadenza",
|
||||
"quick_recipe": "Ricetta veloce con prodotti in scadenza",
|
||||
"banner_review_title": "Quantità anomala",
|
||||
"banner_review_action_ok": "È corretto",
|
||||
"banner_review_action_finish": "🗑️ È finito tutto",
|
||||
@@ -104,6 +104,11 @@
|
||||
"banner_expired_action_edit": "Correggi data",
|
||||
"banner_anomaly_action_edit": "Correggi inventario",
|
||||
"banner_anomaly_action_dismiss": "La quantità è giusta",
|
||||
"banner_no_expiry_title": "Scadenza mancante: {name}",
|
||||
"banner_no_expiry_detail": "Questo prodotto non ha una data di scadenza. Vuoi aggiungerla o confermare che non scade?",
|
||||
"banner_no_expiry_action_set": "Imposta scadenza",
|
||||
"banner_no_expiry_action_dismiss": "Non scade ✓",
|
||||
"banner_no_expiry_toast_dismissed": "Segnato come 'non scade'",
|
||||
"banner_expiring_title": "In scadenza",
|
||||
"banner_expiring_today": "Scade oggi!",
|
||||
"banner_expiring_tomorrow": "Scade domani",
|
||||
@@ -157,7 +162,8 @@
|
||||
"label_quantity": "📦 Quantità",
|
||||
"label_added": "📅 Aggiunto",
|
||||
"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": {
|
||||
"title": "Scansiona Prodotto",
|
||||
@@ -340,7 +346,7 @@
|
||||
"suggestions_title": "💡 Suggerimenti AI",
|
||||
"suggestions_add": "✅ Aggiungi selezionati a Bring!",
|
||||
"search_prices": "🔍 Cerca tutti i prezzi",
|
||||
"suggest_btn": "🤖 Suggerisci cosa comprare",
|
||||
"suggest_btn": "Suggerisci cosa comprare",
|
||||
"smart_title": "🧠 Previsioni intelligenti",
|
||||
"smart_empty": "Nessuna previsione disponibile.<br>Aggiungi prodotti alla dispensa per ricevere previsioni intelligenti.",
|
||||
"smart_filter_all": "Tutti",
|
||||
@@ -357,6 +363,10 @@
|
||||
"all_searched": "Tutti i prodotti sono già stati cercati. Usa 🔄 per ricercare singoli.",
|
||||
"search_complete": "Ricerca completata: {count} prodotti",
|
||||
"removed_sufficient": "🧹 {removed} prodotto/i con scorte sufficienti rimosso/i dalla lista",
|
||||
"suggest_buy": "🛒 Compra: {qty} {unit}",
|
||||
"suggest_buy_approx": "🛒 Almeno: {qty} {unit}",
|
||||
"suggest_buy_tip": "Quantità suggerita in base al consumo degli ultimi 14 giorni",
|
||||
"suggest_buy_approx_tip": "Stima minima basata sul consumo (compra la confezione più vicina)",
|
||||
"bring_badge": "🛒 Già su Bring!",
|
||||
"add_urgent_toast": "🔴 {n} prodotto/i urgente/i aggiunto/i automaticamente a Bring!",
|
||||
"migration_done": "✅ {migrated} aggiornati, {skipped} già ok",
|
||||
@@ -408,6 +418,10 @@
|
||||
"savings_offers": "· 🏷️ Risparmi €{amount} con le offerte",
|
||||
"searching_progress": "Cerco {current}/{total}...",
|
||||
"remove_error": "Errore nella rimozione",
|
||||
"btn_fetch_prices": "Cerca i prezzi",
|
||||
"price_total_label": "💰 Spesa stimata:",
|
||||
"price_loading": "Ricerca prezzi…",
|
||||
"price_not_found": "prezzo n/d",
|
||||
"suggest_loading": "Analisi in corso...",
|
||||
"suggest_error": "Errore nella generazione",
|
||||
"priority_high": "Alta",
|
||||
@@ -426,7 +440,7 @@
|
||||
"use_data_no_barcode": "✅ Usa dati AI (senza barcode)"
|
||||
},
|
||||
"log": {
|
||||
"title": "� Storico",
|
||||
"title": "📒 Storico",
|
||||
"type_added": "Aggiunto",
|
||||
"type_waste": "Buttato",
|
||||
"type_used": "Usato",
|
||||
@@ -500,6 +514,15 @@
|
||||
"email_label": "📧 Email Bring!",
|
||||
"password_label": "🔒 Password Bring!"
|
||||
},
|
||||
"price": {
|
||||
"title": "💰 Stima Prezzi (AI)",
|
||||
"hint": "Mostra il costo stimato di ogni prodotto nella lista della spesa usando l'AI.",
|
||||
"enabled_label": "Attiva stima prezzi",
|
||||
"country_label": "🌍 Paese di riferimento",
|
||||
"currency_label": "💱 Valuta",
|
||||
"update_label": "🔄 Aggiorna prezzi ogni",
|
||||
"update_suffix": "mesi"
|
||||
},
|
||||
"recipe": {
|
||||
"title": "🍳 Preferenze Ricette",
|
||||
"hint": "Configura le opzioni predefinite per la generazione delle ricette.",
|
||||
@@ -898,7 +921,8 @@
|
||||
},
|
||||
"meal_plan": {
|
||||
"reset_success": "Piano settimanale ripristinato",
|
||||
"suggested_by": "suggerito dal piano settimanale"
|
||||
"suggested_by": "suggerito dal piano settimanale",
|
||||
"not_available": "non disponibile in dispensa"
|
||||
},
|
||||
"kiosk_session": {
|
||||
"first_item": "Primo prodotto: {name}!",
|
||||
|
||||
Reference in New Issue
Block a user