- 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
- 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()
SetupActivity was sending type 'install-failure' (hyphen) but the PHP
version-guard bypass list only checked for 'install_failure' (underscore).
Result: if the kiosk was not on the latest released version the error was
silently discarded and no GitHub issue was created.
Fix:
- SetupActivity: change type to 'install_failure' (underscore, consistent
with KioskActivity which already used the correct name)
- api/index.php: add 'install-failure' (hyphen) to the bypass list as
defensive fallback so old APK builds already in the field are covered too
Header layout:
- Redesign header-content as 3-column flex (left / center / right)
- Add #header-left div: dedicated slot for kiosk buttons (empty by default)
- header-title: flex-shrink auto, no more position:absolute centering hack
- header-actions: flex:1 1 0 + justify-content:flex-end (right)
- header-left: flex:1 1 0 (left) — equal width balances the title visually
Kiosk exit/refresh buttons:
- _injectKioskOverlay() now appends to #header-left instead of
insertBefore(firstChild) — buttons appear on LEFT, not mixed with center
DB migration:
- Add shopping_name TEXT DEFAULT '' to CREATE TABLE products schema
- Add ALTER TABLE migration in migrateDB() for existing databases
- Avoids repeated ALTER TABLE in seed code on every request
Demo mode UI:
- _applyDemoModeUI(): hides ⚙️ settings nav button in demo mode
- Suppresses first-run setup wizard when _demoMode === true
- Shows a small DEMO badge in header-left
- Called from both syncSettingsFromDB() and _initApp()
1. Remove raw API key from get_settings response
- getServerSettings() no longer returns gemini_key in plain text
- Only gemini_key_set (boolean) and settings_token_set (boolean)
- JS updated to only check gemini_key_set (removes stale gemini_key fallback)
2. Protect save_settings with SETTINGS_TOKEN
- If SETTINGS_TOKEN is set in .env, all save_settings calls must
include matching X-Settings-Token header (uses hash_equals)
- Empty token = no protection (backwards-compatible default)
- Settings UI (Security tab) has a token input field
- Wrong/missing token returns HTTP 403 with error 'unauthorized'
- JS shows '🔒 Token non valido o mancante' on 403
3. DEMO_MODE native blocking in PHP
- DEMO_MODE=false added to .env (default off)
- When DEMO_MODE=true, all write actions return HTTP 403 before routing
- Blocked: save_settings, product_save/delete/merge, inventory_add/use/update/remove,
dismiss_anomaly, bring_add/remove/sync
- demo_mode flag exposed via get_settings so JS can adapt UI
Feature 1: AI product storage/shelf-life hint
- New API: gemini_product_hint → {location, expiry_days, reason}
- After opening the add form, Gemini suggests optimal storage and expiry
- Shown inline next to expiry estimate as a subtle AI badge with tooltip
- Also updates location buttons if AI suggests a different location
- Cached permanently in food_facts_cache.json (per name+lang)
Feature 2: AI-enriched shopping suggestions
- New API: gemini_shopping_enrich → adds tip field to each suggestion
- After bring_suggest renders, Gemini adds practical buying/storing tips
- Tips shown inline under each suggestion item in indigo italic text
- Cached per item list + lang in food_facts_cache.json
Feature 3: AI anomaly explanation
- New API: gemini_anomaly_explain → plain-language explanation
- '🤖 Spiega' button added to anomaly banners (when Gemini available)
- Explains in 2-3 conversational sentences why the discrepancy likely happened
- Replaces technical banner detail text with friendly explanation
- No caching (anomaly context is always specific)
bringAddItems() used $input and $items without ever decoding the request
body. $items was undefined (null) so the foreach never ran, every call
returned added=0 skipped=0 regardless of what was sent.
Added:
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$items = $input['items'] ?? [];
Also added the missing $auth guard (consistent with all other Bring functions).
Bug 1 — Root cause of PackageInstaller STATUS_FAILURE=1:
The dest file is always named 'evershelf-update.apk'. installApk()
was trying to detect 'gateway' in the filename — always false.
So setAppPackageName() was always passed 'it.dadaloop.evershelf.kiosk'
even when installing the gateway APK (package scalegate).
PackageInstaller rejects the mismatch with STATUS_FAILURE=1.
Fix: save apkUrl into pendingApkDownloadUrl at the TOP of
triggerApkDownload() (not only in the permission branch), then derive
targetPkg from the URL (which does contain 'gateway'/'scale') instead
of the filename.
Bug 2 — Install errors not reaching GitHub Issues:
PHP reportError() has a version guard: if the client version is not
the latest release, it silently skips GitHub issue creation.
A device that is FAILING TO INSTALL an update is by definition on an
old version, so every install error was silently dropped.
Fix: bypass the version guard for types install_download_failed,
install_failure, install_packager_exception.
- Add full-screen CSS preloader to webapp (fades out when _initApp completes)
- Defer _checkWebappUpdate() to 6s after app init so it does not compete
with startup API calls (fixes perceived slowness on first load)
- Switch update-check throttle from sessionStorage to localStorage (6h TTL);
use release published_at instead of version string for comparison, so the
banner correctly appears when a new release is published regardless of whether
the tag is a semver or the rolling "latest" tag
- PHP _isLatestVersion(): return true (do not suppress error reports) when
tag_name is non-semver (e.g. "latest") — was incorrectly blocking ALL reports
- Kiosk checkForUpdates(): show banner only when the release asset actually
contains an APK for the component; handle non-semver tag by treating it
as always-update (prevents silent no-op with rolling "latest" tag)
- Scale gateway checkForUpdates(): same non-semver fix; apkUrl now defaults
to empty and bails out if no matching APK found in assets (prevents 404 install)
in both PHP (api/index.php) and Scale Gateway (ErrorReporter.kt)
- Add _isLatestVersion() / _latestReleaseTag() / _appVersion() helpers in PHP;
skip GitHub issue creation if caller is not on the latest released version
- Add checkUpdate() PHP endpoint (GET api/?action=check_update, no auth required)
- Webapp (app.js): fetch check_update on load, show dismissible amber top-banner
when a newer GitHub release is available; auto-dismiss after 20 s
- Kiosk (KioskActivity.kt + activity_kiosk.xml): replace old JS bottom-banner with
native Android top-banner; real APK download via DownloadManager + PackageInstaller
- Scale Gateway (MainActivity.kt + activity_main.xml): same native top-banner
with checkForUpdates() / showNativeUpdateBanner() / triggerApkDownload() / installApk()
- PHP (api/index.php): hardcode GH_ISSUE_TOKEN/GH_REPO constants at top of
file (before exception handler runs); fix $fp_ variable interpolation bug;
global set_exception_handler + register_shutdown_function; reportError()
endpoint (POST ?action=report_error) with rate limiting, local log, dedup
via fingerprint search on GitHub Issues API
- Kiosk (ErrorReporter.kt): add crash persistence – saves crash payload to
SharedPreferences before network POST, clears on success, retries as
'uncaught-exception-survived' on next launch via sendPendingCrash() in init()
- Scale Gateway: new ErrorReporter.kt – calls GitHub Issues API directly
(no relay needed, token hardcoded, scoped Issues R+W only); crash
persistence via SharedPreferences; MainActivity.kt hooked at onCreate,
startGatewayServer catch, onError (BLE errors)
Tested end-to-end: issues #3-#6 created and closed during QA.
- Replace simple bar chart with full Anti-Waste Report Card
- Grade system (A+ to D) based on user's waste rate
- Dual comparison bars: user waste rate vs national average (IT/DE/US)
- Estimated monthly savings in money, meals saved, CO2 avoided
- 3-month trend mini chart with colour-coded bars
- Backend: getStats() now returns 3×30d buckets (used_30d, used_prev_30d, used_prev_60d, etc.)
- Real-world benchmarks: IT 22%/5.4kg/mo (REDUCE), DE 20%/6.5kg/mo (Eurostat), US 30%/9.2kg/mo (USDA)
- All labels fully i18n: 18 new antiwaste.* keys in it/en/de translation files
- Section is fully JS-rendered; HTML now just an empty container
- Banner: detect expired opened-products via effective shelf-life (opened_at +
estimateOpenedExpiryDays), not just raw expiry_date — fixes Fagioli/Panna case
- Banner: expired items show safety tip inline; danger-level items (fridge dairy,
meat, fish) get red banner + 'L'ho buttato' as primary button, 'Usa comunque'
demoted to grey; safety-ok/warning items keep original button order
- Banner: anomaly dismiss button now shows current inventory qty ('La quantità è
giusta (2 pz)') so the action is unambiguous
- AI: add callGeminiWithFallback() helper — tries gemini-2.5-flash first (separate
quota), falls back to gemini-2.0-flash; applied to all endpoints (expiry, chat,
identify, recipe non-streaming, shopping name classifier)
- AI: show friendly 'Quota AI esaurita' message instead of raw Gemini error string
- Cooking TTS: fix auto-speak broken since 'auto-speak removed' comment — each step
is now read automatically on navigate and on first step when entering cooking mode
- Cooking TTS: remove incorrect s.tts_enabled gate — _cookingTTS toggle is the only
gate; browser Web Speech API used by default without requiring Settings config
- Cooking TTS: timer fires '10 secondi rimanenti' warning at T-10s
- Cooking TTS: announce recipe completion ('Buon appetito!') on last step confirm
- i18n: add timer_warning_tts, recipe_done_tts, error.ai_quota keys (IT/EN/DE)
- CSS: add banner-expired-danger, banner-safety-* styles for unsafe expired items
Two bugs in the migration function:
1. DELETE endpoint does not exist in Bring! API — must use PUT with
'remove' param (same as the remove-from-list flow elsewhere in the code)
2. Items were added using the Italian shopping_name as the 'purchase' field
instead of the German catalog key via italianToBring(shoppingName).
This created Italian/German duplicates (e.g. both 'Affettato' and
'Aufschnitt' in the list at the same time).
Also add a pre-add duplicate check so existing catalog-key items are not
double-added when the old specific item is removed.
Manual cleanup run: removed 25 stale/duplicate items, added 8 correct
German-key items, ran migration (1 more migrated). List is now clean.
- bringGetList() now runs bringMigrateNamesInternal() silently after
returning the response, max once per 10 minutes (bring_migrate_ts.json flag)
- Refactored migration into bringMigrateNamesInternal() reusable function
- Removed manual migrate button from UI (not needed, it's automatic now)
- One-shot migration already executed via curl: 16 items updated in Bring!
- New API action bring_migrate_names: reads current Bring! list, matches
items against products DB, replaces specific names with shopping_name
(e.g. 'Mortadella IGP' → 'Affettato' with spec 'Mortadella IGP · Brand')
- New button in Bring! settings: 'Generalizza nomi lista Bring!'
with live status feedback (migrated / skipped / errors count)
- Auto-refreshes shopping list view after migration
Root cause: after scale auto-confirm fires submitUse(), the old code called
_cancelScaleAutoConfirm(false) which reset _scaleLastConfirmedGrams to null.
This allowed the scale (still showing the same reading) to start a new 10-second
stability+confirm cycle and trigger a second identical deduction.
JS fix:
- submitUse() now calls _cancelScaleTimersOnly() instead of
_cancelScaleAutoConfirm(false), preserving _scaleLastConfirmedGrams so the
same weight is rejected until the product is removed from the plate.
- _scaleStabilityVal reset to null so a genuinely new weight starts fresh.
- Duplicate API response (result.duplicate) silently ignored in the UI.
PHP fix (server-side safety net):
- useFromInventory() rejects a second 'out' transaction for the same product
within 12 seconds with { success: false, duplicate: true }.
This catches any client-side edge cases regardless of scale timing.
- inventory_use API now returns product_shopping_name in response
- showLowStockBringPrompt: uses generic shopping name (e.g. Affettato) as
Bring! item name, specific product name + brand as specification field
- addLowStockToBring: reads from window._lowStockName instead of arg
- Auto-add on depletion JS fallback: same generic-name pattern
- Deduplication check now tries both shoppingName and raw name
1. shopping_name compound-phrase map (computeShoppingName)
Add phraseMap checked against the full product name BEFORE the single-token
keyword loop. Prevents 'pane grattugiato' → 'Pane', 'panna da cucina' → 'Panna', etc.
Key new phrases:
- pane/pan grattugiato → Pangrattato
- panna da cucina / panna cucina / panna chef → Panna da cucina
- fette biscottate → Fette biscottate
- aceto balsamico / glassa balsamico → Aceto balsamico
- latte condensato/evaporato/vegetale/di soia/mandorla/avena/riso/cocco → specific
- prosciutto cotto → Prosciutto cotto
- farina di riso/mais/integrale → specific
- pasta fresca, zucchero di canna, acqua minerale/frizzante/gassata, brodo, …
Also added single-token safety-net entries: 'grattugiato'/'grattato'/'pangrattato'
→ 'Pangrattato', 'biscottate' → 'Fette biscottate'.
2. DB migration (sqlite3 UPDATE)
Re-classified 10 products that had wrong shopping_name:
Pane grattugiato → Pangrattato
Panna da cucina (×4) → Panna da cucina
Fette biscottate (×2) → Fette biscottate
Aceto balsamico (×3) → Aceto balsamico
Cleared 2 stale Gemini cache entries.
3. showLowStockBringPrompt (app.js)
When totalRemaining <= 0 (product fully depleted), skip the modal entirely.
The backend already auto-adds to Bring! on depletion; the JS only asks as a
fallback if that failed (fire-and-forget async, never blocks the UI).
The afterCallback (e.g. move-remainder modal, navigate to dashboard) is called
immediately without user interaction.
- Extended keyword map: 100+ entries covering bread (bauletto->Pane),
cheese (bel->Formaggio, casatella->Formaggio), wine (vesoletto/trebbiano->Vino),
foreign brand names (kaffee->Caffe, risofrolle->Cracker, zuppalatte->Biscotti,
inchusa->Birra, apfelsaft->Succo, kartoffelpüree->Purè, ciobar->Cioccolata calda,
ovomaltine->Bevande), desserts (gelato->Gelato), herbs (camomilla->Camomilla),
liquors (sambuca->Liquore), sugar variants (zuccheri->Zucchero), foreign words
(jus/zumo/arome->Italian equivalents)
- Add _geminiClassifyProduct(): calls Gemini 2.0 Flash for ambiguous product names,
with persistent cache in data/shopping_name_cache.json (never re-queries same product)
- computeShoppingName() now calls Gemini when keyword map and Bring! catalog both fail
and the product name is multi-token or has a brand/category hint
- saveProduct() auto-computes shopping_name on every create/update (already in prev commit)
- DB migration: all 210 products re-classified with new rules
- shopping list: 38->33 groups (Formaggio +4v, Affettato +5v, Biscotti +1v, Pane +1v...)
- Final SQL fixes for edge cases: Gelato, Camomilla, brand name single tokens
- Add shopping_name column to products table
- Add computeShoppingName() PHP auto-assign function:
* Curated keyword map: all salumi/cold cuts → 'Affettato'
* Bring! catalog back-translation: 'Latte di Montagna' → 'Latte'
* Fallback: first significant token capitalized
- Migrate all 210 existing products with auto-computed shopping_name
- saveProduct() auto-computes shopping_name on every create/update
- smartShopping() groups items by shopping_name: most urgent item is
representative, others listed as variants (e.g. 'Affettato' shows
Mortadella, Speck, Nduja, Salame, Prosciutto, Schinkenspeck as one row)
- _productOnBring() also checks shopping_name for Bring! match detection
- addToInventory auto-remove: uses shopping_name-based Bring! key
- useFromInventory auto-add: sends shopping_name to Bring! (not raw name),
specific product name goes into specification field
- Frontend renderSmartItem: shows shopping_name as title, specific
product name(s) in italic subtitle line below
- _syncOnBringFlags: matches on both name and shopping_name
getFinishedItems now:
- Computes total_in - total_out for every qty=0 row
- If balance <= unit threshold (e.g. <20g, <0.1 conf): product was
legitimately used up → silently DELETE, no banner shown
- Only if balance > threshold (unexpected zero): return to frontend
so banner asks user to verify
Banner detail now shows the expected residual qty so user understands
why the alert fired.
- useFromInventory: replace DELETE with UPDATE qty=0 when stock hits 0
(both normal path and use-all-locations path)
- listInventory: add WHERE quantity > 0 so qty=0 rows are invisible in
the regular inventory list
- New API actions: inventory_finished_items (query) and
inventory_confirm_finished (delete after user confirms)
- Banner: new 'finished' type (priority 600, above anomalies)
Shows: '{name} — è finito?' with two buttons
'Sì, è finito' → permanently deletes the qty=0 row
'No, ne ho ancora' → navigates to add-inventory form
- i18n: banner_finished_* and toast.product_finished_confirmed (it/en/de)
- DB migration: restored 75 auto-deleted products (last 30 days) as
qty=0 inventory rows so they appear in the banner queue
- Scadenze: rimuove 'in scadenza' dal banner (solo prodotti già scaduti)
- Consumo anomalo: spiega la media giornaliera, giorni dall'ultimo rifornimento
e direzione della discrepanza (più/meno del previsto) con contesto
- Quantità sospetta: messaggio specifico per caso (bassa/alta/conf insolita)
- Anomalia inventario: linguaggio naturale invece di jargon tecnico
phantom='hai più scorte del previsto', missing='hai meno scorte del previsto'
- API prediction: aggiunge days_since_restock, direction, tx_count
- confirmBannerPrediction: toast con info su ricalcolo previsioni
- Add generateRecipeStream() endpoint with real-time SSE status events
- Frontend generateRecipe() uses ReadableStream for live step updates
- Fix gemini-2.5-flash thinking model: disable thinkingBudget, raise maxOutputTokens to 4096
- Passo 2 is pure PHP heuristic (zero extra AI calls)
- Retry logic with live countdown on 429, fallback chain: 2.5-flash → 2.0-flash
- Pass all ingredients when meal plan is active (no limits)
- Add recipe-loading-msg element with CSS transition
- Aggiunto helper callGemini($url, $payload, $timeout):
* Fino a 4 tentativi su 429 / 503
* Legge Retry-After header dalla risposta HTTP di Google
* Legge retryDelay dal corpo JSON di errore (es. '10s', '30s')
* Backoff default: 2s, 4s, 6s (sovrascitto da Google se specificato)
- geminiReadExpiry(), geminiChat(), geminiIdentifyProduct(): rimosso curl
diretto senza retry, ora usano callGemini()
- generateRecipe(): rimosso vecchio loop manuale (3 tentativi, 2s/4s fissi),
ora usa callGemini() che rispetta i delay suggeriti da Google
- In caso di 429 finale restituisce il messaggio di errore da Google (non generico)
- New API endpoint 'inventory_anomalies': detects items where stored qty
differs from tx history by >20% AND >50 units (phantom qty or missing qty)
- New API endpoint 'dismiss_anomaly': persists dismissal in anomaly_dismissed.json
- Banner system: new 'anomaly' type shown in dashboard alert banner
with 'Correggi' (opens edit) and 'Ok, ignora' (dismisses) buttons
- CSS: banner-anomaly style (orange gradient)
- Fix: lo zucchero azzerato (175g fantasma rimossi), aggiunto a Bring!
- Cache smart_shopping: 10min → 3min (urgenza fresca)
- backgroundBringSync: ogni 10min → ogni 5min + setInterval continuo
- backgroundBringSync: aggiunge anche 'high' (non solo 'critical') a Bring
- autoSyncUrgencySpecs: aggiorna spec anche se il livello di urgenza sale/scende
- Risultato: Latte/prodotti urgenti compaiono su Bring in max ~5min
Prompt ricette:
- Lista ingredienti compatta: skip staples, no brand, flag brevi (🔴3gg)
- Cap gruppo 4 a 40 items, gruppo 6 a 20
- Regole condensate da 10 verbose a 6 concise
- Testi condizionali (varietà, regen, opzioni) abbreviati
- Aggiunto detail errore API Gemini nel toast
Bilancia:
- Ignora oscillazioni sub-grammo (jitter 0.5g)
- Confronto integer-gram prima di dichiarare instabile
- italianToBring(): pass-2 whole-word fallback now ignores generic qualifiers
(dolce, light, classico, originale, etc.)
- when multiple single-word matches exist, choose the longest/specific token
instead of first catalog iteration hit
- prevents wrong mappings like 'Pancetta Dolce' being interpreted via generic
adjective rather than the core product token
- PHP: predictive urgency block now scales by imminence:
round(days_left) <= 3 → high, <= 7 → medium, <= 14 → low
(was flat 'low' for any days_left <= 14)
- PHP: also upgrades existing 'low' urgency to 'high' when
imminent depletion detected (round(days_left) <= 3, isFrequent)
- JS: autoAddCriticalItems now also adds:
- high urgency items with pct_left < 20% (nearly empty)
- high urgency items with days_left <= 3 (imminent)
- any item with days_left <= 2 and uses_per_month >= 5
Result: Latte di Montagna (27.8x/mo, 3 days left) now appears
on shopping list before running out, as do Lenticchie/Riso
Basmati at 1% stock and Sandwich at 1 day left.