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).
- 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
- 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
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.
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.
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.
- 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.
- HA sensor: expiring_list now includes full product details (location, brand,
category, days_remaining, opened_at, vacuum_sealed, default_quantity, etc.)
- HA sensor: new expired_list attribute with full product details per expired item
- HA sensor: new low_stock_list attribute (items with quantity ≤ 1, full details)
- HA sensor: new sensor=product endpoint (?action=ha_sensor&sensor=product)
with optional filters: &id=, &name=, &location=
- HA cron webhook: expiry alert items now carry full product details
- Inventory edit: confirm dialog when quantity exceeds unit-specific threshold
(prevents data loss from unit-confusion typos, e.g. 183 conf instead of 0.183)
- Recipe AI: explicit rule against ingredient form substitution
(fresh tomatoes ≠ passata, fresh milk ≠ UHT ≠ cream, etc.)
- Shelf-life: opened bread rules (piadina 2d, bauletto/pancarrè 4d, pane 3d)
- docs/wiki: HA page updated with new schema, examples, product endpoint
Closes#125
- #124: star toggle on recipe view + favorites shown first in archive with gold border
- #123: +/- persons buttons on recipe to scale ingredient quantities
- #117: wasted value in EUR displayed in monthly stats section
- #118: macronutrient breakdown panel (P/C/F/fiber bars) with 4th insight rotation phase
- DB: is_favorite column on recipes, nutriments_json on products (auto-migrated)
- OFF API: nutriments fields fetched and stored per product
- Translations: it/en/de/fr/es updated with new keys
- _startInsightAlternation: 3_600_000ms → 30_000ms so rotation is actually
visible without waiting an hour
- _applyInsightPhase: smart phase-skip instead of always falling back to waste;
if current phase has no content, advance to the next non-empty phase
- PHP: add _normalizeCat() mapping OpenFoodFacts slug format ('en:dairies',
'en:plant-based-foods-and-beverages', …) to Italian app categories; also
re-aggregates counts after normalization so 'en:dairies' + 'en:milk' both
count toward 'latticini' correctly
- JS _renderMonthlyStatsSection: guard t() fallback — if t() returns the key
itself (not found), display a cleaned-up slug instead of the raw key
Products used ≥ 4x/month (daily staples: latte, pane, uova) now appear in
the smart shopping list when stock will deplete within 28 days (instead of 14).
Products used ≥ 2x/month (weekly staples: yogurt, frutta, carne) appear
within 21 days. The existing ≥1.5x/month rule at 14 days is unchanged.
No manual pin or UI needed: frequency is detected automatically from
transaction history. justRestocked guard prevents noise for freshly
bought items.
- app.js: TTS kiosk timeout 4s → 10s; fires interactive 'Hai sentito?' YES/NO
prompt instead of showing error (TTS can take 6-8s; UtteranceProgressListener
may not fire on all firmware); YES → success, NO → troubleshooting steps
- translations: add heard_question/heard_yes/heard_no/test_ok_kiosk/test_fail_steps
to all 5 languages (it/en/de/fr/es) under settings.tts
- api/index.php: fix end() PHP 8.0+ reference error in _offFetchProduct()
(categories_hierarchy stored in temp var before calling end()) (fixes#130)
- api/database.php: migrateDB() now checks sqlite_master for 'products' table;
if missing, calls initializeDB() and returns — no ALTER on nonexistent table
(fixes#133, covers #131)
- api/index.php: health_check db_row_count query guarded against missing
inventory table (fixes#131)
SetupActivity:
- btnTestRetry click handler: add step3NextButtons.visibility = VISIBLE
so the Indietro/Avanti buttons reappear after pressing No-retry
(previously they stayed hidden → user was stuck with no way to go back)
- onDisconnected(): always re-enable btnTestRetry so user is never stuck
when scale drops unexpectedly before a weight reading arrives
SettingsActivity / activity_settings.xml:
- Add 'IMPOSTAZIONI AVANZATE' section explaining that HA, Gemini AI,
- Add '← Torna all'app per le impostazioni avanzate' button (finish())
Android TTS was silently failing because engine.speak() used the default
audio stream, which may be muted while the media stream is not.
Changes:
- KioskActivity: speak() now passes Bundle with KEY_PARAM_STREAM=STREAM_MUSIC
so TTS plays on the same channel as beep/media audio
- KioskActivity: add UtteranceProgressListener that calls window._kioskTtsDone()
or window._kioskTtsError(uid, code) back into the WebView on completion/failure
- app.js testTTS(): kiosk path now shows real result from Android callbacks
(success/error) with a 4s fallback timeout showing actionable troubleshooting hints
Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true env var to ci.yml,
security.yml and build-kiosk.yml to prevent action failures when
GitHub removes Node 20 runner support on 2026-06-02.
Also re-triggers CI to bypass transient 403 infrastructure failure
that hit the previous run (GitHub CDN outage).
- Add testSound() function: plays AudioContext beep independently of TTS
- Add '🔔 Esegui Test Suono' button in TTS settings (above voice test btn)
- Add translations: settings.tts.test_sound_btn (it/en/de)
- Remove hasOtherLocs guard in showMoveAfterUseModal: always show
location-choice modal when opening a package, regardless of whether
the product already exists in other locations (e.g. peas only in pantry
→ user can now choose to move opened portion to fridge)
_speakBrowser: voice fallback now prefers localService=true (offline)
voices over cloud voices. On Android Chrome, cloud voices fail silently
when no internet. New priority: local Italian → any Italian → local
any-lang → first available → lang-only.
testTTS (browser engine):
- Plays _playCookingTimerSound('done') beep FIRST (AudioContext, always
works) so user can confirm audio hardware/volume is working
- Checks if only cloud voices exist → shows actionable error
- Builds utterance with onerror → shows error code on failure
- Adds onstart → shows actual voice name/lang/online status on success
- 2s watchdog: if onstart never fires → 'nessuna risposta' warning
- Shows '🔊 Beep + TTS in corso...' during the test
_speakBrowser: Chrome sets speechSynthesis.paused=true after tab is
backgrounded/minimized. Calling cancel() does NOT clear this state.
Adding resume() before speak() inside the setTimeout fixes silent
failure when user returns to the tab (e.g. switch app → back to browser).
testTTS: add diagnostic messages for:
- Android kiosk: check isTtsReady() and show actionable error if
the native TTS engine hasn't initialized yet
- Web browser: check getVoices().length before speaking; if empty,
show error asking user to install a voice pack in OS settings
(instead of always showing '✅ Riproduzione in corso' even when silent)
_speakBrowser() was silently failing in two scenarios:
1. Chrome cancel+speak bug: calling speechSynthesis.speak() immediately
after cancel() is silently dropped in Chrome. Fixed by adding a 50ms
setTimeout between cancel() and speak().
2. No voice fallback: when tts_voice preference is empty (never saved)
and the browser has no default 'it-IT' voice, speech fails silently.
Now tries: preferred by name → first Italian voice → first any voice
→ lang-only as last resort.
3. Async voice loading: Chrome/Android load voices asynchronously.
When getVoices() returns empty, now waits for onvoiceschanged (with
a 500ms safety timeout) before speaking.