When inventory item has opened_at set, the expired banner now shows:
- Title: '[Nome] — Aperto da troppo tempo!' (instead of '— Scaduto!')
- Detail: 'Aperto da N giorni in [icon] [location] · hai ancora X'
Also removed hardcoded Italian 'scade il' string from non-opened expired detail.
Fresh milk is explicitly matched by 'latte fresco/intero/parzial/scremato' (3 days).
Generic 'Latte' without qualifier is almost always UHT in Italian households — 7 days.
- Category badge on every inventory item (icon + label); 'altro' items
refined asynchronously via new guess_category Gemini endpoint
(data/category_ai_cache.json) — no AI call when key not configured
- Category search: inventory search now matches by macro-category key
and translated label (e.g. 'biscotti' finds all cookie items)
- Brand fast-path in guessCategoryFromName (Oreo, Barilla, Lavazza…)
- Fix: duplicate banner alerts — _bannerLoading guard + _queuedItemIds Set
- Fix: mapToLocalCategory with en:dairies (dairi stem added)
- Fix: mapToLocalCategory no longer blocks on 'altro' — falls back to
guessCategoryFromName(productName) before returning 'altro'
- Fix: 'Tonno all'olio' was resolving to condimenti — moved tonno\b
check before olio\b in conserve regex block
- AI guards: _refineCategoryBadgesAsync and fetchAllPrices now check
_geminiAvailable (JS); getShoppingPrice returns no_api_key (PHP)
when GEMINI_API_KEY is not set — all AI functions are now explicit
- Recipe use modal: reset _scaleLastConfirmedGrams al peso attuale prima
di aprire il modale, così la tara ha tempo; soglia ridotta 10→5g
- PHP useFromInventory: prima di auto-aggiungere a Bring! un prodotto esaurito,
controlla se la famiglia shopping_name ha scorte da altri prodotti (es.
'Sale marino iodato' esaurito ma 3kg di altri sali in dispensa → non aggiunge)
JS, così il cron bringCleanupObsolete può auto-rimuovere
- Rimosso manualmente 'Sale' da Bring! (aggiunto senza marker dalla vecchia logica)
- Bottone 'Apri la ricetta': il transfer btn si trasforma direttamente in
'📖 Apri la ricetta' dopo il successo (invece di aggiungere un elemento DOM separato)
- meal null: chatToRecipe e recipe_from_ingredient non auto-categorizzano il pasto;
renderRecipe mostra il tag meal solo se presente
- Nuovo endpoint recipe_from_ingredient: genera una ricetta con l'ingrediente
selezionato come protagonista, stessa pipeline di chatToRecipe (Gemini + fuzzy-match)
- Bottone '👨🍳 Crea una ricetta con questo' nel pannello azione degli alimenti
(span-2 sotto la griglia 2x2), apre overlay Ricette in loading state
Fixes parse_error on complex recipes (JSON was truncated at 2048 tokens).
After successful transfer, shows 'Apri la ricetta' button inline in chat
alongside the '✅ Aggiunta alle Ricette!' button.
Closes#27
- Sostituisce 'Usa ingredienti' inline con 'Trasferisci a Ricette'
- Nuovo endpoint chat_to_recipe: Gemini restituisce JSON completo
(title, meal, servings, ingredients, steps, nutrition_note),
PHP arricchisce tutti gli ingredienti con product_id/location
via fuzzy-match identico a generateRecipe
- La ricetta viene salvata in archivio e si apre nell'overlay Ricette
con tutti i pulsanti Usa, modalità cottura, salvataggio intatto
- Rimossi: chatExtractIngredients, _buildChatIngredientPanelHTML,
_chatRecipeTitle, chat_extract_recipe, chat-recipe-panel CSS
- Nuovo endpoint chat_extract_recipe: Gemini estrae solo nomi+quantità
con prompt minimo (nessun inventario nel prompt → niente troncamento),
poi PHP fuzzy-match contro l'inventario completo identico a generateRecipe
- Frontend: _looksLikeRecipe() rileva risposte chat con ricetta;
bottone '🥄 Usa ingredienti' appare sotto la bubble, chiama chatExtractIngredients()
che mostra pannello inline con pulsanti '📦 Usa' per ogni ingrediente in dispensa
- useRecipeIngredient() riusato 1:1 con fallback _chatRecipeTitle per le note
- Stili CSS: btn-chat-use-recipe, chat-recipe-panel, chat-recipe-panel-container
- Chiavi i18n: use_ingredients_btn, recipe_ingredients_from_pantry (it/en/de)
Instead of a separate floating prompt after use, the vacuum sealed checkbox
is now shown directly inside the 'where to put the rest?' modal:
- Always shown for container-type units (conf/g/kg/ml/l) or if previously sealed
- Pre-checked when the item was already vacuum sealed (semi-automatic)
- Saving on 'rimani qui' button also persists the vacuum state
- Saves one step: user answers location + vacuum in a single interaction
- AI prompt: always return a best-guess price (never null/price_not_found)
for unrecognised items returns generic package estimate with '~' prefix
- Cache key bumped to v3 to invalidate old null-returning cache entries
- JS: manually-added items (no smart match, no spec) default to qty=1/conf
instead of qty=1/pz so _calcEstimatedTotal treats them as a single pack
- Price badge: shows '~€X.XX' prefix when source_note starts with '~'
so user knows the price is a rough estimate
- Add get_shopping_price / get_all_shopping_prices API endpoints
- AI (Gemini) estimates retail price per natural unit (pack, piece, bunch)
instead of always per-kg — avoids absurd totals like €1609
- _calcEstimatedTotal: proper g/ml→package conversion using defQty + regex
on unit_label; only 'kg'/'l' labels trigger weight/volume math
- Cache key bumped to v2 to invalidate old per-kg cached entries
- Suggested quantity cap lowered from 20 to 10 conf/pz
- Unit mismatch guard: if totalUsed >> buyCount*5 for unit=conf, use
purchase frequency instead of raw consumption rate
- JS _buildPricePayload: use smartShoppingItems for qty/unit (not Bring! spec)
- JS _cachedPrices: persist in sessionStorage (survives navigation);
validated by _qty/_unit metadata so stale totals auto-invalidate
- Price display redesigned: right-side column per row (price-col-main +
price-col-unit) instead of small inline badge
- fetchAllPrices: buttons disabled immediately before guard check;
running total uses only current shoppingItems (not Object.values cache)
- Background refresh: always silent (removed 90s interaction condition)
- visibilitychange: sets _bgCall=true for shopping before refreshCurrentPage
- .gitignore: add runtime data files (bring_migrate_ts, shopping_price_cache,
anomaly_dismissed, opened_shelf_cache, shopping_name_cache)
- Remove bring_catalog.json and bring_migrate_ts.json from tracking
style.css:
- Connected dot: white fill + green border/glow (was green-on-green, invisible)
app.js:
- _kioskReconfigureScale(): show #kiosk-needs-update-notice + toast when
kiosk APK is too old and reconfigureScale() method is missing
- _scaleUpdateStatus(): show/hide #scale-live-diag panel, update device + battery
- _scaleOnMessage weight: update #scale-diag-weight in real time
- _scaleOnMessage status: update #scale-diag-proto with BLE protocol
index.html:
- #kiosk-needs-update-notice: amber warning + download link inside kiosk panel
- #scale-live-diag: device name, battery, live weight readout, reconnect info
- CSS cache bump ?v=20260506d
style.css:
- Scale connected dot: bright #4ade80 fill + white border + double shadow
so it pops on the dark green header (was white on green = invisible)
index.html:
- Scale settings tab: add kiosk panel with 'Riconfigura bilancia BLE'
button (hidden in browser, shown in kiosk mode)
- Wrap gateway download section and WebSocket URL section with IDs
so JS can hide them in kiosk mode
- CSS cache bust ?v=20260506b
app.js:
- syncSettingsFromDB: in kiosk mode hide scale gateway download section,
WebSocket URL section and test button; show kiosk BLE panel instead;
auto-set URL to ws://localhost:8765
- Add _kioskReconfigureScale() helper that calls _kioskBridge.reconfigureScale()
KioskActivity.kt:
- Add reconfigureScale() @JavascriptInterface: stops GatewayService,
clears saved scale device prefs, launches SetupActivity at step 4
- Import GatewayService
app.js:
- _injectKioskOverlay: move kiosk-mode class assignment BEFORE the
_kiosk_overlay existence guard — fixes race where Kotlin onPageFinished
injects buttons during the await api() pause, then JS skips the class
- _PREF_LOC_NEEDED: 3 → 2 (remember use-location after 2 picks, not 3)
KioskActivity.kt:
- showNativeUpdateBanner: remove auto-start of triggerApkDownload;
banner now shows with 'Scarica' button enabled, download only starts
when user taps it (confirms before install)
initInactivityWatcher() was called once at startup and returned early if
screensaver was disabled at that moment. Enabling it later from the
settings panel had no effect until page reload.
- Make initInactivityWatcher() idempotent: attach DOM listeners only once
(flag _inactivityListenersAttached), check screensaver_enabled dynamically
inside each handler so disabling/enabling is reflected immediately
- Call initInactivityWatcher() at end of saveSettings() so the inactivity
timer starts immediately when user enables screensaver
- .env: split 'Bimby/Moulinex Cookeo' into 'Bimby' and 'Moulinex Cookeo'
- translations (it/en/de): add use_all_confirm_*, throw_all_confirm_*,
confirm.cancel, confirm.proceed, location.dispensa/frigo/freezer
- app.js: when ANY opened package exists, always show disambiguation
dialog ('solo questa confezione?' vs 'tutto?') instead of silently
finishing one — was previously showing dialog only for 2+ opened packages
- app.js: move initInactivityWatcher() inside syncSettingsFromDB().then()
so it reads screensaver_enabled after server sync, not stale localStorage
- app.js: skip gemini_key/bring_password in save_settings POST when empty
to avoid overwriting server .env with blank values
- api/index.php: add screensaver_enabled to getServerSettings() + saveSettings()