Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51f55071fa | |||
| 3a4e843334 | |||
| 7104483dac | |||
| 94e98bc79f | |||
| fd039d743e | |||
| b1bcf9e714 | |||
| 98c38f017e | |||
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 |
@@ -206,7 +206,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: steps.tag_check.outputs.exists == 'false'
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.version.outputs.version }}
|
||||
name: "EverShelf ${{ steps.version.outputs.version }}"
|
||||
|
||||
@@ -11,6 +11,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [1.7.35] - 2026-06-02
|
||||
|
||||
### Fixed
|
||||
- **Barcode scanner accepts invalid codes** — Manual barcode input with an incorrect EAN checksum now blocks the lookup and shows an error (previously showed a warning but proceeded anyway). The native `BarcodeDetector` path now also validates EAN-8/EAN-13/UPC checksum before confirming a scan, consistent with the Quagga fallback which already did this check.
|
||||
- **Recipe persons +/− buttons stopped working in the generation dialog** — A duplicate `adjustRecipePersons` function added for the post-generation rescaler was overriding the one that updated the persons input in the recipe setup dialog. The rescaler is now named `scaleRecipePersons` to avoid the conflict.
|
||||
|
||||
## [1.7.34] - 2026-05-30
|
||||
|
||||
### Added
|
||||
- **AI visual barcode fallback** — When the barcode scanner fails to read a barcode within 5 seconds, EverShelf can now automatically capture a camera frame and send it to Gemini Vision to visually identify the product (name, brand, category). On success the product is saved and the inventory form opens just as if a barcode had been scanned. A new toggle in **Settings → Camera** (`AI visual identification (5s fallback)`) lets users enable or disable this feature at any time. Requires Gemini API key configured. Disabled by default.
|
||||
|
||||
## [1.7.33] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||
|
||||
|
||||
## [1.7.32] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||
|
||||
|
||||
## [1.7.31] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||
|
||||
|
||||
## [1.7.30] - 2026-05-29
|
||||
|
||||
### Fixed
|
||||
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||
|
||||
|
||||
## [1.7.29] - 2026-05-29
|
||||
|
||||
### Added
|
||||
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||
|
||||
|
||||
## [1.7.28] - 2026-05-30
|
||||
|
||||
### Fixed
|
||||
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135–#183).
|
||||
|
||||
## [1.7.27] - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
|
||||
+327
-39
@@ -604,7 +604,7 @@ function checkRateLimit(string $action): void {
|
||||
}
|
||||
|
||||
// Determine limit based on action
|
||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr'];
|
||||
$aiActions = ['gemini_readExpiry', 'gemini_chat', 'gemini_identify', 'gemini_suggest_shopping', 'chat_to_recipe', 'recipe_from_ingredient', 'gemini_number_ocr', 'gemini_barcode_visual'];
|
||||
$loginActions = [];
|
||||
$recipeActions = ['generate_recipe', 'generate_recipe_stream'];
|
||||
$errorActions = ['report_error', 'check_update'];
|
||||
@@ -1109,6 +1109,10 @@ try {
|
||||
geminiNumberOCR();
|
||||
break;
|
||||
|
||||
case 'gemini_barcode_visual':
|
||||
geminiBarcodeVisual();
|
||||
break;
|
||||
|
||||
case 'get_shopping_price':
|
||||
getShoppingPrice($db);
|
||||
break;
|
||||
@@ -1570,7 +1574,9 @@ function haInventorySensor(PDO $db): void {
|
||||
$daysToNextExpiry = (int)$diff->format('%r%a');
|
||||
}
|
||||
|
||||
// Shopping total from server-side total cache (max 1 hour old)
|
||||
// Shopping total from server-side total cache (max 24 hours old).
|
||||
// The cache is populated by the JS frontend or by the ha_refresh_prices action.
|
||||
// 24h TTL is sufficient: the total changes slowly and HA polls frequently.
|
||||
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
|
||||
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
||||
$shoppingTotal = null;
|
||||
@@ -1578,19 +1584,58 @@ function haInventorySensor(PDO $db): void {
|
||||
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
||||
if (file_exists($totalCachePath)) {
|
||||
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
|
||||
// Find the most recent entry not older than 1 hour
|
||||
$best = null;
|
||||
$bestTs = 0;
|
||||
$best = null; $bestTs = 0;
|
||||
foreach ($tc as $entry) {
|
||||
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
||||
$bestTs = $entry['ts'];
|
||||
$best = $entry;
|
||||
}
|
||||
}
|
||||
if ($best && (time() - $bestTs) < 3600) {
|
||||
if ($best && (time() - $bestTs) < 86400) {
|
||||
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
|
||||
}
|
||||
}
|
||||
// If cache is absent or older than 24h, compute inline from the existing
|
||||
// per-item price cache (no AI calls — fast, uses already-stored prices).
|
||||
if ($shoppingTotal === null) {
|
||||
$country = env('PRICE_COUNTRY', 'Italia');
|
||||
$priceCache = _loadPriceCache();
|
||||
if (!empty($priceCache)) {
|
||||
$shopRows = $db->query("
|
||||
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||
FROM shopping_list sl
|
||||
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$inlineTotal = 0.0;
|
||||
$inlinePriced = 0;
|
||||
$seenNames = [];
|
||||
foreach ($shopRows as $r) {
|
||||
$sname = $r['sname'] ?? $r['name'];
|
||||
if (isset($seenNames[$sname])) continue; // deduplicate
|
||||
$seenNames[$sname] = true;
|
||||
$pk = _priceKey($sname, $country);
|
||||
$pk0 = md5(mb_strtolower(trim($sname)) . '|' . mb_strtolower(trim($country)));
|
||||
$e = $priceCache[$pk] ?? $priceCache[$pk0] ?? null;
|
||||
if ($e !== null) {
|
||||
$est = _calcEstimatedTotal(
|
||||
$e['price_per_unit'], $e['unit_label'] ?? '',
|
||||
1, 'pz', 0, ''
|
||||
);
|
||||
$inlineTotal += $est ?? 0;
|
||||
$inlinePriced++;
|
||||
}
|
||||
}
|
||||
if ($inlinePriced > 0) {
|
||||
$shoppingTotal = round($inlineTotal, 2);
|
||||
// Persist so next call is instant
|
||||
$tc2 = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
|
||||
$ckey2 = 'ha_inline_' . date('Ymd_His');
|
||||
$tc2[$ckey2] = ['ts' => time(), 'result' => ['total' => $shoppingTotal, 'priced_items' => $inlinePriced]];
|
||||
if (count($tc2) >= 10) $tc2 = array_slice($tc2, -9, null, true);
|
||||
file_put_contents($totalCachePath, json_encode($tc2, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stateValue = match($sensor) {
|
||||
@@ -1786,9 +1831,17 @@ function haRefreshPrices(PDO $db): void {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$rows = $db->query("SELECT name, quantity, unit FROM shopping_list WHERE checked = 0")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = $db->query("
|
||||
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||
FROM shopping_list sl
|
||||
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
$seenSnamesHa = [];
|
||||
foreach ($rows as $r) {
|
||||
$shoppingItems[] = ['name' => $r['name'], 'quantity' => (float)($r['quantity'] ?? 1), 'unit' => $r['unit'] ?? 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||
$sname = $r['sname'] ?? $r['name'];
|
||||
if (isset($seenSnamesHa[$sname])) continue;
|
||||
$seenSnamesHa[$sname] = true;
|
||||
$shoppingItems[] = ['name' => $sname, 'quantity' => 1, 'unit' => 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1798,9 +1851,10 @@ function haRefreshPrices(PDO $db): void {
|
||||
$missing = [];
|
||||
|
||||
foreach ($shoppingItems as $item) {
|
||||
$key = _priceKey($item['name'], $country);
|
||||
if (isset($priceCache[$key])) {
|
||||
$entry = $priceCache[$key];
|
||||
$key = _priceKey($item['name'], $country);
|
||||
$key0 = md5(mb_strtolower(trim($item['name'])) . '|' . mb_strtolower(trim($country)));
|
||||
$entry = $priceCache[$key] ?? $priceCache[$key0] ?? null;
|
||||
if ($entry !== null) {
|
||||
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
|
||||
$total += $est ?? 0;
|
||||
$priced++;
|
||||
@@ -2634,19 +2688,26 @@ function addToInventory(PDO $db): void {
|
||||
|
||||
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
||||
|
||||
// Check if product already exists in this location
|
||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?");
|
||||
// Check if a SEALED (not yet opened) row exists for this product+location.
|
||||
// We merge new stock into a sealed row only — never into an already-opened
|
||||
// pack, because that would conflate two physically distinct containers and
|
||||
// corrupt the opened_at timestamp tracking.
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, quantity FROM inventory
|
||||
WHERE product_id = ? AND location = ? AND opened_at IS NULL
|
||||
ORDER BY added_at ASC LIMIT 1
|
||||
");
|
||||
$stmt->execute([$productId, $location]);
|
||||
$existing = $stmt->fetch();
|
||||
|
||||
|
||||
if ($existing) {
|
||||
// Update quantity
|
||||
// Merge into the existing sealed row
|
||||
$newQty = $existing['quantity'] + $quantity;
|
||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]);
|
||||
} else {
|
||||
$newQty = $quantity;
|
||||
// Insert new inventory entry
|
||||
// All existing rows (if any) are opened packs — insert a new sealed row
|
||||
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
|
||||
}
|
||||
@@ -4155,6 +4216,24 @@ function getConsumptionPredictions(PDO $db): void {
|
||||
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
|
||||
$actualQty = floatval($item['quantity']);
|
||||
|
||||
// Aggregate total stock for this product across ALL inventory rows.
|
||||
// A product may be split into multiple rows (e.g. one opened pack + one
|
||||
// sealed pack at a different location). The opened row alone may look
|
||||
// depleted while the total is healthy — do not flag in that case.
|
||||
$totalQtyStmt = $db->prepare("
|
||||
SELECT COALESCE(SUM(quantity), 0)
|
||||
FROM inventory
|
||||
WHERE product_id = ? AND quantity > 0
|
||||
");
|
||||
$totalQtyStmt->execute([$pid]);
|
||||
$totalQtyAllRows = floatval($totalQtyStmt->fetchColumn() ?: 0);
|
||||
// If the aggregate total is above the expected remaining, the "depletion"
|
||||
// is just stock spread across rows — suppress the anomaly.
|
||||
if ($totalQtyAllRows >= $expectedQty) continue;
|
||||
// Use the aggregate total as the visible actual qty so the banner shows
|
||||
// the real combined stock, not just the single opened row.
|
||||
$actualQty = $totalQtyAllRows;
|
||||
|
||||
// Need at least some post-restock usage observations before warning.
|
||||
if ($txSinceRestock < 2) continue;
|
||||
|
||||
@@ -4278,6 +4357,7 @@ function getServerSettings(): void {
|
||||
'shopping_forecast' => env('SHOPPING_FORECAST', 'true') === 'true',
|
||||
'shopping_auto_add_threshold' => (int)env('SHOPPING_AUTO_ADD_THRESHOLD', '0'),
|
||||
'dark_mode' => env('DARK_MODE', 'auto'),
|
||||
'barcode_ai_fallback' => env('BARCODE_AI_FALLBACK', 'false') === 'true',
|
||||
// Home Assistant Integration
|
||||
'ha_enabled' => env('HA_ENABLED', 'false') === 'true',
|
||||
'ha_url' => env('HA_URL', ''),
|
||||
@@ -4380,6 +4460,7 @@ function saveSettings(): void {
|
||||
'shopping_enabled' => 'SHOPPING_ENABLED',
|
||||
'shopping_smart_suggestions' => 'SHOPPING_SMART_SUGGESTIONS',
|
||||
'shopping_forecast' => 'SHOPPING_FORECAST',
|
||||
'barcode_ai_fallback' => 'BARCODE_AI_FALLBACK',
|
||||
// Home Assistant
|
||||
'ha_enabled' => 'HA_ENABLED',
|
||||
];
|
||||
@@ -8813,6 +8894,29 @@ function smartShopping(PDO $db): void {
|
||||
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
|
||||
}
|
||||
|
||||
// --- Buy-cycle proxy (for products tracked without individual 'out' events) ---
|
||||
// Products like salt, spices, cleaning products are never logged per-use.
|
||||
// When the user buys them again it implicitly means the previous pack ran out.
|
||||
// If we have ≥ 3 buy events and no (or very few) out events, we estimate
|
||||
// the average cycle duration = (lastIn - firstIn) / (buyCount - 1) and
|
||||
// project how many days of stock are likely left in the current cycle.
|
||||
// estimatedDaysLeft = avgCycleDays − daysSinceLastBuy
|
||||
// This dailyRate proxy is ONLY used when the regular out-based rate is 0.
|
||||
$buyCycleDays = null; // avg days per buy cycle
|
||||
$buyCycleDaysLeft = null; // estimated days remaining in current cycle
|
||||
if ($dailyRate == 0 && $buyCount >= 3 && $firstIn && $lastIn && $lastIn > $firstIn) {
|
||||
$buyCycleDays = ($lastIn - $firstIn) / 86400 / ($buyCount - 1);
|
||||
if ($buyCycleDays >= 7) { // ignore implausible < 1-week cycles
|
||||
$daysSinceLastBuyFloat = ($now - $lastIn) / 86400;
|
||||
$buyCycleDaysLeft = max(0, $buyCycleDays - $daysSinceLastBuyFloat);
|
||||
// Derive a synthetic dailyRate so existing daysLeft / pctLeft logic works naturally
|
||||
// 1 restock event ≈ consuming 1 "average package" over avgCycleDays
|
||||
if ($qty > 0 && $buyCycleDays > 0) {
|
||||
$dailyRate = $qty / max(1, $buyCycleDaysLeft > 0 ? $buyCycleDaysLeft : $buyCycleDays);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Days of stock remaining
|
||||
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
|
||||
|
||||
@@ -8820,7 +8924,9 @@ function smartShopping(PDO $db): void {
|
||||
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
||||
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
||||
$isExpired = $daysToExpiry < 0;
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
|
||||
// 7-day warning window: enough to plan the next shopping trip.
|
||||
// The tighter 3-day threshold was often too late for staple products.
|
||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 7;
|
||||
|
||||
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
||||
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
||||
@@ -8853,7 +8959,9 @@ function smartShopping(PDO $db): void {
|
||||
// Is this a frequently used product? (≥ 1.5 uses/month)
|
||||
$isFrequent = $usesPerMonth >= 1.5;
|
||||
// Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months)
|
||||
$isRegular = $usesPerMonth >= 0.5;
|
||||
// Also treat buy-cycle products (≥3 buys, no out events) as regular — they are
|
||||
// by definition products the user buys periodically.
|
||||
$isRegular = $usesPerMonth >= 0.5 || ($buyCycleDays !== null && $buyCount >= 3);
|
||||
// Is it recently relevant? (used/bought in last 60 days)
|
||||
$isRecent = $daysSinceLastUse <= 60;
|
||||
|
||||
@@ -8951,31 +9059,48 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Expiring soon or expired (needs replacement) — valid regardless of frequency
|
||||
// Expiring soon or expired (needs replacement)
|
||||
if ($isExpired && $qty > 0) {
|
||||
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
||||
// from other (non-expired) products. If so, no need to buy more.
|
||||
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
||||
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
||||
// Subtract this product's own qty (it is expired, so fresh_qty=0 for it anyway)
|
||||
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
||||
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
||||
|
||||
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
||||
// Fresh stock from this product or same-family products is adequate.
|
||||
// The expired batch will show in the dashboard expiry banner — don't add to shopping list.
|
||||
} else {
|
||||
} elseif ($isRegular || $buyCount >= 2) {
|
||||
// Only suggest restocking if this is a product the user buys regularly.
|
||||
// If it expired without ever being a staple, the expiry banner is enough.
|
||||
$urgency = 'critical';
|
||||
$reasons[] = 'Scaduto!';
|
||||
$score += 90;
|
||||
}
|
||||
} elseif ($isExpiringSoon && $qty > 0 && $pctLeft < 50) {
|
||||
// Only flag "expiring soon" if stock is also low (<50%). If you have plenty of
|
||||
// stock (e.g. just bought fresh produce that naturally expires in 3 days), the
|
||||
// shopping list is not the right place — the expiry banner handles it.
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
||||
$score += 40;
|
||||
// else: one-off product expired unused → expiry banner handles it, no shopping noise
|
||||
} elseif ($isExpiringSoon && $qty > 0) {
|
||||
// Flag if:
|
||||
// (a) regular consumer + stock low (<50%) → needs restock soon
|
||||
// (b) regular consumer + will expire before finishing it
|
||||
// (daysLeft based on consumption rate > days to expiry)
|
||||
// (c) non-regular + within 3 days + low stock → minimal safety net
|
||||
$willExpireBeforeUsed = $dailyRate > 0 && $daysToExpiry < $daysLeft;
|
||||
if ($isRegular && ($pctLeft < 50 || $willExpireBeforeUsed)) {
|
||||
if ($urgency === 'none') $urgency = 'medium';
|
||||
if ($willExpireBeforeUsed && $pctLeft >= 50) {
|
||||
// Has stock but won't finish it in time → buy fresh and use this one now
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg — ricompra';
|
||||
} else {
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||
}
|
||||
$score += 40;
|
||||
} elseif (!$isRegular && $daysToExpiry <= 3 && $pctLeft < 50) {
|
||||
// Non-regular product: only flag when very close and running low
|
||||
if ($urgency === 'none') $urgency = 'low';
|
||||
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||
$score += 20;
|
||||
}
|
||||
}
|
||||
|
||||
// Frequently used but stock getting low (predictive) — scale urgency by imminence
|
||||
@@ -8983,11 +9108,24 @@ function smartShopping(PDO $db): void {
|
||||
$daysLeftDisplay = (int)round($daysLeft);
|
||||
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
|
||||
if ($daysLeftDisplay <= 3) {
|
||||
// Running out within 3 days for a frequent product → high urgency
|
||||
$urgency = 'high';
|
||||
$score += 70;
|
||||
} elseif ($daysLeftDisplay <= 7) {
|
||||
// Running out within a week → medium
|
||||
$urgency = 'medium';
|
||||
$score += 45;
|
||||
} else {
|
||||
$urgency = 'low';
|
||||
$score += 25;
|
||||
}
|
||||
}
|
||||
// Buy-cycle prediction for products not tracked per-use (e.g. salt, spices):
|
||||
// if daily rate was derived from buy cycles and we have < 21 days left → flag.
|
||||
if ($urgency === 'none' && $buyCycleDays !== null && $dailyRate > 0
|
||||
&& $daysLeft <= 21 && $isRegular && !$justRestocked) {
|
||||
$daysLeftDisplay = (int)round($daysLeft);
|
||||
$cycleDisplay = (int)round($buyCycleDays);
|
||||
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg (ciclo medio ' . $cycleDisplay . 'gg)';
|
||||
if ($daysLeftDisplay <= 7) {
|
||||
$urgency = 'medium';
|
||||
$score += 45;
|
||||
} else {
|
||||
@@ -9931,9 +10069,41 @@ function checkUpdate(): void {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return path to the local fingerprint deduplication cache.
|
||||
* Falls back to /tmp when data/ is not writable (e.g. fresh install with wrong perms).
|
||||
*/
|
||||
function _getFpCachePath(): string {
|
||||
$primary = __DIR__ . '/../data/reported_issue_fps.json';
|
||||
return is_writable(dirname($primary)) ? $primary : (sys_get_temp_dir() . '/evershelf_fps.json');
|
||||
}
|
||||
|
||||
/** Load & prune (> 30 days) the local FP cache. */
|
||||
function _loadFpCache(): array {
|
||||
$path = _getFpCachePath();
|
||||
if (!file_exists($path)) return [];
|
||||
$data = @json_decode(@file_get_contents($path), true) ?: [];
|
||||
$cutoff = time() - 30 * 86400;
|
||||
return array_filter($data, fn($v) => ($v['ts'] ?? 0) > $cutoff);
|
||||
}
|
||||
|
||||
/** Persist the local FP cache. */
|
||||
function _saveFpCache(array $cache): void {
|
||||
@file_put_contents(_getFpCachePath(), json_encode($cache), LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub issue, or add a comment to an existing open issue with the
|
||||
* same fingerprint. Uses the REST API v3 directly (no library needed).
|
||||
*
|
||||
* Deduplication strategy (two-layer):
|
||||
* 1. Local file cache (data/reported_issue_fps.json or /tmp fallback) — checked
|
||||
* first to avoid the GitHub Search API indexing delay that caused duplicate
|
||||
* issues to be created in rapid succession.
|
||||
* 2. GitHub Search API — used only on first occurrence (cache miss) as backup.
|
||||
*
|
||||
* Comment throttle: at most one recurrence comment per 30 minutes per fingerprint,
|
||||
* to avoid flooding an issue when an error fires on every request.
|
||||
*/
|
||||
function _createOrCommentGithubIssue(
|
||||
string $token, string $repo,
|
||||
@@ -9944,13 +10114,27 @@ function _createOrCommentGithubIssue(
|
||||
$fp = _errorFingerprint($source, $type, $message);
|
||||
EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]);
|
||||
|
||||
// ── 1. Search for an existing open issue with this fingerprint ─────────
|
||||
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
||||
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
||||
|
||||
// ── 1. Check local cache (fast, avoids Search API indexing lag) ────────
|
||||
$fpCache = _loadFpCache();
|
||||
$existingIssueNumber = null;
|
||||
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) {
|
||||
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null;
|
||||
if (isset($fpCache[$fp])) {
|
||||
$existingIssueNumber = $fpCache[$fp]['issue'];
|
||||
// Comment throttle: skip if we already commented within the last 30 min
|
||||
$lastComment = $fpCache[$fp]['last_comment'] ?? 0;
|
||||
if (time() - $lastComment < 1800) {
|
||||
EverLog::debug('_createOrCommentGithubIssue: throttled', ['fp' => $fp]);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// ── 2. Fall back to GitHub Search (handles first run / cache cleared) ─
|
||||
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
||||
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
||||
if (!empty($searchResult['body']['items'][0]['number'])) {
|
||||
$existingIssueNumber = (int)$searchResult['body']['items'][0]['number'];
|
||||
// Populate local cache with what we found
|
||||
$fpCache[$fp] = ['issue' => $existingIssueNumber, 'ts' => time(), 'last_comment' => 0];
|
||||
_saveFpCache($fpCache);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build the common details block ─────────────────────────────────────
|
||||
@@ -9965,7 +10149,7 @@ function _createOrCommentGithubIssue(
|
||||
$verMd = $version ? "\n**Version:** `$version`" : '';
|
||||
|
||||
if ($existingIssueNumber) {
|
||||
// ── 2a. Post a comment to the existing issue ──────────────────────
|
||||
// ── 3a. Post a comment to the existing issue ──────────────────────
|
||||
$body = "### 🔁 Recurrence — $ts\n"
|
||||
. "**Source:** `$source` | **Type:** `$type`\n"
|
||||
. $urlMd . $uaMd . $verMd . "\n"
|
||||
@@ -9975,8 +10159,11 @@ function _createOrCommentGithubIssue(
|
||||
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
|
||||
['body' => $body]
|
||||
);
|
||||
// Update throttle timestamp
|
||||
$fpCache[$fp]['last_comment'] = time();
|
||||
_saveFpCache($fpCache);
|
||||
} else {
|
||||
// ── 2b. Create a new issue ────────────────────────────────────────
|
||||
// ── 3b. Create a new issue ────────────────────────────────────────
|
||||
// Determine labels from source
|
||||
$labelMap = [
|
||||
'pwa' => 'js-error',
|
||||
@@ -10004,7 +10191,7 @@ function _createOrCommentGithubIssue(
|
||||
. "<!-- auto-report fp:$fp -->\n"
|
||||
. "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
|
||||
|
||||
_githubRequest($token, 'POST',
|
||||
$newIssueRes = _githubRequest($token, 'POST',
|
||||
"https://api.github.com/repos/$repo/issues",
|
||||
[
|
||||
'title' => $title,
|
||||
@@ -10012,6 +10199,12 @@ function _createOrCommentGithubIssue(
|
||||
'labels' => ['auto-report', $typeLabel],
|
||||
]
|
||||
);
|
||||
// Save to local cache immediately to prevent duplicates on rapid recurrences
|
||||
$newNum = $newIssueRes['body']['number'] ?? null;
|
||||
if ($newNum) {
|
||||
$fpCache[$fp] = ['issue' => (int)$newNum, 'ts' => time(), 'last_comment' => time()];
|
||||
_saveFpCache($fpCache);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10307,6 +10500,101 @@ function geminiNumberOCR(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ===== GEMINI AI: BARCODE VISUAL FALLBACK ====================================
|
||||
// =============================================================================
|
||||
/**
|
||||
* POST /api/?action=gemini_barcode_visual
|
||||
* Body: { image: base64-jpeg, lang: 'it'|'en'|'de'|... }
|
||||
* Returns: { found, source, product } or { found: false, error }
|
||||
* Uses Gemini vision to visually identify a product from a camera frame
|
||||
* when the barcode scanner fails to read the barcode after 5 seconds.
|
||||
*/
|
||||
function geminiBarcodeVisual(): void {
|
||||
EverLog::info('geminiBarcodeVisual');
|
||||
$apiKey = env('GEMINI_API_KEY');
|
||||
if (empty($apiKey)) {
|
||||
echo json_encode(['found' => false, 'error' => 'no_api_key']);
|
||||
return;
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$imageBase64 = $input['image'] ?? '';
|
||||
$lang = $input['lang'] ?? 'it';
|
||||
if (empty($imageBase64)) {
|
||||
echo json_encode(['found' => false, 'error' => 'no_image']);
|
||||
return;
|
||||
}
|
||||
|
||||
$langNote = match($lang) {
|
||||
'de' => 'Use the German product name if known.',
|
||||
'fr' => 'Use the French product name if known.',
|
||||
'es' => 'Use the Spanish product name if known.',
|
||||
default => 'Use the Italian product name if known.',
|
||||
};
|
||||
|
||||
$payload = [
|
||||
'contents' => [[
|
||||
'parts' => [
|
||||
['text' => "Identify the product shown in this image. {$langNote}\n" .
|
||||
"Respond with ONLY valid JSON (no markdown, no backticks):\n" .
|
||||
"{\"name\":\"...\",\"brand\":\"...\",\"category\":\"...\"}\n" .
|
||||
"- name: the product name (as specific as possible, not just the brand)\n" .
|
||||
"- brand: the brand/manufacturer, or empty string if not visible\n" .
|
||||
"- category: one of: latticini, pasta, bevande, snack, carne, pesce, " .
|
||||
"frutta, verdura, surgelati, condimenti, conserve, cereali, pane, " .
|
||||
"igiene, pulizia, altro\n" .
|
||||
"If you cannot identify the product at all, respond with: {\"unknown\":true}"],
|
||||
['inline_data' => ['mime_type' => 'image/jpeg', 'data' => $imageBase64]],
|
||||
],
|
||||
]],
|
||||
'generationConfig' => [
|
||||
'temperature' => 0,
|
||||
'maxOutputTokens' => 200,
|
||||
'responseMimeType' => 'application/json',
|
||||
'thinkingConfig' => ['thinkingBudget' => 0],
|
||||
],
|
||||
];
|
||||
|
||||
$result = callGeminiWithFallback($apiKey, $payload, 15, 'barcode_visual');
|
||||
if ($result['http_code'] !== 200) {
|
||||
echo json_encode(['found' => false, 'error' => 'gemini_error_' . $result['http_code']]);
|
||||
return;
|
||||
}
|
||||
|
||||
$text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
|
||||
// Strip accidental markdown fences
|
||||
$text = preg_replace('/^```json\s*/i', '', $text);
|
||||
$text = preg_replace('/\s*```$/i', '', trim($text));
|
||||
|
||||
$data = json_decode($text, true);
|
||||
if (!$data || !empty($data['unknown']) || empty($data['name'])) {
|
||||
echo json_encode(['found' => false]);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'found' => true,
|
||||
'source' => 'gemini_visual',
|
||||
'product' => [
|
||||
'name' => $data['name'] ?? '',
|
||||
'brand' => $data['brand'] ?? '',
|
||||
'category' => $data['category'] ?? '',
|
||||
'image_url' => '',
|
||||
'quantity_info' => '',
|
||||
'nutriscore' => '',
|
||||
'ingredients' => '',
|
||||
'allergens' => '',
|
||||
'conservation' => '',
|
||||
'origin' => '',
|
||||
'nova_group' => '',
|
||||
'ecoscore' => '',
|
||||
'labels' => '',
|
||||
'stores' => '',
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ===== GEMINI AI: ANOMALY EXPLANATION =======================================
|
||||
// =============================================================================
|
||||
|
||||
+372
-18
@@ -1943,6 +1943,8 @@ let quaggaRunning = false;
|
||||
let aiStream = null;
|
||||
let _scanZoomLevel = 2; // always 2x
|
||||
let _torchActive = false;
|
||||
let _aiFallbackTimer = null;
|
||||
let _aiFallbackExhausted = false; // true after one failed AI visual attempt — reset when scanner is closed
|
||||
|
||||
// Apply fixed 2x zoom (hardware if available, CSS fallback)
|
||||
async function _applyFixedZoom() {
|
||||
@@ -2083,13 +2085,15 @@ function _showScanConfirm(name) {
|
||||
|
||||
// ===== AI NUMBER OCR (Gemini reads printed barcode digits) =====
|
||||
let _numOcrRunning = false;
|
||||
async function _tryGeminiNumberOCR() {
|
||||
async function _tryGeminiNumberOCR(options = {}) {
|
||||
const { chainToVisual = false } = options;
|
||||
if (_numOcrRunning || !_requireGemini()) return;
|
||||
const video = document.getElementById('scanner-video');
|
||||
if (!video || !video.videoWidth) { showToast(t('error.camera'), 'error'); return; }
|
||||
_numOcrRunning = true;
|
||||
const btn = document.getElementById('scan-num-ocr-btn');
|
||||
if (btn) { btn.disabled = true; btn.textContent = t('scan.num_ocr_searching'); }
|
||||
_setScanStatus(t('scan.status_ocr_searching'), 'retry', t('scan.method_ai_ocr'));
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
@@ -2098,12 +2102,28 @@ async function _tryGeminiNumberOCR() {
|
||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||
const result = await api('gemini_number_ocr', {}, 'POST', { image: imageBase64 });
|
||||
if (result.barcode) {
|
||||
scanLog(`AI OCR: found barcode ${result.barcode}`);
|
||||
showToast(t('scan.num_ocr_found').replace('{code}', result.barcode), 'success');
|
||||
onBarcodeDetected(result.barcode);
|
||||
} else {
|
||||
showToast(t('scan.num_ocr_not_found'), 'warning');
|
||||
scanLog('AI OCR: barcode digits not found');
|
||||
if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
|
||||
scanLog('AI OCR failed — switching to visual product identification');
|
||||
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||
await _tryGeminiVisualBarcode();
|
||||
} else {
|
||||
showToast(t('scan.num_ocr_not_found'), 'warning');
|
||||
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
scanLog(`AI OCR error: ${e.message}`);
|
||||
if (chainToVisual && scannerStream && !_aiFallbackExhausted) {
|
||||
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||
await _tryGeminiVisualBarcode();
|
||||
} else {
|
||||
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||
}
|
||||
showToast(t('error.connection'), 'error');
|
||||
} finally {
|
||||
_numOcrRunning = false;
|
||||
@@ -2111,6 +2131,225 @@ async function _tryGeminiNumberOCR() {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== AI VISUAL PRODUCT IDENTIFICATION (auto-fallback after 5s) =====
|
||||
let _aiBarcodeVisualRunning = false;
|
||||
let _aiDetectedProductDraft = null;
|
||||
let _aiInventoryCandidates = [];
|
||||
|
||||
function _showScanAiOverlay(msg) {
|
||||
const el = document.getElementById('scan-ai-overlay');
|
||||
const msgEl = document.getElementById('scan-ai-overlay-msg');
|
||||
if (el) el.style.display = 'flex';
|
||||
if (msgEl) msgEl.textContent = msg || '';
|
||||
}
|
||||
function _hideScanAiOverlay() {
|
||||
const el = document.getElementById('scan-ai-overlay');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
function _showAiRetryButton() {
|
||||
const btn = document.getElementById('scan-ai-retry-btn');
|
||||
if (btn) btn.style.display = '';
|
||||
}
|
||||
function _clearAiMatchPanel() {
|
||||
const result = document.getElementById('scan-result');
|
||||
if (!result) return;
|
||||
result.style.display = 'none';
|
||||
result.innerHTML = '';
|
||||
_aiDetectedProductDraft = null;
|
||||
_aiInventoryCandidates = [];
|
||||
}
|
||||
function _renderAiCandidateRow(item, idx) {
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
|
||||
const qty = (item.total_qty !== null && item.total_qty !== undefined)
|
||||
? `${parseFloat(item.total_qty)} ${item.unit || ''}`.trim()
|
||||
: '';
|
||||
return `
|
||||
<button class="scan-ai-candidate-item" type="button" onclick="_selectAiInventoryCandidate(${idx})">
|
||||
<span class="scan-ai-candidate-icon">${catIcon}</span>
|
||||
<span class="scan-ai-candidate-info">
|
||||
<span class="scan-ai-candidate-name">${escapeHtml(item.name || '')}</span>
|
||||
<span class="scan-ai-candidate-meta">${escapeHtml((item.brand || '') + (qty ? ' · ' + qty : ''))}</span>
|
||||
</span>
|
||||
<span class="scan-ai-candidate-cta">${t('scan.ai_match_use_btn')}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
function _showAiMatchChoices(aiProduct, candidates) {
|
||||
const result = document.getElementById('scan-result');
|
||||
if (!result) return;
|
||||
const aiName = aiProduct?.name || t('product.not_recognized');
|
||||
const aiBrand = aiProduct?.brand || '';
|
||||
const catIcon = CATEGORY_ICONS[mapToLocalCategory(aiProduct?.category || '', aiName)] || '📦';
|
||||
const itemsHtml = (candidates || []).slice(0, 3).map((it, i) => _renderAiCandidateRow(it, i)).join('');
|
||||
|
||||
result.innerHTML = `
|
||||
<div class="scan-ai-match-box">
|
||||
<div class="scan-ai-match-head">
|
||||
<div class="scan-ai-match-title">${t('scan.ai_match_title')}</div>
|
||||
<div class="scan-ai-match-subtitle">${t('scan.ai_match_subtitle')}</div>
|
||||
</div>
|
||||
|
||||
${itemsHtml ? `
|
||||
<div class="scan-ai-match-list-wrap">
|
||||
<div class="scan-ai-match-list-title">${t('scan.ai_match_existing')}</div>
|
||||
<div class="scan-ai-match-list">${itemsHtml}</div>
|
||||
</div>
|
||||
` : `
|
||||
<div class="scan-ai-match-empty">${t('scan.ai_match_none')}</div>
|
||||
`}
|
||||
|
||||
<button class="btn btn-primary scan-ai-add-btn" type="button" onclick="_confirmAiDetectedProduct()">
|
||||
${t('scan.ai_match_add_btn').replace('{name}', escapeHtml(aiName))}
|
||||
</button>
|
||||
<div class="scan-ai-detected-label">${t('scan.ai_detected_label')}</div>
|
||||
<div class="scan-ai-detected-pill">${catIcon} ${escapeHtml(aiName)}${aiBrand ? ' · ' + escapeHtml(aiBrand) : ''}</div>
|
||||
</div>
|
||||
`;
|
||||
result.style.display = 'block';
|
||||
}
|
||||
async function _confirmAiDetectedProduct() {
|
||||
const p = _aiDetectedProductDraft;
|
||||
if (!p) return;
|
||||
showLoading(true);
|
||||
try {
|
||||
const saveResult = await api('product_save', {}, 'POST', {
|
||||
barcode: '',
|
||||
name: p.name || t('product.not_recognized'),
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
image_url: '',
|
||||
unit: 'pz',
|
||||
default_quantity: 1,
|
||||
package_unit: '',
|
||||
notes: '',
|
||||
});
|
||||
if (saveResult.id) {
|
||||
currentProduct = {
|
||||
id: saveResult.id,
|
||||
barcode: '',
|
||||
name: p.name || t('product.not_recognized'),
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
image_url: '',
|
||||
unit: 'pz',
|
||||
default_quantity: 1,
|
||||
package_unit: '',
|
||||
_confCount: 0,
|
||||
weight_info: '',
|
||||
};
|
||||
addToScanRecents(currentProduct);
|
||||
_clearAiMatchPanel();
|
||||
showLoading(false);
|
||||
setTimeout(() => showProductAction(), 250);
|
||||
} else {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
} catch (_) {
|
||||
showLoading(false);
|
||||
showToast(t('error.connection'), 'error');
|
||||
}
|
||||
}
|
||||
function _selectAiInventoryCandidate(idx) {
|
||||
const p = _aiInventoryCandidates[idx];
|
||||
if (!p) return;
|
||||
currentProduct = {
|
||||
id: p.id,
|
||||
barcode: p.barcode || '',
|
||||
name: p.name || '',
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
image_url: p.image_url || '',
|
||||
unit: p.unit || 'pz',
|
||||
default_quantity: p.default_quantity || 1,
|
||||
package_unit: p.package_unit || '',
|
||||
_confCount: 0,
|
||||
weight_info: '',
|
||||
};
|
||||
if (p.notes) {
|
||||
const pesoMatch = p.notes.match(/Peso:\s*([^·]+)/);
|
||||
if (pesoMatch) currentProduct.weight_info = pesoMatch[1].trim();
|
||||
}
|
||||
addToScanRecents(currentProduct);
|
||||
_clearAiMatchPanel();
|
||||
setTimeout(() => showProductAction(), 250);
|
||||
}
|
||||
async function _retryAiScan() {
|
||||
const btn = document.getElementById('scan-ai-retry-btn');
|
||||
if (btn) btn.style.display = 'none';
|
||||
_aiFallbackExhausted = false;
|
||||
_clearAiMatchPanel();
|
||||
await _tryGeminiNumberOCR({ chainToVisual: true });
|
||||
}
|
||||
|
||||
async function _tryGeminiVisualBarcode() {
|
||||
if (_aiBarcodeVisualRunning || !_requireGemini()) return;
|
||||
const video = document.getElementById('scanner-video');
|
||||
if (!video || !video.videoWidth) return;
|
||||
|
||||
// ★ Capture the frame BEFORE stopping the stream — after stopScanner() the
|
||||
// video element is blanked and drawImage would send a black image to Gemini.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||
const imageBase64 = canvas.toDataURL('image/jpeg', 0.88).split(',')[1];
|
||||
if (!imageBase64) { scanLog('AI visual: failed to capture frame'); return; }
|
||||
|
||||
_aiBarcodeVisualRunning = true;
|
||||
stopScanner(); // stop scanner loop while AI processes (stream already captured above)
|
||||
_setScanStatus(t('scan.status_ai_visual_searching'), 'retry', t('scan.method_ai_vision'));
|
||||
_showScanAiOverlay(t('scan.ai_overlay_msg'));
|
||||
|
||||
try {
|
||||
const result = await api('gemini_barcode_visual', {}, 'POST', {
|
||||
image: imageBase64,
|
||||
lang: _currentLang || 'it',
|
||||
});
|
||||
|
||||
if (result.found && result.product) {
|
||||
const p = result.product;
|
||||
scanLog(`AI visual: found "${p.name}" (${p.brand})`);
|
||||
_hideScanAiOverlay();
|
||||
showToast(t('scan.ai_fallback_found'), 'success');
|
||||
_aiDetectedProductDraft = {
|
||||
name: p.name || t('product.not_recognized'),
|
||||
brand: p.brand || '',
|
||||
category: p.category || '',
|
||||
};
|
||||
let candidates = [];
|
||||
try {
|
||||
const invRes = await api('inventory_search', {
|
||||
q: _aiDetectedProductDraft.name,
|
||||
limit: 3,
|
||||
});
|
||||
candidates = (invRes.items || []).slice(0, 3);
|
||||
} catch (_) {
|
||||
candidates = [];
|
||||
}
|
||||
_aiInventoryCandidates = candidates;
|
||||
_showAiMatchChoices(_aiDetectedProductDraft, candidates);
|
||||
} else {
|
||||
scanLog('AI visual: product not identified — exhausted for this session');
|
||||
_aiFallbackExhausted = true;
|
||||
_hideScanAiOverlay();
|
||||
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
||||
_showAiRetryButton();
|
||||
// Restart barcode scanner — AI timer won't fire again (_aiFallbackExhausted=true).
|
||||
setTimeout(() => initScanner(), 300);
|
||||
}
|
||||
} catch (e) {
|
||||
scanLog(`AI visual error: ${e.message}`);
|
||||
_aiFallbackExhausted = true;
|
||||
_hideScanAiOverlay();
|
||||
_setScanStatus(t('scan.ai_fallback_exhausted'), 'retry', '');
|
||||
_showAiRetryButton();
|
||||
setTimeout(() => initScanner(), 300);
|
||||
} finally {
|
||||
_aiBarcodeVisualRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== CAMERA HELPER =====
|
||||
function getCameraConstraints(extraVideo = {}) {
|
||||
const s = getSettings();
|
||||
@@ -2296,6 +2535,7 @@ function _applySyncedSettings(serverSettings) {
|
||||
'shopping_enabled','shopping_mode','shopping_smart_suggestions',
|
||||
'shopping_forecast','shopping_auto_add_threshold',
|
||||
'dark_mode',
|
||||
'barcode_ai_fallback',
|
||||
// Home Assistant
|
||||
'ha_enabled','ha_url','ha_tts_entity','ha_webhook_id','ha_webhook_events',
|
||||
'ha_notify_service','ha_expiry_days'];
|
||||
@@ -2887,6 +3127,8 @@ async function loadSettingsUI() {
|
||||
const cameraSelect = document.getElementById('setting-camera-facing');
|
||||
if (cameraSelect) cameraSelect.value = s.camera_facing || 'environment';
|
||||
loadCameraDevices();
|
||||
const baifEl = document.getElementById('setting-barcode-ai-fallback');
|
||||
if (baifEl) baifEl.checked = s.barcode_ai_fallback === true;
|
||||
renderAppliances(s.appliances || []);
|
||||
const mealPlanEnabled = s.meal_plan_enabled !== false;
|
||||
const mpEnabledEl = document.getElementById('setting-meal-plan-enabled');
|
||||
@@ -3465,6 +3707,8 @@ async function saveSettings() {
|
||||
s.dietary = document.getElementById('setting-dietary').value.trim();
|
||||
// Camera
|
||||
s.camera_facing = document.getElementById('setting-camera-facing').value;
|
||||
const baifSave = document.getElementById('setting-barcode-ai-fallback');
|
||||
if (baifSave) s.barcode_ai_fallback = baifSave.checked;
|
||||
// Screensaver
|
||||
const ssEl = document.getElementById('setting-screensaver-enabled');
|
||||
if (ssEl) s.screensaver_enabled = ssEl.checked;
|
||||
@@ -3608,6 +3852,7 @@ async function saveSettings() {
|
||||
shopping_forecast: s.shopping_forecast !== false,
|
||||
shopping_auto_add_threshold: s.shopping_auto_add_threshold || 0,
|
||||
dark_mode: s.dark_mode || 'auto',
|
||||
barcode_ai_fallback: !!s.barcode_ai_fallback,
|
||||
// Home Assistant
|
||||
ha_enabled: !!s.ha_enabled,
|
||||
ha_url: s.ha_url || '',
|
||||
@@ -3748,6 +3993,18 @@ async function api(action, params = {}, method = 'GET', body = null, extraHeader
|
||||
// Track current page for auto-refresh
|
||||
let _currentPageId = 'dashboard';
|
||||
let _currentPageParam = null;
|
||||
let _pageHistory = [{ pageId: 'dashboard', param: null }];
|
||||
|
||||
function goBack(fallbackPage = 'dashboard') {
|
||||
if (_pageHistory.length > 1) {
|
||||
// Drop current page and navigate to the previous entry without re-adding history.
|
||||
_pageHistory.pop();
|
||||
const prev = _pageHistory[_pageHistory.length - 1] || { pageId: fallbackPage, param: null };
|
||||
showPage(prev.pageId, prev.param, { skipHistory: true });
|
||||
return;
|
||||
}
|
||||
showPage(fallbackPage, null, { skipHistory: true });
|
||||
}
|
||||
|
||||
// Refresh current page data without full navigation
|
||||
function refreshCurrentPage() {
|
||||
@@ -3765,7 +4022,17 @@ function refreshCurrentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function showPage(pageId, param = null) {
|
||||
function showPage(pageId, param = null, options = {}) {
|
||||
const skipHistory = !!options.skipHistory;
|
||||
if (!skipHistory) {
|
||||
const last = _pageHistory[_pageHistory.length - 1];
|
||||
const sameAsLast = !!last && last.pageId === pageId && (last.param ?? null) === (param ?? null);
|
||||
if (!sameAsLast) {
|
||||
_pageHistory.push({ pageId, param });
|
||||
if (_pageHistory.length > 80) _pageHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
_currentPageId = pageId;
|
||||
_currentPageParam = param;
|
||||
// Hide all pages
|
||||
@@ -3797,7 +4064,7 @@ function showPage(pageId, param = null) {
|
||||
}
|
||||
loadInventory();
|
||||
break;
|
||||
case 'scan': initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
|
||||
case 'scan': _aiFallbackExhausted = false; _hideScanAiOverlay(); { const _rb = document.getElementById('scan-ai-retry-btn'); if (_rb) _rb.style.display = 'none'; } initScanner(); clearQuickNameResults(); updateSpesaBanner(); updateScanRecents(); switchScanTab('barcode');
|
||||
// Pre-warm the embedding model the first time user visits scan page
|
||||
if (typeof window._getCategoryPipeline === 'function' && !window._categoryPipelineReady) {
|
||||
window._getCategoryPipeline(); // fire-and-forget
|
||||
@@ -4942,10 +5209,11 @@ async function loadBannerAlerts() {
|
||||
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
|
||||
|
||||
try {
|
||||
const [invData, predData, anomalyData, finishedData, statsData] = await Promise.all([
|
||||
const [invData, predData, anomalyData, dupLossData, finishedData, statsData] = await Promise.all([
|
||||
api('inventory_list'),
|
||||
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
|
||||
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
|
||||
api('inventory_duplicate_loss_checks').catch(err => { console.warn('[Banner] duplicate loss checks fetch failed:', err); return { checks: [] }; }),
|
||||
api('inventory_finished_items').catch(err => { console.warn('[Banner] finished_items fetch failed:', err); return { finished: [] }; }),
|
||||
api('stats').catch(() => ({ opened: [] })),
|
||||
]);
|
||||
@@ -5069,14 +5337,21 @@ async function loadBannerAlerts() {
|
||||
_bannerQueue.push({ type: 'anomaly', data: an });
|
||||
});
|
||||
|
||||
// 6. Finished products: inventory hit 0, waiting for user confirmation
|
||||
// 6. Potentially lost products due to rapid duplicate "out" events
|
||||
const dupChecks = dupLossData.checks || [];
|
||||
dupChecks.forEach(ch => {
|
||||
if (confirmed['dup_' + ch.dismiss_key]) return;
|
||||
_bannerQueue.push({ type: 'dup_loss_check', data: ch });
|
||||
});
|
||||
|
||||
// 7. Finished products: inventory hit 0, waiting for user confirmation
|
||||
const finished = finishedData.finished || [];
|
||||
finished.forEach(fin => {
|
||||
if (confirmed['fin_' + fin.product_id]) return;
|
||||
_bannerQueue.push({ type: 'finished', data: fin });
|
||||
});
|
||||
|
||||
// 7. Products with no expiry date set (and not permanently dismissed)
|
||||
// 8. Products with no expiry date set (and not permanently dismissed)
|
||||
// Warn for ALL food/drink items — only skip igiene/pulizia (non-food).
|
||||
// Items are capped at 8 per load (opened packages first) to avoid banner overflow.
|
||||
const noExpiryDismissed = _getNoExpiryDismissed();
|
||||
@@ -5157,6 +5432,8 @@ function _bannerPriority(entry) {
|
||||
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
|
||||
return entry.data.direction === 'missing' ? 260 : 250;
|
||||
}
|
||||
case 'dup_loss_check':
|
||||
return 700; // high-priority check: likely double-consume loss
|
||||
case 'finished':
|
||||
return 600; // product ran out — confirm before removing from DB
|
||||
case 'no_expiry':
|
||||
@@ -5358,6 +5635,30 @@ function renderBannerItem() {
|
||||
}
|
||||
actionsEl.innerHTML = btns;
|
||||
|
||||
} else if (entry.type === 'dup_loss_check') {
|
||||
const ch = entry.data;
|
||||
banner.className = 'alert-banner banner-dup-loss';
|
||||
iconEl.textContent = '🧪';
|
||||
|
||||
const locInfo = LOCATIONS[ch.location] || { icon: '📦', label: ch.location || '—' };
|
||||
const locText = `${locInfo.icon} ${locInfo.label}`;
|
||||
const qtyPair = `${ch.q1} + ${ch.q2}`;
|
||||
|
||||
titleEl.textContent = t('dashboard.banner_dup_loss_title').replace('{name}', ch.name);
|
||||
detailEl.textContent = t('dashboard.banner_dup_loss_detail')
|
||||
.replace('{location}', locText)
|
||||
.replace('{seconds}', Math.round(ch.dt_sec || 0))
|
||||
.replace('{qty_pair}', qtyPair);
|
||||
|
||||
let btns = '';
|
||||
if (ch.inventory_id && ch.inventory_id > 0) {
|
||||
btns += `<button class="btn-banner btn-banner-edit" onclick="editReviewItem(${ch.inventory_id}, ${ch.product_id})">${t('dashboard.banner_dup_loss_action_fix')}</button>`;
|
||||
} else {
|
||||
btns += `<button class="btn-banner btn-banner-edit" onclick="openDuplicateLossCheck(${ch.product_id})">${t('dashboard.banner_dup_loss_action_open')}</button>`;
|
||||
}
|
||||
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissDuplicateLossCheck()">${t('dashboard.banner_dup_loss_action_done')}</button>`;
|
||||
actionsEl.innerHTML = btns;
|
||||
|
||||
} else if (entry.type === 'no_expiry') {
|
||||
const item = entry.data;
|
||||
banner.className = 'alert-banner banner-no-expiry';
|
||||
@@ -5495,6 +5796,32 @@ function dismissBannerAnomaly() {
|
||||
dismissBannerItem();
|
||||
}
|
||||
|
||||
function dismissDuplicateLossCheck() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry || entry.type !== 'dup_loss_check') return;
|
||||
const key = entry.data.dismiss_key;
|
||||
setReviewConfirmed('dup_' + key);
|
||||
showToast(t('dashboard.banner_dup_loss_toast_done'), 'success');
|
||||
dismissBannerItem();
|
||||
}
|
||||
|
||||
async function openDuplicateLossCheck(productId) {
|
||||
showLoading(true);
|
||||
try {
|
||||
const data = await api('product_get', { id: productId });
|
||||
if (data.product) {
|
||||
currentProduct = data.product;
|
||||
showProductAction();
|
||||
} else {
|
||||
showToast(t('error.not_found'), 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(t('error.connection'), 'error');
|
||||
} finally {
|
||||
showLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function weighBannerItem() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry) return;
|
||||
@@ -6489,11 +6816,14 @@ async function initScanner() {
|
||||
|
||||
const constraints = getCameraConstraints();
|
||||
scanLog(`Camera mode: ${getSettings().camera_facing || 'environment'}`);
|
||||
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (Quagga fallback)'}`);
|
||||
scanLog(`BarcodeDetector: ${_useBarcodeDetector ? 'YES (native)' : 'NO (fallback)'}`);
|
||||
scanLog(`Gemini available: ${_geminiAvailable}`);
|
||||
scanLog(`AI fallback enabled: ${getSettings().barcode_ai_fallback === true}`);
|
||||
scanLog(`Constraints: ${JSON.stringify(constraints.video)}`);
|
||||
|
||||
try {
|
||||
stopScanner();
|
||||
_clearAiMatchPanel();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
const track = stream.getVideoTracks()[0];
|
||||
@@ -6527,6 +6857,17 @@ async function initScanner() {
|
||||
}
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
// After 5s without a scan, auto-trigger AI visual identification (if enabled)
|
||||
if (_geminiAvailable && getSettings().barcode_ai_fallback && !_aiFallbackExhausted) {
|
||||
clearTimeout(_aiFallbackTimer);
|
||||
_aiFallbackTimer = setTimeout(() => {
|
||||
if (scannerStream && !_aiFallbackExhausted) { // still scanning — no barcode found yet
|
||||
scanLog('5s elapsed without barcode — triggering AI OCR fallback');
|
||||
_tryGeminiNumberOCR({ chainToVisual: true });
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
scanLog(`CAMERA ERROR: ${err.name}: ${err.message}`);
|
||||
@@ -6598,14 +6939,14 @@ async function startNativeScanner(videoEl) {
|
||||
|
||||
if (frameCount === 1) {
|
||||
updateFeedback('scanning');
|
||||
_setScanStatus(t('scan.status_scanning'), '', 'Native API');
|
||||
_setScanStatus(t('scan.status_scanning'), '', '');
|
||||
}
|
||||
|
||||
// After 2s without detection, also start Quagga in parallel as backup
|
||||
if (!quaggaParallelStarted && (Date.now() - startTime) > 2000) {
|
||||
quaggaParallelStarted = true;
|
||||
scanLog('Native: 2s elapsed, spawning Quagga in parallel');
|
||||
_setScanStatus(t('scan.status_parallel'), 'retry', 'Native + Quagga');
|
||||
scanLog('Native: 2s elapsed, spawning fallback scanner in parallel');
|
||||
_setScanStatus(t('scan.status_parallel'), 'retry', '');
|
||||
quaggaRunning = false; // temporarily release so Quagga can start
|
||||
startQuaggaScanner(videoEl, false);
|
||||
quaggaRunning = true; // re-take ownership (Quagga will share)
|
||||
@@ -6637,6 +6978,13 @@ async function startNativeScanner(videoEl) {
|
||||
// For other formats (code_128, code_39) require 2 to avoid false reads.
|
||||
const highConfidence = ['ean_13','ean_8','upc_a','upc_e'].includes(format);
|
||||
if (highConfidence || detectCount >= 2 || detectionHistory[code].count >= 2) {
|
||||
if (highConfidence && !validateEANChecksum(code)) {
|
||||
_invalidBarcodeCount++;
|
||||
scanLog(`Invalid EAN checksum (native): ${code} (retry #${_invalidBarcodeCount})`);
|
||||
_setScanStatus(t('scan.status_invalid').replace('{code}', code), 'invalid', 'Native');
|
||||
lastDetected = ''; detectCount = 0;
|
||||
return;
|
||||
}
|
||||
scanning = false;
|
||||
quaggaRunning = false;
|
||||
updateFeedback(null);
|
||||
@@ -6674,7 +7022,7 @@ function startQuaggaScanner(videoEl, isPrimary = true) {
|
||||
let frameCount = 0;
|
||||
let partialCount = 0;
|
||||
|
||||
scanLog(`Quagga starting — frontCam: ${frontCam}`);
|
||||
scanLog(`Fallback scanner starting — frontCam: ${frontCam}`);
|
||||
|
||||
let scanning = true;
|
||||
quaggaRunning = true;
|
||||
@@ -6850,6 +7198,10 @@ function stopScanner() {
|
||||
quaggaRunning = false;
|
||||
_scanZoomLevel = 2; // always 2x on next start
|
||||
_torchActive = false;
|
||||
clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
|
||||
// NOTE: _aiFallbackExhausted is intentionally NOT reset here.
|
||||
// It is only reset in showPage('scan') so that internal stop/restart
|
||||
// cycles (e.g. initScanner calling stopScanner) don't re-arm the AI timer.
|
||||
if (scannerStream) {
|
||||
scannerStream.getTracks().forEach(t => t.stop());
|
||||
scannerStream = null;
|
||||
@@ -6861,6 +7213,7 @@ function stopScanner() {
|
||||
if (tb) tb.classList.remove('torch-on');
|
||||
// Hide live code
|
||||
_hideScanLiveCode();
|
||||
_clearAiMatchPanel();
|
||||
// Also stop AI camera
|
||||
if (aiStream) {
|
||||
aiStream.getTracks().forEach(t => t.stop());
|
||||
@@ -6871,6 +7224,7 @@ function stopScanner() {
|
||||
}
|
||||
|
||||
async function onBarcodeDetected(barcode) {
|
||||
clearTimeout(_aiFallbackTimer); _aiFallbackTimer = null;
|
||||
showLoading(true);
|
||||
|
||||
// Vibrate if available
|
||||
@@ -7026,7 +7380,7 @@ function autoSubmitEAN(inputEl, force = false) {
|
||||
if (!raw) { showToast(t('error.barcode_empty'), 'error'); inputEl.focus(); return; }
|
||||
if (!/^\d{4,14}$/.test(raw)) { showToast(t('error.barcode_format'), 'error'); inputEl.focus(); return; }
|
||||
if (isComplete && !isValid) {
|
||||
showToast('⚠️ Checksum EAN errato — verifica le cifre', 'warning');
|
||||
showToast(t('error.barcode_checksum'), 'error'); inputEl.focus(); return;
|
||||
}
|
||||
stopScanner();
|
||||
onBarcodeDetected(raw);
|
||||
@@ -7781,7 +8135,7 @@ function showProductAction() {
|
||||
|
||||
// Update back button: go back to shopping if came from shopping list scan
|
||||
const backBtn = document.getElementById('action-back-btn');
|
||||
if (backBtn) backBtn.onclick = _spesaScanTarget ? () => { _spesaScanTarget = null; showPage('shopping'); } : () => showPage('scan');
|
||||
if (backBtn) backBtn.onclick = () => goBack();
|
||||
|
||||
// Show "shopping target" banner if we came from the shopping list
|
||||
const banner = document.getElementById('shopping-scan-target-banner');
|
||||
@@ -7795,7 +8149,7 @@ function showProductAction() {
|
||||
</div>
|
||||
<div class="shopping-scan-target-actions">
|
||||
<button class="btn btn-success stb-btn" onclick="confirmShoppingItemFound()">✅ ${t('shopping.scan_target_found')}</button>
|
||||
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>showPage('scan')">✕ ${t('btn.cancel')}</button>
|
||||
<button class="btn btn-secondary stb-btn" onclick="_spesaScanTarget=null; document.getElementById('shopping-scan-target-banner').style.display='none'; document.getElementById('action-back-btn').onclick=()=>goBack()">✕ ${t('btn.cancel')}</button>
|
||||
</div>
|
||||
`;
|
||||
} else if (banner) {
|
||||
@@ -13559,7 +13913,7 @@ async function toggleRecipeFavorite(btn) {
|
||||
* Scale recipe ingredient quantities (#123).
|
||||
* Delta: +1 or -1. Min 1, max 20 persons.
|
||||
*/
|
||||
function adjustRecipePersons(delta) {
|
||||
function scaleRecipePersons(delta) {
|
||||
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
|
||||
if (newPersons === _recipeCurrentPersons) return;
|
||||
_recipeCurrentPersons = newPersons;
|
||||
@@ -13604,9 +13958,9 @@ function renderRecipe(r) {
|
||||
html += '<div class="recipe-meta">';
|
||||
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||
html += `<span class="recipe-tag recipe-persons-ctrl">
|
||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(-1)">−</button>
|
||||
<button class="btn-persons-adj" onclick="scaleRecipePersons(-1)">−</button>
|
||||
<span id="recipe-persons-display">👥 ${r.persons} ${t('recipes.persons_short')}</span>
|
||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(+1)">+</button>
|
||||
<button class="btn-persons-adj" onclick="scaleRecipePersons(+1)">+</button>
|
||||
</span>`;
|
||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
|
||||
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
|
||||
|
||||
+34
-13
@@ -64,7 +64,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.35</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.35</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -194,7 +194,7 @@
|
||||
<!-- ===== INVENTORY LIST ===== -->
|
||||
<section class="page" id="page-inventory">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="inventory-title" data-i18n="inventory.title">Dispensa</h2>
|
||||
<button class="page-header-action-btn" onclick="_showExportModal()" title="Export" data-i18n-title="export.btn_title">📤</button>
|
||||
</div>
|
||||
@@ -225,7 +225,7 @@
|
||||
<!-- ===== SCAN PAGE ===== -->
|
||||
<section class="page" id="page-scan">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="scan.title">Scansiona</h2>
|
||||
<button class="scan-spesa-chip" id="scan-spesa-btn" onclick="startSpesaMode()" data-i18n="scan.spesa_btn">🛒 Spesa</button>
|
||||
</div>
|
||||
@@ -256,6 +256,14 @@
|
||||
<span id="scan-status-method" class="scan-status-method"></span>
|
||||
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||
</div>
|
||||
<!-- AI processing overlay (shown when Gemini Vision is analyzing) -->
|
||||
<div class="scan-ai-overlay" id="scan-ai-overlay" style="display:none">
|
||||
<div class="scan-ai-overlay-inner">
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="scan-ai-overlay-label">Gemini Vision</span>
|
||||
<span class="scan-ai-overlay-msg" id="scan-ai-overlay-msg"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Success flash overlay -->
|
||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||
<div class="scan-confirm-check">✓</div>
|
||||
@@ -274,6 +282,9 @@
|
||||
<!-- Scan errors -->
|
||||
<div class="scan-result" id="scan-result" style="display:none"></div>
|
||||
|
||||
<!-- AI retry button (shown after visual identification fails) -->
|
||||
<button class="btn btn-accent scan-ai-retry-btn" id="scan-ai-retry-btn" style="display:none" onclick="_retryAiScan()" data-i18n="scan.ai_retry_btn">🤖 Riprova con AI</button>
|
||||
|
||||
<!-- Recent scans -->
|
||||
<div class="scan-recents" id="scan-recents" style="display:none">
|
||||
<span class="scan-recents-label" data-i18n="scan.recents_label">Recenti</span>
|
||||
@@ -333,7 +344,7 @@
|
||||
<!-- ===== PRODUCT ACTION (IN/OUT after scan) ===== -->
|
||||
<section class="page" id="page-action">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" id="action-back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" id="action-back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="action.title">Cosa vuoi fare?</h2>
|
||||
</div>
|
||||
<!-- Banner: shopping list scan context -->
|
||||
@@ -356,7 +367,7 @@
|
||||
<!-- ===== ADD TO INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-add">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="add.title">Aggiungi alla Dispensa</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="add-product-preview"></div>
|
||||
@@ -419,7 +430,7 @@
|
||||
<!-- ===== USE FROM INVENTORY FORM ===== -->
|
||||
<section class="page" id="page-use">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('action')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="use.title">Usa / Consuma</h2>
|
||||
</div>
|
||||
<div class="product-preview-small" id="use-product-preview"></div>
|
||||
@@ -475,7 +486,7 @@
|
||||
<!-- ===== MANUAL / EDIT PRODUCT FORM ===== -->
|
||||
<section class="page" id="page-product-form">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 id="product-form-title">Nuovo Prodotto</h2>
|
||||
</div>
|
||||
<form class="form" onsubmit="submitProduct(event)">
|
||||
@@ -663,7 +674,7 @@
|
||||
<!-- ===== ALL PRODUCTS PAGE ===== -->
|
||||
<section class="page" id="page-products">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="products.title">📦 Tutti i Prodotti</h2>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
@@ -675,7 +686,7 @@
|
||||
<!-- ===== RECIPE PAGE ===== -->
|
||||
<section class="page" id="page-recipe">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||
</div>
|
||||
<div class="recipe-page-container">
|
||||
@@ -689,7 +700,7 @@
|
||||
<!-- ===== SHOPPING LIST (BRING!) PAGE ===== -->
|
||||
<section class="page" id="page-shopping">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="shopping.title">🛒 Lista della Spesa</h2>
|
||||
</div>
|
||||
<div class="shopping-container">
|
||||
@@ -797,7 +808,7 @@
|
||||
<!-- ===== AI IDENTIFICATION PAGE ===== -->
|
||||
<section class="page" id="page-ai">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="stopScanner(); showPage('scan')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="ai.title">🤖 Identificazione AI</h2>
|
||||
</div>
|
||||
<div class="ai-container">
|
||||
@@ -835,7 +846,7 @@
|
||||
<!-- ===== SETTINGS PAGE ===== -->
|
||||
<section class="page" id="page-settings">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onclick="showPage('dashboard')" data-i18n="btn.back">← Indietro</button>
|
||||
<button class="back-btn" onclick="goBack()" data-i18n="btn.back">← Indietro</button>
|
||||
<h2 data-i18n="settings.title">⚙️ Configurazione</h2>
|
||||
</div>
|
||||
<div class="settings-tabs">
|
||||
@@ -1183,6 +1194,16 @@
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.devices_hint">Se hai più fotocamere, puoi selezionarne una specifica dall'elenco sopra dopo aver concesso i permessi.</p>
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="loadCameraDevices()" data-i18n="settings.camera.detect_btn">🔄 Rileva fotocamere</button>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:14px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.camera.ai_fallback_label">Identificazione visiva AI (fallback 5s)</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-barcode-ai-fallback">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.camera.ai_fallback_hint">Se il codice a barre non viene letto entro 5 secondi, un fotogramma viene inviato automaticamente all'AI per identificare visivamente il prodotto. Richiede Gemini configurato.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Security Tab -->
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.25",
|
||||
"version": "1.7.35",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+1474
-1448
File diff suppressed because it is too large
Load Diff
+1474
-1448
File diff suppressed because it is too large
Load Diff
+1417
-1391
File diff suppressed because it is too large
Load Diff
+1417
-1391
File diff suppressed because it is too large
Load Diff
+1473
-1447
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user