Compare commits

..

22 Commits

Author SHA1 Message Date
dependabot[bot] 51f55071fa ci: bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 00:23:13 +00:00
dadaloop82 3a4e843334 Merge branch 'develop' 2026-06-02 08:59:19 +00:00
dadaloop82 7104483dac fix: barcode EAN checksum validation + recipe persons dialog conflict
- Manual barcode input now blocks on invalid EAN checksum (was warning-only)
- Native BarcodeDetector now validates EAN/UPC checksum before confirming
- Renamed duplicate adjustRecipePersons (rescaler) to scaleRecipePersons
  to restore +/- buttons in the recipe generation dialog
- Added error.barcode_checksum translation key (all 5 languages)
- Bump version to v1.7.35
2026-06-02 08:58:48 +00:00
dadaloop82 94e98bc79f style: remove 'Quagga' text from scanner status bar and debug labels 2026-05-29 17:49:17 +00:00
dadaloop82 fd039d743e fix: move _aiFallbackExhausted reset out of stopScanner
stopScanner() is called internally by initScanner() on every restart,
so resetting the flag there caused the AI timer to re-arm on every
internal cycle — creating an infinite 5-second loop.

Flag now resets only in showPage('scan'), which fires exclusively when
the user opens the scanner page (fresh session). Internal stop/restart
cycles leave the flag untouched.
2026-05-29 17:46:45 +00:00
dadaloop82 b1bcf9e714 fix: AI visual barcode fallback fires only once per scanner session
If Gemini cannot identify the product visually, mark _aiFallbackExhausted=true
for the current scanner session so the 5s timer never fires again. The scanner
restarts normally (user can keep trying with the barcode reader) and a persistent
status message is shown: 'AI: product not recognized — try scanning the barcode'.
_aiFallbackExhausted resets to false in stopScanner() so the next camera session
starts fresh.
2026-05-29 17:43:55 +00:00
dadaloop82 98c38f017e feat: AI visual barcode fallback after 5s with settings toggle
When the barcode scanner cannot read a code within 5 seconds and Gemini
is available, a camera frame is automatically captured and sent to the
new gemini_barcode_visual endpoint for visual product identification.
The result pre-fills the product form identically to a barcode scan.

- PHP: new geminiBarcodeVisual() function + router case + aiActions entry
- PHP: barcode_ai_fallback setting in getServerSettings() + saveSettings() boolMap
- JS: _aiFallbackTimer (cleared on detection/stop), 5s timer in initScanner()
- JS: _tryGeminiVisualBarcode() — captures JPEG frame, calls API, saves product
- JS: barcode_ai_fallback wired into serverKeys, applyUI, collectUI, POST body
- HTML: AI fallback toggle in Settings → Camera card
- Translations: ai_fallback_* strings in scan + settings.camera (it/en/de/fr/es)

Feature is disabled by default (BARCODE_AI_FALLBACK=false).
2026-05-29 17:37:37 +00:00
dadaloop82 7947f47e6d release: v1.7.33 2026-05-29 11:06:28 +00:00
dadaloop82 758eb93e20 fix: ha_sensor shopping_total null + wrong shopping_list columns
- Extended shopping_total cache TTL from 1h to 24h
- Added inline price fallback: when cache is empty/stale, computes total
  from shopping_price_cache.json (no AI calls); joins shopping_list with
  products to get canonical shopping_name; tries both v3 and legacy v0
  key formats to maximise cache hit rate; works in both internal and
  Bring shopping modes (removed isShoppingBringMode guard — table is
  always populated by sync)
- Fixed haInventorySensor + haRefreshPrices: shopping_list has no
  quantity/unit/checked columns; changed to SELECT name with
  COALESCE(p.shopping_name, sl.name) join, defaults qty=1/unit=pz
2026-05-29 11:06:19 +00:00
dadaloop82 ff1175451a release: v1.7.32 2026-05-29 06:54:42 +00:00
dadaloop82 42630c3e3e feat: smarter expiry-to-shopping-list logic
- Extend isExpiringSoon threshold: 3d -> 7d
- Expired items: add isRegular/buyCount>=2 guard so one-off
  expired products don't appear in shopping list (expiry
  banner already covers them)
