chore: auto-merge develop → main

Triggered by: da62647 feat: v1.7.9 — category badges, category search, AI guards
This commit is contained in:
github-actions[bot]
2026-05-11 05:55:10 +00:00
10 changed files with 333 additions and 44 deletions
+17
View File
@@ -5,6 +5,23 @@ All notable changes to EverShelf will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.7.9] - 2026-05-11
### Added
- **Category badge on inventory items** — Every product in the inventory now displays a macro-category badge (icon + label) next to the location badge. Badges showing `altro` are asynchronously refined via the new `guess_category` AI endpoint (Gemini + `data/category_ai_cache.json` cache) so the correct category appears automatically after the page loads.
- **Category search** — The inventory search bar now matches items by category. Typing "biscotti" returns every cookie/biscuit regardless of brand or exact name; the match uses both the direct category key and the translated label.
- **Brand map in `guessCategoryFromName`** — A fast-path brand table (Oreo, Ringo, Uno, Barilla, De Cecco, Galbani, Mutti, Lavazza, etc.) provides instant category resolution before any regex evaluation.
- **PHP `guess_category` endpoint** — New server-side action that calls Gemini to classify a product name into a local category key, with file-based caching (`data/category_ai_cache.json`). Returns `altro` immediately when no Gemini API key is configured.
### Fixed
- **Duplicate banner alerts** — `loadBannerAlerts()` was occasionally enqueuing the same item multiple times when called concurrently. Fixed with a `_bannerLoading` re-entrancy guard and a `_queuedItemIds` Set that prevents any item from being pushed more than once per refresh cycle.
- **`mapToLocalCategory` with `en:dairies` / `en:dairies-and-eggs`** — The dairy regex was not matching OpenFoodFacts tags that use the `dairi` stem; extended to cover the full range of dairy tags.
- **`mapToLocalCategory` always returning `altro`** — When the input category was already `altro`, the function exited the direct-match loop before attempting any fallback, losing all name-based guesses. The loop now skips the `altro` key for the early-return and falls back to `guessCategoryFromName(productName)` at the end.
- **"Tonno all'olio" → condimenti** — `tonno\b` was matched after `olio\b` (condimenti) due to regex ordering. Moved the conserve block before the condimenti block so tuna products resolve correctly.
### Security
- **AI function guards** — All Gemini-powered functions now check `_geminiAvailable` (JS) or the presence of `GEMINI_API_KEY` (PHP) before executing. Affected functions: `_refineCategoryBadgesAsync`, `fetchAllPrices`, `getShoppingPrice`. The PHP endpoint returns `{"success":false,"error":"no_api_key"}` instead of silently returning empty results, making the missing-key state explicit and diagnosable.
## [1.7.8] - 2026-05-10
### Added
+3
View File
@@ -30,6 +30,9 @@
## 🌍 Recent Updates
- **Category badges on inventory items** — Every product now shows its macro-category badge (icon + label) inline. Items that can't be classified locally are automatically refined via Gemini AI after the page loads, with server-side caching so the AI call only happens once per product.
- **Category search** — Typing a category name (e.g. "biscotti", "latticini") in the inventory search bar now returns all matching items across brands and exact names.
- **AI function guards** — All Gemini-powered features (price estimation, category classification, recipe suggestions, etc.) now explicitly check whether a Gemini API key is configured before making any AI call. The UI gracefully skips or shows a friendly message instead of silently failing.
- **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab.
- **Kiosk v1.7.0: OTA update system** — "Cerca aggiornamenti" button in Settings triggers a forced GitHub release check; new `installUpdate()` JS bridge calls Android `DownloadManager` directly (lockTask mode blocks external browser links); graceful degradation for older APKs with manual instructions. Automatic OTA check every 6 hours with native update banner.
- **Kiosk: consistent APK signing** — Project keystore (`evershelf.jks`) committed to the repo; every build — local or CI — now produces an APK with the same signature, eliminating "APK incompatible / signature conflict" errors on OTA update.
+70
View File
@@ -17,6 +17,7 @@ define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e4526
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
@@ -99,6 +100,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}
// ── Ping / heartbeat — early response, no DB or rate-limit required ───────────
if (($_GET['action'] ?? '') === 'ping') {
echo json_encode(['ok' => true, 'ts' => time()]);
exit;
}
// ===== RATE LIMITING =====
/**
* Simple file-based rate limiter.
@@ -455,6 +462,10 @@ try {
getAllShoppingPrices($db);
break;
case 'guess_category':
guessCategoryFromAI();
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -7393,6 +7404,59 @@ PROMPT;
return $out;
}
/**
/**
* GET /api/?action=guess_category&name=...
* Returns the macro-category for a product name, using a file cache + Gemini AI fallback.
* Response: { category: string }
*/
function guessCategoryFromAI(): void {
$name = trim($_GET['name'] ?? '');
if ($name === '') { echo json_encode(['category' => 'altro']); return; }
// Load cache
$cache = [];
if (file_exists(CATEGORY_CACHE_PATH)) {
$cache = json_decode(file_get_contents(CATEGORY_CACHE_PATH), true) ?? [];
}
$key = md5(mb_strtolower($name));
if (isset($cache[$key])) { echo json_encode(['category' => $cache[$key]]); return; }
$apiKey = env('GEMINI_API_KEY', '');
if ($apiKey === '') { echo json_encode(['category' => 'altro']); return; }
$cats = 'latticini, carne, pesce, frutta, verdura, pasta, pane, surgelati, bevande, condimenti, snack, conserve, cereali, igiene, pulizia, altro';
$prompt = "Sei un classificatore di prodotti alimentari e domestici italiani.\n"
. "Classifica il prodotto \"" . addslashes($name) . "\" in UNA di queste categorie esatte: $cats.\n"
. "Rispondi con SOLO la parola chiave della categoria, senza spiegazioni né punteggiatura aggiuntiva.";
$payload = [
'contents' => [['parts' => [['text' => $prompt]]]],
'generationConfig' => [
'temperature' => 0,
'maxOutputTokens' => 20,
'thinkingConfig' => ['thinkingBudget' => 0],
],
];
$result = callGeminiWithFallback($apiKey, $payload, 10);
$raw = strtolower(trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? ''));
$raw = preg_replace('/[^a-z_ ]/', '', $raw);
$raw = trim($raw);
$valid = ['latticini','carne','pesce','frutta','verdura','pasta','pane','surgelati',
'bevande','condimenti','snack','conserve','cereali','igiene','pulizia','altro'];
$cat = in_array($raw, $valid, true) ? $raw : 'altro';
// Persist to cache
$cache[$key] = $cat;
@file_put_contents(CATEGORY_CACHE_PATH, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
echo json_encode(['category' => $cat]);
}
// ─────────────────────────────────────────────────────────────────────────────
/**
* GET /api/?action=get_shopping_price
* POST body: { name, quantity, unit, default_quantity, package_unit, country, currency, lang, force_refresh }
@@ -7417,6 +7481,12 @@ function getShoppingPrice(PDO $db): void {
return;
}
// Guard: price estimation requires Gemini API key
if (empty(env('GEMINI_API_KEY'))) {
echo json_encode(['success' => false, 'error' => 'no_api_key']);
return;
}
$cache = _loadPriceCache();
$key = _priceKey($name, $country);
$now = time();
+50
View File
@@ -378,6 +378,51 @@ body {
/* (scan active is defined above in .header-scan-btn:active) */
/* ── Offline / server-unreachable banner ──────────────────────────────── */
.offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: #dc2626;
color: #fff;
padding: 9px 16px;
font-size: 0.88rem;
font-weight: 600;
position: sticky;
top: 0;
z-index: 950;
box-shadow: 0 2px 8px rgba(220,38,38,0.35);
}
.offline-banner-icon { font-size: 1rem; line-height: 1; }
.offline-banner-text { flex: 1; text-align: center; }
.offline-banner-retry {
background: rgba(255,255,255,0.22);
border: 1px solid rgba(255,255,255,0.55);
color: #fff;
border-radius: 6px;
padding: 3px 11px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
/* When server is offline, block interactions with the main content */
body.server-offline .app-content {
opacity: 0.4;
pointer-events: none;
user-select: none;
transition: opacity 0.3s;
}
body.server-offline .bottom-nav {
opacity: 0.4;
pointer-events: none;
transition: opacity 0.3s;
}
/* Spesa mode banner */
.spesa-mode-banner {
display: flex;
@@ -1098,6 +1143,11 @@ body {
color: #1d4ed8;
}
.badge-category {
background: #f1f5f9;
color: #475569;
}
.badge-qty {
background: #d1fae5;
color: #047857;
+168 -35
View File
@@ -1215,13 +1215,12 @@ const URGENCY_BG = {
// Map Open Food Facts categories to local categories
function mapToLocalCategory(ofCategory, productName) {
if (!ofCategory) {
// No category tag — try to guess from product name
return guessCategoryFromName(productName || '');
}
const cat = ofCategory.toLowerCase();
// Direct match with our local keys
// Direct match with our local keys — but NOT 'altro': fall through to name guess
for (const key of Object.keys(CATEGORY_ICONS)) {
if (cat === key) return key;
if (cat === key && key !== 'altro') return key;
}
// Handle specific Open Food Facts tags FIRST (before generic regex)
@@ -1233,9 +1232,15 @@ function mapToLocalCategory(ofCategory, productName) {
if (/^en:beverages/.test(cat)) return 'bevande';
// sweeteners = condimenti
if (/sweetener|dolcific/.test(cat)) return 'condimenti';
// food-additives, cooking-helpers, flavourings = condimenti
if (/food-additive|cooking-helper|aromi|flavour/.test(cat)) return 'condimenti';
// breakfasts = cereali
if (/breakfast/.test(cat)) return 'cereali';
// dried-products = conserve
if (/dried-product/.test(cat)) return 'conserve';
// Specific tag patterns
if (/dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte|egg|uova|uovo|poultry-egg/.test(cat)) return 'latticini';
if (/dairi|dairy|lait|cheese|fromage|yoghurt|milk|latticin|latte\b|egg\b|uova\b|uovo\b|poultry-egg/.test(cat)) return 'latticini';
if (/meat|viande|carne|sausage|salum|prosciutt/.test(cat)) return 'carne';
if (/fish|poisson|pesce|seafood|tuna|tonno|salmone/.test(cat)) return 'pesce';
if (/fruit|frutta|juice|succo|apple|banana/.test(cat)) return 'frutta';
@@ -1245,12 +1250,15 @@ function mapToLocalCategory(ofCategory, productName) {
if (/frozen|surgelé|surgel|gelat/.test(cat)) return 'surgelati';
if (/sauce|condiment|oil|olio|vinegar|aceto|mayo|ketchup|spice|salt|sugar|zuccher/.test(cat)) return 'condimenti';
if (/snack|chip|crisp|chocolate|cioccolat|candy|biscuit|cookie|wafer|merendine|patatine/.test(cat)) return 'snack';
if (/conserve|canned|can|pelati|passata|preserve|jam|marmellat|miele|honey/.test(cat)) return 'conserve';
if (/preserve|jam|marmellat|miele|honey|canned|pelati|passata/.test(cat)) return 'conserve';
if (/cereal|muesli|granola|oat|fiocchi/.test(cat)) return 'cereali';
if (/hygiene|soap|shampoo|igien|dentifricio|deodorant/.test(cat)) return 'igiene';
if (/clean|detergent|pulizia|detersiv/.test(cat)) return 'pulizia';
// Beverage check LAST (to avoid false matches on compound tags)
if (/^(?!.*plant-based).*(beverage|drink|boisson|bevand|water|acqua|beer|birra|wine|vino|coffee|caffè|tea\b)/.test(cat)) return 'bevande';
// Last resort: try product name before giving up
const nameGuess = guessCategoryFromName(productName || '');
if (nameGuess !== 'altro') return nameGuess;
return 'altro';
}
@@ -1258,36 +1266,47 @@ function mapToLocalCategory(ofCategory, productName) {
function guessCategoryFromName(name) {
if (!name) return 'altro';
const n = name.toLowerCase();
// ── Known Italian brand names → direct category (fast-path before regex)
// "Uno" only if it starts the name (Bahlsen biscuits, not the Italian word)
if (/^uno\b/.test(n)) return 'snack';
const _brandRx = [
[/\b(baiocchi|macine|tarallucci|tegolini|pavesini|plasmon|loacker|manner|digestive|oreo|hanuta|ringo|abbracci|gocciole|pan di stelle|oro saiwa|kinder|ferrero rocher|raffaello|bounty|twix|snickers|pringles|fonzies|tuc\b|ritz\b|mulino bianco|gran cereale|gocciole|saiwa|togo|principe|oro ciok|kit ?kat)\b/, 'snack'],
[/\b(barilla|de cecco|garofalo|la molisana|rummo|voiello|divella|agnesi|buitoni)\b/, 'pasta'],
[/\b(galbani|granarolo|yomo|danone|muller|müller|pr[eé]sident|santa lucia|jocca|fiorfiore)\b/, 'latticini'],
[/\b(mutti|cirio)\b/, 'conserve'],
[/\b(san pellegrino|levissima|ferrarelle|lete|nestea|lipton|nescaf[eé]|lavazza|illy\b|kimbo|segafredo)\b/, 'bevande'],
];
for (const [rx, cat] of _brandRx) { if (rx.test(n)) return cat; }
// Pasta & Rice
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette/.test(n)) return 'pasta';
if (/spaghetti|penne|fusilli|rigatoni|linguine|orecchiette|farfalle|pasta\b|riso\b|basmati|carnaroli|arborio|gnocchi|lasagne|tagliatelle|maccheroni|bucatini|pennette|sedani|tortiglioni|calamarata|spaghettini|vermicelli/.test(n)) return 'pasta';
// Pane & Forno
if (/pane\b|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini|sandwich|taralli|pancarrè|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b/.test(n)) return 'pane';
if (/pane\b|bauletto|fette biscottate|grissini|cracker|toast|piadina|piadelle|focaccia|panini\b|sandwich|taralli|pancarr[eè]|baguette|ciabatta|rosetta|tramezzino|tortilla|pita\b|pangrattato|pane grattugiato|pan.*carr[eè]/.test(n)) return 'pane';
// Latticini (before bevande to avoid latte→bevande)
if (/latte\b|yogurt|yaourt|formaggio|mozzarella|burro|panna|ricotta|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie|camembert|emmental|asiago|feta\b|provola|caciotta|caprino/.test(n)) return 'latticini';
// Conserve
if (/passata|pelati|pomodoro|pomodori|pomodorini|ciliegino|sugo|polpa di pomod|marmellata|miele|legumi|ceci|fagioli|lenticchie|olive|tonno in scatola|sgombro in scatola|concentrato|brodo|dado|besciamella/.test(n)) return 'conserve';
// Condimenti (include spezie, farine, zucchero)
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina|maionese|ketchup|senape|salsa|paprika|curry|cannella|noce moscata|origano|rosmarino|timo|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido|maizena|semola|pesto|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco/.test(n)) return 'condimenti';
if (/latte\b|yogurt|y[o]?gurt|yaourt|yougurt|yoghurt|formaggio|mozzarella|burro\b|panna\b|ricott|mascarpone|gorgonzola|parmigiano|grana\b|uova\b|uovo\b|egg\b|burrata|scamorza|provolone|pecorino|fontina|taleggio|stracchino|crescenza|brie\b|camembert|emmental|asiago|feta\b|provola|caciotta|caprino|philadelphia|skyr|kefir|labneh/.test(n)) return 'latticini';
// Conserve — controllo tonno\b PRIMA di condimenti (che ha olio\b)
if (/passata|pelati|pomodoro\b|pomodori|pomodorini|ciliegino|sugo\b|polpa di pomod|marmellata|miele\b|zagara|legumi|ceci\b|fagioli\b|lenticchie|olive\b|tonno\b|sgombro in scatola|concentrato|brodo\b|dado\b|besciamella|datterini|passato di/.test(n)) return 'conserve';
// Condimenti (include spezie, farine, zucchero, aromi, lieviti)
if (/olio\b|aceto|sale\b|pepe\b|zucchero|zuccher|farina\b|maionese|ketchup|senape|salsa\b|paprika|curry\b|cannella|noce moscata|origano|rosmarino|timo\b|basilico|prezzemolo|curcuma|cumino|cardamomo|vaniglia|lievito|bicarbonato|amido\b|maizena|semola|pesto\b|tahini|miso\b|colatura|soia.*salsa|worcester|tabasco|aroma\b|aromi\b|arome\b|estratto.*vaniglia|estratto.*limone|polenta\b|semolino\b|cacao amaro|cacao.*polvere|purea|pure\b|pur[ée]e/.test(n)) return 'condimenti';
// Bevande (after latticini to avoid latte conflict)
if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caffè|tè\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino/.test(n)) return 'bevande';
if (/acqua\b|birra\b|vino\b|succo|spremuta|coca.cola|aranciata|caff[eè]\b|kaffee|kafè|t[eè]\b|tea\b|tisana|camomilla|infuso|energy drink|bevanda|limonata|aranciate|sprite|pepsi|fanta|san pellegrino|ciobar|ovomaltine|zuppalatte|cioccolata.*calda|latte.*cioccolato/.test(n)) return 'bevande';
// Carne (include salumi)
if (/pollo|manzo|maiale|vitello|tacchino|prosciutto|salame|bresaola|mortadella|wurstel|speck|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola/.test(n)) return 'carne';
if (/pollo\b|manzo|maiale|vitello|tacchino|prosciutto|salame\b|bresaola|mortadella|wurstel|speck\b|pancetta|nduja|guanciale|cotechino|salsiccia|agnello|cinghiale|polpette|arrosto|bistecca|cotoletta|lonza|braciola|schinken|scamorza affumicat|spianata/.test(n)) return 'carne';
// Pesce
if (/tonno|salmone|merluzzo|pesce|sgombro|gamberi|acciughe|baccalà|vongole|cozze|calamari|surimi|alici|branzino|orata|sardine|trota|dentice|seppia|polpo/.test(n)) return 'pesce';
if (/tonno\b|salmone|merluzzo|pesce\b|sgombro\b|gamberi|acciughe|baccal[aà]|vongole|cozze|calamari|surimi|alici|branzino|orata\b|sardine|trota|dentice|seppia|polpo|filetto.*pesce|pesce.*filetto/.test(n)) return 'pesce';
// Frutta
if (/mela|mele|banana|arancia|pera|fragola|uva\b|kiwi|limone|frutta|mandarino|clementina|pompelmo|avocado|mango|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|fico\b|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta';
if (/mela\b|mele\b|banana|arancia|pera\b|fragola|uva\b|kiwi\b|limone|frutta\b|mandarino|clementina|pompelmo|avocado|mango\b|ananas|melone|anguria|susina|prugna|ciliegia|albicocca|pesca\b|nettarina|fico\b|melograno|papaya|maracuja|cocco\b|dattero|lampone|mirtillo|ribes|more\b/.test(n)) return 'frutta';
// Verdura
if (/insalata|zucchina|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais|zucca|aglio|cetriolo|rapa|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura';
if (/insalata|zucchina|zucchine|pomodor|cipolla|carota|spinaci|rucola|peperoni|melanzane|broccoli|patata|finocchio|sedano|porro|scalogno|cavolo|cavolfiore|asparagi|funghi|courgette|lattuga|bietola|radicchio|carciofo|fagiolini|piselli|mais\b|zucca\b|aglio\b|cetriolo|rapa\b|barbabietola|cime di rapa|pak choi|bok choy|verza|cavolo nero/.test(n)) return 'verdura';
// Surgelati
if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato/.test(n)) return 'surgelati';
if (/surgelat|frozen|findus|4.salti|gelato|minestrone surgelato|potato wedge|potato.*wedge/.test(n)) return 'surgelati';
// Snack & Dolci
if (/biscott|cioccolat|nutella|merendine|patatine|caramelle|wafer|sfornatini|torta|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramisù/.test(n)) return 'snack';
if (/biscott|cioccolat|nutella|merendine\b|merendina|patatine|caramelle|wafer|cialda|cialdine|sfornatini|torta\b|pandoro|panettone|colomba|cornetto|brioche|croissant|dolc|dessert|tiramis[uù]|cantucci|amaretti|savoiardi|pralin|confetti dolci|chicchi.*cacao|cacao.*chicchi|risofrolle|sfogliatine|ossi di morto|canestrelli|snack/.test(n)) return 'snack';
// Cereali
if (/cereali|muesli|fiocchi|granola|polenta|porridge|avena/.test(n)) return 'cereali';
if (/cereali|muesli|fiocchi|granola|porridge|avena|mix energia|misto cereal|farro\b|orzo\b|quinoa/.test(n)) return 'cereali';
// Igiene personale
if (/sapone|shampoo|dentifricio|deodorante|carta igienica|fazzoletti|cotton fioc|assorbente|rasoio|schiuma da barba|gel doccia|balsamo\b|lozione/.test(n)) return 'igiene';
// Pulizia casa
if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
if (/detersivo|pulito|sgrassatore|candeggina|ammorbidente|anticalcare|bucato|piatti\b|lavatrice|lavastoviglie|detergente/.test(n)) return 'pulizia';
return 'altro';
}
@@ -3492,6 +3511,7 @@ function _dismissNoExpiry(productId) {
// === ALERT BANNER SYSTEM (replaces old review table) ===
let _bannerQueue = []; // array of { type, data } — 'review' or 'prediction'
let _bannerIndex = 0;
let _bannerLoading = false; // guard against concurrent calls
let _bannerEditPending = false; // true when editing from banner → dismiss after save
let _bannerRefreshTimer = null; // periodic refresh while on dashboard
let _shoppingPollTimer = null; // periodic refresh while on shopping page (multi-client sync)
@@ -3501,10 +3521,12 @@ let _shoppingPollTimer = null; // periodic refresh while on shopping page (mul
* merge into a single banner queue and show the first item.
*/
async function loadBannerAlerts() {
if (_bannerLoading) return;
_bannerLoading = true;
_bannerQueue = [];
_bannerIndex = 0;
const banner = document.getElementById('alert-banner');
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
if (!banner) { _bannerLoading = false; console.warn('[Banner] #alert-banner not found'); return; }
try {
const [invData, predData, anomalyData, finishedData] = await Promise.all([
@@ -3515,6 +3537,8 @@ async function loadBannerAlerts() {
]);
const items = invData.inventory || [];
const confirmed = getReviewConfirmed();
// Track item IDs already queued to prevent the same item appearing in multiple types
const _queuedItemIds = new Set();
// 1. Expired products (highest priority) - derived from inventory
// Also considers opened_at: if item is opened and its opened-shelf-life has passed, it's expired too
@@ -3547,6 +3571,7 @@ async function loadBannerAlerts() {
// Skip items the freezer bonus still considers safe — no need to alarm the user
if (getExpiredSafety(item, daysExpired).level === 'ok') return;
_bannerQueue.push({ type: 'expired', data: { ...item, days_expired: daysExpired } });
_queuedItemIds.add(item.id);
});
// 2. Suspicious quantities ("expiring soon" shown only in dashboard sections, not in banner)
@@ -3562,6 +3587,7 @@ async function loadBannerAlerts() {
});
items.forEach(item => {
if (_queuedItemIds.has(item.id)) return; // already in expired
if (confirmed[item.id]) return;
const t_ = QTY_THRESHOLDS[item.unit] || QTY_THRESHOLDS['pz'];
const qty = parseFloat(item.quantity);
@@ -3600,6 +3626,7 @@ async function loadBannerAlerts() {
else if (isLow) warning = '⬇️ Troppo poco';
else warning = '⬆️ Troppo';
_bannerQueue.push({ type: 'review', data: { ...item, warning, _isLow: isLow } });
_queuedItemIds.add(item.id);
});
// 4. Consumption predictions that don't match actual quantity
@@ -3628,6 +3655,7 @@ async function loadBannerAlerts() {
const PERISHABLE_CATS = ['latticini','carne','pesce','salumi','fresco','verdura','frutta','surgelati',
'dairy','meat','fish','fresh','vegetables','fruit','frozen'];
items.forEach(item => {
if (_queuedItemIds.has(item.id)) return; // already in expired or review
if (item.expiry_date) return; // already has expiry
if (parseFloat(item.quantity) <= 0) return; // no stock
const pid = String(item.product_id || item.id);
@@ -3651,6 +3679,8 @@ async function loadBannerAlerts() {
} catch (e) {
console.error('[Banner] loadBannerAlerts error:', e);
} finally {
_bannerLoading = false;
}
if (_bannerQueue.length > 0) {
@@ -4370,7 +4400,10 @@ async function loadInventory() {
}
function renderInventoryItem(item) {
const catIcon = CATEGORY_ICONS[mapToLocalCategory(item.category, item.name)] || '📦';
const catKey = mapToLocalCategory(item.category, item.name);
const catIcon = CATEGORY_ICONS[catKey] || '📦';
const catLabel = t('categories.' + catKey) || catKey;
const catBadge = `<span class="inv-badge badge-category" data-cat="${catKey}" data-itemname="${escapeHtml(item.name)}">${catIcon} ${catLabel}</span>`;
const locInfo = LOCATIONS[item.location] || { icon: '📦', label: item.location };
const days = daysUntilExpiry(item.expiry_date);
const isExpired = days < 0;
@@ -4401,6 +4434,7 @@ function renderInventoryItem(item) {
${item.brand ? `<div class="inv-brand">${escapeHtml(item.brand)}</div>` : ''}
<div class="inv-meta">
<span class="inv-badge badge-location">${locInfo.icon} ${locInfo.label}</span>
${catBadge}
${expiryBadge}
${openedBadge}
${vacuumBadge}
@@ -4421,6 +4455,28 @@ function renderInventory(items) {
return;
}
container.innerHTML = renderGroupedByCategory(items, false);
_refineCategoryBadgesAsync();
}
/**
* After rendering, find all badges still showing 'altro' and ask the server
* (Gemini-backed, cached) for a better category. Updates the DOM in place.
*/
async function _refineCategoryBadgesAsync() {
if (!_geminiAvailable) return; // AI not available — keep 'altro' label
const badges = Array.from(document.querySelectorAll('.badge-category[data-cat="altro"]'));
for (const badge of badges) {
const name = badge.dataset.itemname;
if (!name) continue;
try {
const res = await api('guess_category', { name });
const cat = res.category;
if (cat && cat !== 'altro') {
badge.dataset.cat = cat;
badge.textContent = (CATEGORY_ICONS[cat] || '📦') + ' ' + (t('categories.' + cat) || cat);
}
} catch (_) { /* network error — leave as 'altro' */ }
}
}
function filterLocation(loc) {
@@ -4440,11 +4496,21 @@ function filterInventory() {
return;
}
if (qas) qas.style.display = 'none';
const filtered = currentInventory.filter(i =>
i.name.toLowerCase().includes(q) ||
(i.brand && i.brand.toLowerCase().includes(q)) ||
(i.barcode && i.barcode.includes(q))
);
// Category inferred from the search term itself (e.g. "biscotti" → "snack")
const queryCat = guessCategoryFromName(q);
const filtered = currentInventory.filter(i => {
if (i.name.toLowerCase().includes(q)) return true;
if (i.brand && i.brand.toLowerCase().includes(q)) return true;
if (i.barcode && i.barcode.includes(q)) return true;
const itemCat = mapToLocalCategory(i.category, i.name);
// Match category key directly (e.g. "snack", "latticini")
if (itemCat.includes(q)) return true;
// Match category label (e.g. "dolci" matches "Snack & Dolci", "riso" matches "Pasta & Riso")
if ((CATEGORY_LABELS[itemCat] || '').toLowerCase().includes(q)) return true;
// Match by inferred category: "biscotti" → queryCat="snack" → all snack items
if (queryCat !== 'altro' && itemCat === queryCat) return true;
return false;
});
renderInventory(filtered);
}
@@ -4572,6 +4638,7 @@ function showItemDetail(inventoryId, productId) {
<div class="modal-actions">
<button class="btn btn-danger flex-1" onclick="quickUse(${item.product_id}, '${item.location}')">📤 Usa</button>
<button class="btn btn-primary flex-1" onclick="editInventoryItem(${inventoryId})"> Modifica</button>
<button class="btn btn-accent flex-1" onclick="closeModal();generateRecipeForIngredient(${JSON.stringify(item.name)})">🍳 Ricetta</button>
<button class="btn btn-secondary" onclick="deleteInventoryItem(${inventoryId})" style="padding:12px">🗑</button>
</div>
`;
@@ -7810,12 +7877,12 @@ function showMoveAfterUseModal(product, fromLoc, remaining, openedId, openedVacu
// Show vacuum checkbox for any container-type unit or if the item was previously vacuum sealed.
// Pre-checked when it was already sealed (semi-automatic: if you sealed it last time, you likely will again).
const wasVacuum = !!(openedVacuumSealed ?? product.vacuum_sealed);
const isContainer = ['conf','g','kg','ml','l'].includes(unit || product.unit || '') || wasVacuum;
const vacuumRow = isContainer ? `
// Always offer vacuum sealing: any leftover food can be vacuum sealed regardless of unit type.
const vacuumRow = `
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
<span>🔒 Metti <b>sotto vuoto</b> il resto${wasVacuum ? ' (era già sigillato)' : ''}</span>
</label>` : '';
</label>`;
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('move.title')}</h3>
@@ -9048,6 +9115,12 @@ async function fetchAllPrices(forceRefresh = false) {
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
return;
}
if (!_geminiAvailable) {
// AI not configured — prices cannot be estimated without Gemini
if (fetchBtn) fetchBtn.disabled = false;
if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.textContent = '🔄'; }
return;
}
_pricesFetching = true;
@@ -11422,11 +11495,11 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
const locButtons = otherLocs.map(([k, v]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmRecipeMove(${productId}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
).join('');
const vacuumRow = wasVacuum ? `
const vacuumRow = `
<label style="display:flex;align-items:center;gap:8px;margin-top:12px;cursor:pointer">
<input type="checkbox" id="move-vacuum-check" checked>
<span>${t('move.vacuum_restore')}</span>
</label>` : '';
<input type="checkbox" id="move-vacuum-check" ${wasVacuum ? 'checked' : ''}>
<span>${wasVacuum ? t('move.vacuum_restore') : '🔒 Metti <b>sotto vuoto</b> il resto'}</span>
</label>`;
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('move.title')}</h3>
@@ -13598,6 +13671,65 @@ async function _finishSetup() {
document.getElementById('setup-wizard').style.display = 'none';
}
// ===== SERVER HEARTBEAT =====
// Polls the lightweight ?action=ping endpoint every 20 s (online) / 5 s (offline).
// When the server is unreachable: shows the #offline-banner, blocks the UI via
// body.server-offline, and retries faster until the server responds again.
let _serverOffline = false;
let _heartbeatTimer = null;
const _HB_INTERVAL_ONLINE = 20_000; // ms — normal polling interval
const _HB_INTERVAL_OFFLINE = 5_000; // ms — faster retry when unreachable
async function _runHeartbeat() {
const ac = new AbortController();
const tid = setTimeout(() => ac.abort(), 7000); // 7 s hard timeout
try {
const res = await fetch(`${API_BASE}?action=ping`, {
cache: 'no-store',
signal: ac.signal,
});
clearTimeout(tid);
_setServerOffline(!res.ok);
} catch (e) {
clearTimeout(tid);
_setServerOffline(true);
}
}
function _setServerOffline(offline) {
if (offline === _serverOffline) {
// State unchanged — reschedule at the appropriate interval and return
_heartbeatTimer = setTimeout(_runHeartbeat,
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
return;
}
_serverOffline = offline;
document.body.classList.toggle('server-offline', offline);
const banner = document.getElementById('offline-banner');
if (banner) banner.style.display = offline ? '' : 'none';
if (offline) {
showToast(t('error.server_offline'), 'error');
} else {
showToast(t('error.server_restored'), 'success');
// Refresh the current page since updates may have been missed
refreshCurrentPage();
}
_heartbeatTimer = setTimeout(_runHeartbeat,
offline ? _HB_INTERVAL_OFFLINE : _HB_INTERVAL_ONLINE);
}
/** Called by the banner "Retry" button to trigger an immediate check. */
function _heartbeatRetry() {
clearTimeout(_heartbeatTimer);
_runHeartbeat();
}
/** Start the heartbeat loop (called once from _initApp). */
function startHeartbeat() {
_runHeartbeat(); // immediate first probe
}
async function _initApp() {
// Check for setup wizard resume (after language change)
const resumeStep = localStorage.getItem('evershelf_setup_step');
@@ -13647,6 +13779,7 @@ async function _initApp() {
initSpesaMode();
initScreensaverShortcuts();
startBgShoppingRefresh();
startHeartbeat();
_injectKioskOverlay(); // kiosk X / refresh buttons (only when running inside Android WebView)
// Hide preloader once the dashboard is rendered
+10 -3
View File
@@ -11,7 +11,7 @@
<title>EverShelf</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/png" href="assets/img/logo/logo_icon.png">
<link rel="stylesheet" href="assets/css/style.css?v=20260508c">
<link rel="stylesheet" href="assets/css/style.css?v=20260511a">
<!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
<!-- @xenova/transformers: ES-module bootstrap that exposes a lazy category-classifier as window._categoryPipelinePromise -->
@@ -67,7 +67,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.6</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.9</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -94,6 +94,13 @@
</div>
</header>
<!-- Offline / server-unreachable banner -->
<div id="offline-banner" class="offline-banner" style="display:none" role="alert" aria-live="assertive">
<span class="offline-banner-icon" aria-hidden="true">🔌</span>
<span class="offline-banner-text" data-i18n="error.server_offline">Connessione al server persa</span>
<button class="offline-banner-retry" onclick="_heartbeatRetry()" data-i18n="error.server_retry">Riprova</button>
</div>
<!-- Main Content Area -->
<main class="app-content" id="app-content">
@@ -1462,6 +1469,6 @@
</div>
</div>
<script src="assets/js/app.js?v=20260510k"></script>
<script src="assets/js/app.js?v=20260511a"></script>
</body>
</html>
+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.8",
"version": "1.7.9",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
+4 -1
View File
@@ -775,7 +775,10 @@
"appliance_exists": "Gerät bereits vorhanden",
"already_exists": "Bereits vorhanden",
"network_retry": "Verbindungsfehler. Erneut versuchen.",
"select_items": "Wähle mindestens ein Produkt aus"
"select_items": "Wähle mindestens ein Produkt aus",
"server_offline": "Serververbindung unterbrochen",
"server_restored": "Serververbindung wiederhergestellt",
"server_retry": "Erneut versuchen"
},
"confirm": {
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
+4 -1
View File
@@ -775,7 +775,10 @@
"appliance_exists": "Appliance already exists",
"already_exists": "Already exists",
"network_retry": "Connection error. Try again.",
"select_items": "Select at least one product"
"select_items": "Select at least one product",
"server_offline": "Server connection lost",
"server_restored": "Server connection restored",
"server_retry": "Retry"
},
"confirm": {
"remove_item": "Do you really want to remove this product from inventory?",
+4 -1
View File
@@ -775,7 +775,10 @@
"appliance_exists": "Elettrodomestico già presente",
"already_exists": "Già presente",
"network_retry": "Errore di connessione. Riprova.",
"select_items": "Seleziona almeno un prodotto"
"select_items": "Seleziona almeno un prodotto",
"server_offline": "Connessione al server persa",
"server_restored": "Connessione al server ripristinata",
"server_retry": "Riprova"
},
"confirm": {
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",