- Expiring-soon block: require isRegular for 7-day window;
  add 'willExpireBeforeUsed' check (daysLeft > daysToExpiry);
  new reason string 'Scade in Ngg — ricompra' when stock is
  adequate but won't be consumed in time
2026-05-29 06:54:40 +00:00
dadaloop82 637eaa20d6 docs: version badge 1.7.31 2026-05-29 06:48:52 +00:00
dadaloop82 5e307f79b8 docs: update version badge to v1.7.31 2026-05-29 06:48:50 +00:00
dadaloop82 a6478b20e1 release: v1.7.31 2026-05-29 06:46:40 +00:00
dadaloop82 223457bbdf fix: addToInventory creates new row when all existing rows are opened
When adding a new pack of a product that already has an opened row
in inventory (opened_at IS NOT NULL), the previous code merged the
new stock into the opened row, corrupting opened_at tracking and
hiding the second pack from the anomaly model.

Now: search only for sealed rows (opened_at IS NULL) to merge into.
If only opened rows exist, INSERT a new sealed row instead.
2026-05-29 06:46:37 +00:00
dadaloop82 12c6a8977a release: v1.7.30 2026-05-29 06:37:52 +00:00
dadaloop82 c7a69d8379 fix: consumption anomaly ignores sealed packs in other rows
getConsumptionPredictions now aggregates total qty across all
inventory rows for the same product_id before flagging.
If totalQtyAllRows >= expectedQty, the anomaly is suppressed
(stock is healthy, just split across opened+sealed rows).
Also uses aggregated total as the displayed actual_qty.
2026-05-29 06:37:50 +00:00
dadaloop82 c7f3c95d75 release: v1.7.29 2026-05-29 06:34:50 +00:00
dadaloop82 a6f90a07e5 feat: buy-cycle consumption prediction for untracked products
Products like salt/spices that are never marked per-use now get
consumption rate estimated from the average time between restocks:
  avgCycleDays = (lastIn - firstIn) / (buyCount - 1)
  estimatedDaysLeft = avgCycleDays - daysSinceLastBuy

Requirements: buyCount >= 3, dailyRate == 0, avgCycle >= 7 days.
Appears in smart shopping list with reason 'Finisce tra ~Ngg (ciclo medio Mgg)'.
Also marks buy-cycle products as isRegular so stock checks apply.
2026-05-29 06:34:40 +00:00
dadaloop82 2d07001c5b release: v1.7.28 2026-05-29 06:02:53 +00:00
dadaloop82 faa55eda93 chore: CHANGELOG v1.7.28 2026-05-29 06:02:51 +00:00
dadaloop82 0b902d7c19 fix: issue reporter — local FP cache prevents duplicate issues
- Add _getFpCachePath(), _loadFpCache(), _saveFpCache() helpers
- Check data/reported_issue_fps.json before GitHub Search API
  (falls back to /tmp/ when data/ is not writable)
- Save new issue number to cache immediately after creation
- Apply 30-minute comment throttle per fingerprint
- Fall back to GitHub Search on first run / cache miss

Fixes root cause of ~50 duplicate issues (#134 duplicates #135-#183)
caused by GitHub Search API indexing delay.
2026-05-29 06:02:27 +00:00
12 changed files with 8038 additions and 7198 deletions
+1 -1
View File
@@ -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 }}"
+47
View File
@@ -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
+1 -1
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.25-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.33-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
+326 -38
View File
@@ -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
View File
@@ -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];
@@ -6528,6 +6858,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}`);
console.error('Camera error:', err);
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1474 -1448
View File
File diff suppressed because it is too large Load Diff
+1417 -1391
View File
File diff suppressed because it is too large Load Diff
+1417 -1391
View File
File diff suppressed because it is too large Load Diff
+1473 -1447
View File
File diff suppressed because it is too large Load Diff