# Changelog 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). ## [Unreleased] — Ideas & Roadmap > Ideas collected during development. No priority or date implied. - **G1–G3 / G6–G7** — Real money ledger, multi-user roles, multi-list UX, recipe matching depth, offline conflict resolution. - **Heavy items** — Receipt OCR, voice commands, geofencing, AGP 9 / SDK 37 (separate projects). - **CI INDEX drift job** — `regen-code-index` in `.github/workflows/ci.yml` needs a `workflow`-scoped PAT to push; until then run `bash scripts/gen-code-index.sh` locally (see `todo/CI-INDEX-NOTE.md`). ## [1.11.11] - 2026-10-10 ### Scan screen redesign - Hero camera viewport with favourites + recent chips **above** the stream; layout fills the viewport so action buttons stay visible without scrolling. - Removed Barcode / Nome / AI tabs; compact action bar: **Identifica AI**, **Cerca**, **Manuale** (larger tap targets). - Recognition HUD: seeking particle cloud that follows barcode-like regions; green lock box only after a confirmed code; AI match opens as a full-page sheet over the camera. - Continuous autofocus + center focus point; valid EAN confirms on first hit; digit OCR on a narrow strip. - Speculative `resolve_barcode` while digits accumulate + warm offline product cache on scan open. - Camera idle shows solid black (no grey ▶ poster); video muted / absolute fill. ### Product titles - UI-tab labels (`Snacks & Sweets`, `Vegetables`, `Dairy`…) are no longer used as title genres (they orphaned stock from barcode scans). - Maintenance helper `scripts/fix-mangled-kind-titles.php` strips poisoned prefixes and merges barcode-less stock onto the barcode twin. ## [1.11.10] - 2026-10-09 ### Recipes archive - Chat / free-slot imports no longer overwrite each other: many `libero` recipes per day are allowed; scheduled slots (pranzo/cena/…) still replace one row per day. - Every archive row has a **`content_hash`** (title + ingredient names + steps). Re-saving the same recipe (chat import again, marking ingredients used, quantity drift) **updates** the existing row instead of cloning it. Same-title `libero` near-duplicates are collapsed on migrate. - Migration drops the old `UNIQUE(date, meal)` constraint, backfills hashes, and deletes duplicate rows (keeps favourite, else newest). Guard: `scripts/test-recipe-archive.php`. - Chat → recipe titles: never leave placeholder `"libero"`; derive a real dish name from the chat text / first ingredient when the AI returns an empty or placeholder title. ### Recipe “Usa ingrediente” UI - Needed amount is shown large (“Serve per la ricetta”). - With a connected scale: live **X / Y unit** compare, progress toward the recipe target, and clear under/ok/over feedback (i18n in all six locales). ## [1.11.9] - 2026-10-08 - **Kiosk pairing UX:** dedicated wizard step explains where to find the code (Settings → Security / server log) and collects it on its own screen (no AlertDialog). - Native API token is handed to the WebView (`_kioskBridge.getApiToken` + `localStorage`) so the web app does **not** ask for pairing again after setup. - Save-failed toast no longer leaves you stuck: if the server rejected the save for a missing token, the wizard returns to the pairing step. - Kiosk **1.7.24** (versionCode 25). ## [1.11.8] - 2026-10-08 ### Web app - Settings → EverShelf Kiosk: **Scarica APK da questo server** (`releases/evershelf-kiosk.apk` on the LAN) plus GitHub `kiosk-latest`. The old “Latest release” URL 404’d because app tags own GitHub Latest and carry no APK. - Kiosk OTA panel always keeps a direct local download; install no longer dies on `typeof` bridge checks; stuck “Avvio download…” resets after 8s. - **Pairing code** is visible on a paired device under Settings → System → Security (and Info): large code, countdown, copy / refresh. Pairing dialog and docs no longer point only at `docker logs`. ### Android kiosk - CI no longer marks versioned kiosk releases as GitHub `--latest`; refuses debug-signed APKs so OTA no longer forces uninstall. - **1.7.22:** connection Test uses public `ping` (same as discovery); wizard pairs then offers keep/reset of server settings; `save_settings` sends `gemini_key` + API token; BLE connect retries up to 3×. - **1.7.23 — full UI redesign:** emerald Corporate UI across setup wizard, settings and splash; welcome/privacy/feature cards with real explanations; progress dots use brand drawables; all new copy in EN/IT/DE/FR/ES; no leftover purple accents. ### Docs - Wiki / README / SECURITY / ARCHITECTURE / CODEBASE-MAP aligned for pairing-in-Settings, `kiosk-latest`, and the emerald wizard. ## [1.11.7] - 2026-10-06 - Recipe detail no longer shows the empty «Mancano dalla dispensa» stub when the pantry already covers the dish (auto mode left the loading placeholder stuck). Panel appears only when there are real gaps; default `RECIPE_SHOPPING_MODE` is `off`. - Undo of a location move (`[Spostamento]`) now reverses **both** ledger halves together — undoing only the “in” half used to wipe the stock and trigger a false “è finito?” banner. ## [1.11.6] - 2026-10-06 Home Assistant sensor/calendar parity for [ha-evershelf 1.3.2](https://github.com/dadaloop82/ha-evershelf/releases/tag/v1.3.2). - `ha_sensor` exposes `shopping_mode` (`internal`/`bring`) and `expiry_filter=attention`. - `expiring_today` / `expiring_3d` use the same attention rules as the dashboard. - `ha_calendar?attention=1` returns only alert-worthy expiries; full feed still available by default (with `needs_attention` on each event). ## [1.11.5] - 2026-10-06 Shopping-list correctness + Home Assistant expiry parity (Bring stays dark when disabled). **Shopping** - Family restock converts mixed `g`/`conf` stock and TX into pack-equivalents (no more “24 jars of passata”). - Conf suggestions hard-capped at 3–6 packs per trip (`SHOPPING_GUARD_MAX_CONF_PACKS`). - Dictionary trailing genres (`piadine`/`avocados`/`muesli`→`Cereali`); ingredient phrases like `farina di riso` only match at title lead. - Off-season ice cream pruned May–Sep; seasonal tip cards deduped by buyable family. - Internal list marks `on_bring` so suggestions never repeat rows already on Spesa. - Locked user titles (`name_user_set`) own name + `shopping_name` on repurchase/rescan. **Bring!** - Hard gate: `bringAuth` / `bringRequest` and `bring_*` actions no-op when `SHOPPING_MODE=internal` (even with leftover credentials). - Bring catalog back-translation unused for shopping names in internal mode. **Home Assistant** - Expiry webhook/sensor uses the same urgency rules as the dashboard (silent on low-risk “best before”). - `expiry` ↔ `expiry_alert` alias so `HA_WEBHOOK_EVENTS=expiry` matches the cron event name. ## [1.11.4] - 2026-10-06 Free opt-in features (each with Settings + guided “da decidere” until configured). **Prices** - Open Prices (Open Food Facts) as free barcode price source; `PRICE_SOURCE=auto|open_prices|ai`. - `price_enabled_set` so the checklist asks once before enabling estimates. **Web Push (PWA)** - Optional browser push: VAPID keys minted locally, empty-payload wake + `webpush_inbox`. - Subscribe / test from Notifiche; requires HTTPS. **UX polish** - Toast undo after simple inventory delete; torch also tries `fillLightMode`. - Wider `refreshAppDataAfterMutation()` (favourites, discard, vacuum). - Code indexes: run `bash scripts/gen-code-index.sh` locally (CI INDEX job awaits a workflow-scoped PAT; see Unreleased). ## [1.11.3] - 2026-10-06 Audit-driven shopping, notify, and UX batch (see `todo/AUDIT-2026-10-06.md`). **Shopping** - Generic list matching by shopping group; normalize/prune/dedupe on load and cron; audit script. - `SHOPPING_REMOVE_ON_BUY=trip` removes list rows on purchase; **Spesa fatta** clears the list. - Smart `period_usage` caps; `need_qty` / `need_unit` columns; regression tests. **Notify & integrations** - ntfy/webhook events: `shopping_remove`, `shopping_trip_complete`, `weekly_digest` + cron. - Telegram webhook: `/lista`, `/scadenze` (optional `TELEGRAM_*` in `.env`). **UI & cooking** - `refreshAppDataAfterMutation()` after key flows; list subtitle under generic title. - Cooking scrap reuse hints (peels, cores, shells); price cache when AI is off. **Ops** - `EVERSHELF_DISABLE_HTTPS_REDIRECT`; mcp-server npm audit fix (proxy-addr). ## [1.11.2] - 2026-10-06 **Fixes** - **Extend expiry updates the UI.** After `inventory_update` for a new expiry date, the client recalculates days-to-expiry from the date, marks the review as confirmed for the banner queue, reloads banner alerts, and calls `refreshCurrentPage()` so dashboard cards and inventory lists do not stay stale. **Docs** - Added `docs/OPTIMIZATION-BACKLOG.md` — prioritized bugs, optimizations, and feature ideas. ## [1.11.1] - 2026-10-06 Faster barcode identity, fewer bogus singular titles, a short favourites rail, and a shopping list that actually remembers what ran out. **Highlights** - **Barcode lookup is snappier without calling the AI.** Parallel OFF mirrors abort as soon as the first one answers; GTIN-14 / EAN-8 / UPC-A variants are tried as candidates; the camera path confirms UPC-A in one good frame, brings ZBar in earlier, and uses an adaptive threshold on the crop so shiny packs decode faster. AI stays behind the manual button / `BARCODE_AI_FALLBACK`. - **Package collectives stay plural.** `Ceci`, `Fagioli`, `Lenticchie`, `Piselli`, `Cereali`, `Spinaci`, `Gnocchi`, `Grissini`, `Taralli`, `Sardine`, `Pelati` are invariant — one tin still holds many beans, so the title never becomes `Cece`. - **Automatic favourites are the top 3 only.** Reaching `AUTO_FAVORITE_MIN_USES` is no longer enough; only the absolute top `AUTO_FAVORITE_TOP_N` (default 3) most-used products get the star. A manual unstar still always wins. - **Shopping list catches finished and almost-finished products.** Trace crumbs count as empty, depleted products the household has bought/used come back at least as medium urgency, near-empty stock (≤25 %) is flagged even for non-staples, and loose any-token "already covered" matching no longer hides a finished staple behind a loosely related cousin on the shelf. ## [1.11.0] - 2026-10-06 An article is now named the way a catalog names it, and counted the way a pantry counts it: the title stays singular, the shelf says how many there are. **Highlights** - **The stored title is the singular.** `Fette biscottate Integrali` becomes `Fetta biscottata integrale`, `Uova medie` becomes `Uovo medio` — the catalog, the search and the maintenance pass always speak of *one* article, so the same product cannot look like two. Only the genre that **leads** the title moves (a title that merely contains it, `Bucce cotte di pomodoro`, is left alone), the adjectives right after it follow the move (`Uova medie` → `Uovo medio`), a variety name never does (`Tarallini` is not `Taralli`, `Mela rossa Gala` keeps its `Gala`), and a title opening with a quantity is not touched at all (`6 UOVA PASTA GIALLA`): the number must not jump behind the genre. Package collectives (`Ceci`, `Fagioli`, …) and mass nouns the dictionary does not number are invariant — the app never invents a plural (or a bogus singular) it was not given. - **The pantry shows the plural, counting pieces only.** The list is rendered from a `display_name` the API derives at read time (`3 Uova medie`, `2 Mozzarelle`), so three eggs read like three eggs while `500 g Pasta` stays a mass and never becomes `500 g Paste`, and millilitres, kilos and unknown units are never guessed. - **Certifications keep their capitals.** `Igp`, `i.g.p.` and `igp` are all written back as `IGP`, `Dop` as `DOP`, `Evo` as `EVO`, inside brackets or not, HTML entities included; an unknown all-caps token is read as a sigla and kept (`XYZ`), while `BIO` stays a word and follows the sentence case like everything else. - **The buyable word is not a title.** The maintenance pass leaves `products.shopping_name` exactly as it is: the shopping list buys `Grissini`, not one `Grissino`, so renaming the title does not rename the Bring! item. **Internals** - `productKindSingularizeName()` / `productNameForPieces()` and the curated `evershelfProductKindNumberForms()` / `...AgreementForms()` directories live in `api/lib/product_kind.php`: one vocabulary shared with the genre resolver, read through one lookup, with the two directions asserted to be a round trip (singular → plural → singular) so the pass can replay without drifting a title. `scripts/test-product-number.php` locks the number rules, `productTitleSiglaForm()` the certifications, and `scripts/test-product-kind-prefix.php` the genre, the case and the single dictionary. ## [1.10.1] - 2026-10-05 The splash screen now shows what the app is doing instead of a bare spinner: a rail of stage icons that starts grey, blinks while a stage is being checked, and lights up with an aura in its own colour when it passes — amber or red when it does not. The version is printed large enough to be read out when opening a bug report. **Added** - **Boot rail on the splash.** Seven icons — connection & access, PHP runtime, storage & permissions, database, configuration, network, app start — driven by `_preloaderStage()`: grey (`is-pending`) → blinking (`is-active`) → coloured with a glow (`is-done`), with a lit connector behind every settled stage. The ~30 flat `health_check` keys are grouped by `_PRELOADER_STAGE_OF_CHECK`, so a failed stage can never look green (`is-warn` / `is-error` override the accent). On phones the per-icon labels collapse into the caption line naming the running stage. - **Version chip on the splash.** Previously a 0.68rem grey footnote; it is now a bordered pill next to the logo, and `bump-version.sh` keeps rewriting it (the `preloader-version">v` marker is unchanged, and a guard test locks both sides of that contract). - **Every article title starts with a capital letter.** The name is shaped at the one choke point (`mergeIncomingProductFields()` → `productTitleCapitalize()`), so a scan, an import, a catalog title, the AI and a hand-typed rename all land as `Latte fresco`, never `latte fresco`. Only the first letter is raised: the rest of the word keeps its case (`NUTELLA` stays `NUTELLA`; a brand spelling such as `iPhone` becomes `IPhone`, because the rule is mechanical and cannot disagree with itself), a title that opens with a digit or an emoji is left alone, and the rule stays on when the genre prefix is switched off (`PRODUCT_KIND_PREFIX=false`) because it is applied outside that guard. Titles already stored are fixed by the same pass (`settings → Nomi e preferiti automatici → Applica agli articoli esistenti`). - **`scripts/test-preloader-stages.php`** — asserts the drawn rail, `_PRELOADER_STAGE_KEYS`, the stage ownership of every health check and the six locales stay in sync, plus the CSS state rules (grey → blink → aura, reduced-motion) and the version markup. ## [1.10.0] - 2026-10-05 Two things the pantry should do by itself: say what every article actually is, and keep the products you really use where you can reach them. **Highlights** - **The genre leads the title.** A scanned, imported or hand-typed product now carries its genre in front of the name — `Fiori di latte` becomes `Yogurt Fiori di latte` (or `Formaggio …`, depending on what it is) — because the label rarely says what the product *is*. The genre is resolved cheapest-first: the curated Italian dictionary that already drives the shopping/Bring! names, then the signature cache so similar products reuse it, then **one** AI word (cached per name and per signature, still capped by `GEMINI_CLASSIFY_DAILY_MAX`), then the localized app category. A title that already leads with a genre — or with something similar (`Tarallini` vs `Taralli`, `Pera Italiana` vs `Pere`, `Kaffee` vs `Caffè`, `Italia Zuccheri` vs `Zucchero`) — is never touched twice, and neither is a title that already opens with the genre stored on the product, so the pass can be re-run as often as you like without a title drifting. `settings → Nomi e preferiti automatici → Applica agli articoli esistenti` renames the articles already stored (with a preview first). Off with `PRODUCT_KIND_PREFIX=false`. - **Products you use often become favourites.** Consuming a product `AUTO_FAVORITE_MIN_USES` times (default 3) within `AUTO_FAVORITE_WINDOW_DAYS` (default 90, the same window `recent_popular_products` reports) promotes it to the top of the inventory. The rule only ever *adds* favourites, and unstarring one by hand records a veto (`products.favorite_user_override`) so the automation never puts it back. - **The scan step no longer decides by itself.** A barcode label or an AI guess can be wrong, and the add-to-pantry step is the last moment you see it: the identified **title and brand are now tappable there** (and on the AI match card) so you can correct them before saving. The correction is stored with `products.name_user_set`, which is what stops the next rescan of the same barcode from putting the wrong name back; the stored title still comes back through the API with the genre prefix, the capital letter and the singular form applied. **Schema** - `products.kind` — the genre resolved for that article (also makes the maintenance pass idempotent and free of extra AI calls). - `products.favorite_user_override` — set when you un-star a favourite by hand. **New action** - `products_apply_auto_rules` (POST, `dry_run` supported) — applies both rules to the products already in the pantry; returns what it renamed/promoted plus a few samples. **Internals** - The curated dictionaries (phrase map, keyword map, stop words, tokenizer) moved from `computeShoppingName()` into `api/lib/product_kind.php` so the shopping name and the genre resolver can never drift apart, and `computeShoppingName()` now calls `productKindFromDictionary()` instead of re-reading the two maps itself: **one** vocabulary and **one** lookup, so on any product the dictionary knows the buyable name and the genre leading the title are the same string by construction (asserted over the whole live pantry, 397 products). `products.shopping_name` and `products.kind` stay two separate columns on purpose: the first is the buyable word of the list/Bring! (historical, may be a raw token), the second the genre inside the title (precise, and allowed to stay empty). `scripts/test-product-kind-prefix.php` locks that down together with the new i18n keys and the UI wiring. `scripts/test-auto-favorite.php` runs the favourite rules on an in-memory database (threshold, window, veto, sweep). ## [1.9.4] - 2026-10-05 Two UI bugs that no test was watching: the home page drew every chart empty, and a few screens printed an HTML tag instead of a quantity. **Highlights** - **The dashboard charts are back** — macros, nutrition score, monthly categories and the spend comparison sat at zero since **v1.9.1**. `_applyInsightPhase()` reveals one panel per rotation and fills its bars from their `data-target`, but it asked `showNutr`/`showMonthly`/`showMacros`/`showSpend` whether that panel was the current one: the commit that gave the dashboard three more panels deleted those constants and left the `if (show… )` branches behind, so every rotation tick threw `ReferenceError: showNutr is not defined` inside the `requestAnimationFrame()` callback. The exception aborted the whole reveal step, which is why *no* chart was drawn — not only the nutrition one — while the numbers printed around them looked fine. The branches now test the phase itself. - **No HTML tags on text-only surfaces** — `formatQuantity()` appends `(da 36g)` for `conf` units, and the screensaver facts, the home alert banner and the item action modal print with `textContent`/`escapeHtml()`, where the tag showed up as literal text (`Lenticchie: ne hai 2 conf (da 250g)`). They go through `_formatQtyPlain()` / `stripHtml()` now. **Upgrading from 1.9.3** - Nothing to do: no schema change and no new setting. The charts fill in again on the next dashboard rotation. - The spend comparison shows what you actually recorded: remember the "🧾 Quanto hai speso?" prompt when a shopping session starts — "Non ora" records nothing, so a month you never entered stays at `0`. - Two guard tests join the suite and run like the rest of it: `php scripts/test-dashboard-panels.php` and `php scripts/test-html-in-text.php`. ### Fixed - **The insight rotation never filled a bar since v1.9.1** — `_applyInsightPhase()` branches on `phase === 'nutrition' | 'monthly' | 'macros' | 'spend'` now, exactly like the panel visibility code above it, so the `ReferenceError` is gone and each bar receives the width/height its `data-target` asks for. `scripts/test-dashboard-panels.php` (341 lines, 53 assertions) fails on any part of that contract: an undeclared identifier used as a guard **anywhere** in `app.js` (the generic half of the crash class), a reveal branch testing the deleted flags, a phase listed in `_INSIGHT_PHASES` without a section, a reveal branch and a renderer, or a bar emitted without a `data-target` — or never filled from it. The `data-target` rule is deliberate: bars are painted at `0%` and animated in by the CSS transition, so a bar rendered pre-filled would hide a broken rotation instead of showing it. - **Quantity helpers leaked markup into text-only surfaces** — the two screensaver facts and the home alert banner now build their strings with `_formatQtyPlain()`, and the item action modal strips the markup with `stripHtml()` before escaping it. `scripts/test-html-in-text.php` (251 lines, 18 assertions) locks the class: a text-only call site may not hand a bare `formatQuantity()` result to `escapeHtml()`/`textContent`/`title`, `generateScreensaverFact()` may only build its `{qty}` values with a text-only helper, and the fact element itself is written with `textContent`, never with `innerHTML`. ## [1.9.3] - 2026-10-05 One bug, and it explains why settings on an install with a read-only `.env` kept "disappearing": they were stored, and then never read back. **Highlights** - **Settings stored in the database are read again** — when `.env` cannot be written by the web server (Docker, or a `.env` owned by root), `save_settings` stores the value in SQLite and answers `success — stored: database`. But bootstrap.php read `DISPLAY_ERRORS` through `env()` *before* `api/database.php` was loaded, and `loadEnvOverrides()` cached that "database not ready" answer for the rest of the request — so from then on every `env()` call in that request ignored the database. Custom storage units, dietary preferences and the rest looked lost after an update, and adding one again appeared to save nothing although the row WAS written every single time. Closes [#261](https://github.com/dadaloop82/EverShelf/issues/261). **Upgrading from 1.9.2** - Nothing to do, and nothing to re-enter: the values saved during the broken window are still in the database, they simply start being read again. - `save_settings` now reads its own fallback back before answering, so a store that cannot be read fails loudly (`settings_not_persisted`) instead of showing a green toast. - The database fallback lives in `data/evershelf.db`: if you run EverShelf in Docker and your `.env` is not writable, keep `data/` on a volume or the overrides go away with the container. Below is the long form of the fix, plus what the new test locks down. ### Fixed - **Settings stored in the DB fallback were ignored for the rest of the request (#261)** — `loadEnvOverrides()` no longer caches the "the database is not loaded yet" answer that bootstrap triggers on every HTTP request. It also means the fallback now behaves exactly like a `.env` write: the same `env()` call, the same effective value, in the request that saved it and in every request after it. CLI and cron were never affected — a `CRON_MODE` process short-circuits the early `env('DISPLAY_ERRORS')` — which is why the watchdog, the notifier and the smart-shopping jobs kept reading their overrides while the web UI did not. - **`save_settings` no longer claims a success it cannot prove (#261)** — after the fallback stores the value it is read back through `env()`; a mismatch returns `settings_not_persisted` and logs `settings_fallback_readback_failed` instead of leaving the user with a green toast and an old value. `scripts/test-env-overrides.php` replays the bootstrap order in a child process and fails when the value written by the fallback cannot be read back afterwards, so this cannot silently regress. The same test asserts the structural rule that let it happen: the "database is not loaded yet" answer must never enter the static cache. ## [1.9.2] - 2026-10-05 Two bug reports from the tracker: EverShelf answered in the wrong language, and one API branch could take the whole backend down. **Highlights** - **Photo identification answers in the app language** — the `gemini_identify` prompt was hardcoded in Italian, so a user whose UI is in English photographed toilet paper and got `Carta igienica` with an Italian description back. The prompt is now assembled from one fragment set per locale (`api/lib/ai_prompts.php`), both scan entry points send the UI language, and the Open Food Facts lookup is asked in that locale instead of a hardcoded `it`. Closes [#260](https://github.com/dadaloop82/EverShelf/issues/260). - **The API can no longer die on a function that moves** — `ping`, `app_bootstrap` and `health_check` answer *before* the router, so they may only call functions PHP has already hoisted. A declaration placed inside a block is not hoisted, and that shape produced 61 `Call to undefined function checkRateLimit()` crashes in one 20-minute window. `checkRateLimit()` now sits at the top of `api/index.php`, and `scripts/test-api-hoisting.php` fails on any function declared inside a block, or called before it exists. Closes [#246](https://github.com/dadaloop82/EverShelf/issues/246). **Upgrading from 1.9.1** - Nothing to do: no schema change and no new setting. The next photo is simply identified in the language the app is set to (Settings → 🌍 Language), and the scan does not care whether the UI was reloaded in the meantime. - The two new checks are plain PHP scripts and run like the rest of the suite: `php scripts/test-api-hoisting.php` and `php scripts/test-ai-language.php`. Below is the long form of the two fixes, plus what the new tests lock down. ### Fixed - **`gemini_identify` ignored the UI language (#260)** — both scan entry points now send `lang`, the handler normalizes it with the same `recipeNormalizeLang()` the recipe endpoints use, and the prompt, the description, the confidence label and the Open Food Facts response (`lc`, `product_name_`) all follow it. The `category` field deliberately keeps the canonical Italian tokens, because `mapToLocalCategory()` matches them against the app's category keys. - **`checkRateLimit()` could be undefined for the early API branches (#246)** — the function sits at the top of `api/index.php`, above every call site, and the new test locks that invariant with a self-tested tokenizer scan, so it cannot pass by accident. The same test also fails when an early branch calls a function that is declared nowhere, which is how a one-character typo in `ping` would otherwise reach production unnoticed, and it reports the exact line of the offending call so the fix is obvious. ## [1.9.1] - 2026-10-05 Everything below landed on `develop` after v1.9.0 was published and ships as **v1.9.1**. The badges in `index.html`, `manifest.json` and the service-worker cache all say 1.9.1, so the tag is no longer blocked by a mismatched version string. **Highlights** - **Alerts without Home Assistant** — a free [ntfy](https://ntfy.sh) topic and/or any JSON webhook (n8n, Node-RED, Gotify, a Discord/Slack bridge) configured in *Settings → 🔔 Notifiche*, with a test button that reports the HTTP status of every channel. Expiry push used to be locked behind the Home Assistant integration: no HA, no phone notification. - **Cron watchdog** — every CLI job pings a dead-man's-switch URL (Healthchecks.io or the Uptime Kuma push style), so a scheduled job that stops running raises an alert on a channel the household already reads, instead of leaving a smart list that quietly goes stale while the UI still looks fine. - **Settings, reorganised** — the endless horizontally scrolling tab strip collapses into four sub-sections with their own sub-navigation, and every card becomes a collapsible sub-section whose heading and hint stay visible while collapsed. - **Dashboard** — three more rotating panels (overview, freshness, trends) beside the existing insights, so the home page says something useful without being scrolled. - **Setup checklist and guided assistant** — a fresh install is walked through the steps it still has to do (server, cron, notifications, backup…) instead of leaving the user to find every switch alone. - **ICS calendar feed** — *Settings → 🗓️ Calendar* publishes the pantry as a WebCal subscription: one all-day event per item that has an expiry date, a ⚠️ EXPIRED prefix for the ones already gone, a reminder the day before, stable `UID`s, and a rotatable token. - **Recipe → shopping list** — a recipe's ingredients land on the shopping list with the pantry already deducted, so only what is actually missing is asked for. - **Seasonal produce** — shopping and recipes can be filtered against the current season (`data/seasonal_produce_it.json`), with every tip key translated in all six locales. - **Android kiosk** — the setup wizard is now 9 steps (language first, permissions and server discovery included), and the kiosk update check polls every 30 minutes while throttling the GitHub API to once every 6 hours. - **Docs and tests** — API reference and OpenAPI schema for the notification, calendar and recipe-shopping endpoints, an updated wiki/architecture/kiosk guide, and the checks that keep i18n keys, settings navigation and the shopping guards honest. **Upgrading from 1.9.0** - Nothing to do: the SQLite migrations run on the first request, and the new `NOTIFY_*` variables are written from *Settings* like any other option. - Existing Home Assistant automations keep working: the notification events keep their names (`expiry_alert`, `shopping_add`, `stock_update`) and still fire from the same choke point. `NOTIFY_ENABLED` gates the two new channels only, it does not touch the HA notify service, which keeps its own `HA_ENABLED` + `HA_NOTIFY_SERVICE` switch. - The calendar feed is off until you generate a token, and rotating that token invalidates the old subscription URL immediately. Every item below is the long form of the list above. ### Added - **Outbound notifications without Home Assistant (ntfy + generic webhook)** — push alerts used to be locked behind the HA integration: no Home Assistant, no phone notification. *Settings → 🔔 Notifiche* now configures a free [ntfy](https://ntfy.sh) topic and/or any JSON webhook (n8n, Node-RED, Gotify, a Discord/Slack bridge) and can **send a test notification** that reports the HTTP status of every channel — replacing a failure mode nobody could debug, a silent non-delivery discovered days later. - Events keep the names the HA automations already use (`expiry_alert`, `shopping_add`, `stock_update`) and fire from the same `_fireHaWebhook()` choke point, so an existing automation keeps working untouched. `NOTIFY_ENABLED` is the master switch for the two new channels *only*; the legacy HA notify service keeps its own `HA_ENABLED` + `HA_NOTIFY_SERVICE` gate. Message text is built server-side in `NOTIFY_LANGUAGE` (it/en/de/fr/es/zh), so one household can read notifications in one language while the UI follows the browser. - The ntfy topic is validated as a URL path segment (letters, digits, `-`, `_`, max 64) and the 🎲 button generates a 16-char unguessable one, because the topic *is* the credential on a public server. The body is clamped to 3600 bytes on a UTF-8 boundary, CR/LF is stripped from every header value (Title, Tags, Authorization, custom token header) so a product name cannot inject a header, and only `http(s)` targets are ever contacted. `NOTIFY_INSECURE_SSL` (opt-in) covers LAN servers with a self-signed certificate; the URL is never logged because it may carry the topic. - Tokens are write-only end to end: `get_settings` returns `*_token_set` booleans, the field shows `••••••••` when one is stored and an empty field means "leave the stored value alone". A non-`Authorization` token header (e.g. `X-Api-Key`) is sent verbatim, `Authorization` as `Bearer …`. - New `api/lib/notify.php`, action `notify_test` (POST, demo-blocked) and `scripts/test-notify.php` (44 assertions: priority clamps, UTF-8 clamp boundary, header sanitising, URL scheme guard, the language-aware message builders, `evershelfNotifyConfigured()`). 60 new i18n keys in all six locales; the daily cron expiry push goes through the same fan-out. - **Cron watchdog — a stopped cron job finally says so** — a CLI job that stops running leaves no trace: the smart list quietly goes stale and the UI still looks fine. Every job now pings a dead-man's-switch URL, so *the absence of a ping* becomes an alert on a channel the household already reads. One optional setting (`NOTIFY_HEALTHCHECK_URL`) covers both free services that speak the same idea, and the URL shape picks the protocol: - no query string → **Healthchecks.io** style: the state is a path suffix (`…/`, `…//fail`, `…//start`) and the detail text is the POST body, so it shows up as the check's log. - with a query string → **Uptime Kuma** push style (`…/api/push/?…&status=up|down&msg=`, GET). - `NOTIFY_HEALTHCHECK_URL_` overrides the shared URL per job (`smart_shopping`, `barcode_catalog`, `mealie_cache`); each job sends `start` at the beginning and one terminal `ok`/`fail`, and `mealie_cache` stays silent while Mealie is unconfigured so an unused feature never raises an alarm. - The URL **is** the credential (whoever reads it can silence the alarm), so it is validated as an `http(s)` URL, never logged, never returned by the API and never put into an error string — and a value that is set but not a URL is ignored with a warning instead of silently falling back. `NOTIFY_INSECURE_SSL` is the only way to reach a self-signed LAN Uptime Kuma. - The last outcome of every job is recorded in `data/cron_health.json` (best effort, `start` excluded) and returned by `get_settings` as `notify_healthcheck_status`, so the last run of every job is visible even before a URL is configured; job health and ping delivery are tracked separately, because a failing sync whose alert was delivered fine must not read as healthy. - **Settings → 🔔 Notifiche → ⏱️ Cron watchdog** is where it is configured: the URL field is write-only (masked `••••••••` when stored, empty never overwrites), **⏱️ Test the ping** pings the typed-or-stored URL and reports the HTTP status, **🗑️ Remove URL** deletes it, and the card lists the last run of every job with its age, its health and whether the ping was delivered. 22 new keys in all six locales; asset/i18n stamp → `20261005e`. - New `api/lib/healthcheck.php`, action `notify_healthcheck_test` (POST — pings the typed-or-stored URL and reports the HTTP status without touching the recorded state; the URL may come in the JSON body the panel posts *or* as a plain form field), `notify_healthcheck_*` in `get_settings`, and `scripts/test-healthcheck.php`. - **Three more dashboard panels, and the spend card explains itself.** The rotation carried waste → nutrition → monthly → spend → macros and had nothing to say about the pantry as a whole; the spend panel silently hid itself whenever nothing was logged (so "the tracked spend is always zero" looked like a bug rather than a feature you have to feed). The rotation is eight panels now — overview, waste, trend, nutrition, freshness, monthly, spend, macros (`_INSIGHT_PHASES`) — with: - **overview** (`/dashboard.tile_*`): products, expiring, expired, open packages as tiles, plus where the stock actually is ("🗄️ Dispensa 12 · 🧊 Frigo 5…"); - **freshness** (`dashboard.freshness_title`, `dashboard.score_tracked`): how many products have a tracked expiry, how varied the pantry is, how much of it is fresh (fridge + freezer) and the leading category; - **trend** (`dashboard.trend_*`): products used and thrown away in the last 30 days, each compared with the 30 before it; - **spend** never disappears: with nothing recorded it says so and explains where the number comes from (*close a shopping session from continuous scanning and enter the amount*), showing the current list total when the price module has one (`stats_spend.empty_hint`). - 13 new keys in all six locales, locked by `scripts/test-i18n-icons.php`. - **A setup checklist and a guided assistant, so a new option cannot be missed.** Configurable options only ever lived inside the settings page: to discover one you had to open the right tab, and nothing ever told you that notifications — the whole point of an app pinging you — were still unconfigured. A single registry (`SETTINGS_CHECKLIST` in `assets/js/app.js`) now describes every option, the tab it lives in and whether it is set, and two things read it: - a **checklist card at the top of Settings** — one compact line while everything is configured — that lists only what needs a decision (the unconfigured options plus a *Novità* group, see below) with a **Configura →** button per row that opens the right tab, the right card and flashes it; - the **guided assistant** (the first-run wizard) now asks about notifications and the cron watchdog too, with a 🎲 topic generator and buttons that prove the configuration *before* it is saved (**send a real test notification**, **ping the watchdog**). Every asked step is recorded in `evershelf_setup_seen` together with the item's `askVersion`, so the question is asked **once** — bump that number to re-ask after a genuinely new option — while the checklist keeps showing whatever is still missing. **▶️ Rivedi le opzioni** re-runs the assistant on demand over every open option, not only the never-asked ones. This also fixes the wizard appearing only on a truly fresh install (`evershelf_setup_done`): every later start consults the registry. - The wizard's closing step is no longer the hardcoded index 4 (the list grew to six), and `get_settings` returns `ics_enabled` so the calendar row is not stuck on "not configured". - 12 new keys in all six locales; asset/i18n stamp → `20261005g`; `scripts/test-setup-assistant.php` (275 assertions: registry ↔ real tabs, wizard steps, all six locales, the ask-once ledger, no hardcoded closing index). ### Changed - **The settings page opens collapsed: every card is a sub-section now.** Two panels (*Notifiche*, *Home Assistant*) hold about ten cards each and all of them were open at once, so the option you came for sat somewhere below the fold; the second level was also still a horizontally scrolling pill strip, where the last sub-sections of a section had no visible existence. Now: - every card with a heading becomes a **collapsible sub-section**: its heading and its first hint line stay visible while collapsed, the rest (fields, toggles, test buttons) opens on click, and **only one card is open at a time** — clicking a second header closes the first (`_initSettingsAccordions()`, `_toggleSettingsCard()`, `_closeSettingsCards()`, `.settings-card-head` / `.settings-card-body`). The `h4` stays where it is, so a checklist jump still finds the card *and now opens it* (`_openSettingsCardFor()`); the checklist card itself is exempt, and picking an AI provider or switching to Bring! opens the card it reveals instead of showing an apparently empty panel; - the second level reads as a **sub-section list**: wrapped, indented under its section and labelled (*Sottosezioni* / *Sub-sections* / *Unterabschnitte*), so all of them are on screen instead of scrolling sideways. - The checklist card now lists **only what needs a decision** — unconfigured options plus a *Novità* group for an option whose `askVersion` moved on since you saw it (`_checklistNewsItems()`). Nine rows where six said "Configured" was noise. - `scripts/test-settings-nav.php` grows the accordion + sub-section locks (and the four assertions that had ended up *after* its own `exit()`, so they now actually run); `scripts/test-setup-assistant.php` locks the two-group rendering, and the new `settings.subsections_label` / `settings.checklist.group_todo` / `group_news` keys exist in all six locales. Asset/i18n stamp → `20261005i`. - **The dashboard shows fewer rows, one fact per line, and buttons side by side.** Every block stacked its buttons vertically (name, brand, quantity, location and four action buttons, each on its own line) and the top banner glued every fact into one sentence — "Scaduto da 106 giorni · hai ancora 15 conf (da 37g). · dispensa · 2026-06-21 👀 Scaduto da oltre un mese, controllare integrità confezione" — which says nothing a reader can act on. Now: - **name and brand share one line** (`.alert-item-head`, built by `_alertItemHead()`) and so do **location and quantity** (`_alertItemMeta()` → "🗄️ Dispensa · ne hai ancora 15 conf"), through the new `dashboard.still_have_qty` key; - **buttons sit in a wrapping row** (`.alert-item-badges` is a `flex-wrap` row with compact buttons instead of a `flex-direction: column` stack), so two or three fit per line wherever the row has room — the same layout for expired, upcoming, opened and rarely-used stock; - **expired stock is judged, not just labelled**: `status.tip_*` are full sentences ("Fuori data da oltre un mese: apri, annusa e assaggia…") rendered as `{label} — {tip}` (`dashboard.banner_advice_format`) under each row and in the banner, whose detail is now one fact per line (`.banner-fact` / `.banner-verdict`) plus the expiry date (`dashboard.banner_sold_date`) — eat / check / discard is always spelled out, and the label is the verdict ("Ancora buono", "Controlla", "Butta via") instead of a bare "OK"; - **only the most urgent rows are shown**: 3 upcoming expiries (`DASHBOARD_EXPIRING_MAX`), 5 opened packages (`DASHBOARD_OPENED_MAX`, now sorted by `opened_at` so the *longest* open come first instead of the server's days-to-expiry order), 3 rarely-used items (`DASHBOARD_STALE_MAX`); - **the rarely-used block rotates**: it fetches a pool of 24 and shows a deterministic slice that changes every 30 minutes (`_staleRotationPick()` seeds a shuffle with the half-hour slot; `_startStaleRotation()` re-renders on the tick), so the same window shows the same items and the next one shows different ones; - **🍳 Ricetta veloce moved under the "Scaduti" card** it cooks from, and "Estendi" is now **"Estendi Scadenza"** (`dashboard.banner_expired_action_extend`, all six locales). - Removed while passing through: the TTS test toast printed the raw `{code}` placeholder, a finished-product toast was a hardcoded Italian string (now `toast.finished_all`), and the Fuel badge glued "· target … kcal / ≥…g prot" onto the layout (now `recipes.fuel_badge_target`). Asset/i18n stamp → `20261005h`. - **The settings page now navigates in two levels: four sections, then their tabs.** Sixteen tab buttons shared a single scrolling strip — *Generali, API, Spesa, Ricette, Salute, Piano, Cucina, Camera, Sicurezza, Voce, HA, Notifiche, Bilancia, Backup, Calendario, Info* — mixing unrelated panels (Home Assistant sat between the voice settings and the shopping list), and the Kiosk download banner, the two Kiosk panels and the About card lived *outside* `.settings-panels`, so they were painted below every single tab, whatever you were configuring. The strip is grouped in **🤖 App & AI**, **🍳 Cucina**, **🔔 Avvisi e servizi**, **⚙️ Sistema** and only the tabs of the active section are shown, so the page opens on four buttons instead of sixteen; the Kiosk cards and About moved into *Info*, next to the version and the bug-report button they belong with. - `switchSettingsGroup()`, `_syncSettingsGroupForTab()` and `_restoreSettingsNav()` in `assets/js/app.js`: a tab opened from anywhere (deep link, "configure" button, restore) brings its section forward, the tab strip scrolls the selection into view, and the section + tab you last used reopen on the next visit (`evershelf_settings_group` / `evershelf_settings_tab`). `.click()` is used to switch tabs so each tab keeps its own loader (`_loadNotifyTab`, `_loadHaTab`…). - `scripts/test-settings-nav.php` (58 assertions) locks HTML, JS and translations together: every tab names a section the JS knows, every panel has exactly one tab and every tab exactly one panel, the page opens on a tab of the highlighted section, the four labels exist in all six locales, and the Kiosk/About blocks really live inside `#tab-info` (checked through the DOM, not a substring). Dropping a single `data-group` fails it. - Asset/i18n stamp → `20261005f`; 5 new keys in all six locales. ### Fixed - **Labels no longer print their icon twice.** Lots of the UI showed two identical pictographs in a row — the guided assistant opened on "⏱️ ⏱️ Cron watchdog", "🔔 🔔 Push notifications" and "🎲 🎲 Genera topic", every settings save toasted "✅ ✅ Configurazione salvata!", and the trash bin, the pen and the "✕ Annulla" buttons of the product/edit dialogs doubled themselves. The cause was the same everywhere: a translation value that already carries its emoji (`settings.notify.title = "🔔 Push notifications"`) with code that has to add an icon of its own. Those sites now go through the existing `iconLabel(icon, key)` / `_stripLeadingEmoji()` helpers, which strip the emoji already inside the translation (wizard steps, save toasts, `product.edit_info`, `btn.cancel`, `use.disambiguation_all`, `use.toast_opened_finished`, `recipes.opt_fuel`), and the checklist row/tab markup was verified to keep one icon only. New `scripts/test-i18n-icons.php` (23 assertions) fails on the whole class: it walks `app.js` for an emoji immediately before a `t('key')` whose value starts with the same emoji in any locale (and `index.html` for an icon element followed by a label that repeats it), and it locks the new dashboard copy — verdict format, expiry-date line, "you still have X", fuel target — plus the short verdict labels in all six locales. ## [1.9.0] - 2026-10-05 What shipped between the `1.8.10` tag and this one: the calendar feed, recipe → shopping with the pantry deducted, the seasonal-produce filters, the third CSS layer, and the hardening of the public client-log sink. ### Security - **The public client-log sink wrote whatever a client sent, verbatim and unbounded.** `client_log` is a public action, and its `messages` array went straight into `data/client_debug.log` — the file the in-app log viewer (`get_client_log`) serves and that backups copy around. A client that logs its own request URLs (the documented auth accepts `?api_token=…`) or its headers wrote that token into the file, and one request could append up to `post_max_size` (32 MB in the shipped image) to a log the rotation only checks *before* writing. Lines are now redacted with `evershelfRedactSecrets()` and capped at 1 KB, at most 100 per request; a non-array `messages` is ignored instead of reaching `foreach`. ### Added - **Recipe → shopping list, minus what the pantry already holds** — a recipe used to hand you a frozen list the AI wrote when it was generated (no quantities, and wrong the next day). The 🛒 panel under the ingredients now recomputes the gap every time the recipe is opened, against the pantry as it is *right now*: `need − have = to buy`, so a 500 g recipe with 200 g left asks for 300 g. The rows are tickable and default to the gaps; **Add the missing ones** writes them through `shoppingAddItemsCore()`, the same core the manual add uses, so Bring! sync, the blocklist, generic-name normalisation and the HA webhook are unchanged. - Matching is by `product_id` first, then the shopping-family key, then name tokens (`evershelfNameTokens()`, now shared with `shopping_sync.php`); stock held in another unit (3 ricotta tubs vs "250 g asked") counts as *covered*, and free staples (water, salt, pepper, oil) are never nagged about in strict pantry mode. An ingredient already on the list is reported as *listed*, never added twice. - New action `recipe_shopping_add` (POST, authenticated + CSRF, blocked in demo), accepting a full recipe or a `recipe_id`, with `dry_run` for the page render, `selected[]` for the ticked rows and `only_missing` for "ignore what I have"; new `api/lib/recipe_shopping.php` (`evershelfRecipeShoppingPlan()`, `recipeShoppingAdd()`) and `scripts/test-recipe-shopping.php` (57 assertions on the isolated fixture: unit families, container expansion, g/ml/pz maths, staples, covered/partial/listed states, dry-run purity, "only the gap" inserts). - The panel follows the existing *recipe shopping mode* setting: *ask* (default), *add automatically*, *off*. 11 new i18n keys in all six locales. - **Calendar feed of expiries (ICS / WebCal)** — the pantry deadlines now show up where the household already looks. *Settings → 🗓️ Calendar* turns the feed on, picks the horizon (`ICS_DAYS`, default 30) and keeps recently missed dates visible with a ⚠️ prefix (`ICS_PAST_DAYS`, default 7); the same tab shows the subscribe URL, a copy button and *Open in Calendar* (`webcal://` on phones). Every in-stock row with an expiry date becomes an all-day `VEVENT` with a stable UID (`evershelf-inv-@`), location/quantity/brand in the description and a `-P1D` reminder alarm. Text is escaped and folded per RFC 5545 (multi-byte safe, no line over 73 octets), served as `text/calendar; charset=utf-8` with `X-Robots-Tag: noindex`. - `GET api/index.php?action=calendar_ics&token=…` sits in `evershelfPublicActions()` because a calendar client can only GET a URL: it authenticates with a dedicated read-only `ICS_TOKEN` (`hash_equals`, never logged, 403/404 on mismatch) that Settings mints and *Rotate link* revokes. - New `api/lib/calendar_ics.php`, `api/lib/i18n.php` (the server-side `evershelfTr()` the feed's own labels use) and `scripts/test-calendar-ics.php` (escaping, folding, all-day date maths, feed shape). `scripts/i18n-audit.py` now counts `evershelfTr()` keys, so PHP-rendered strings are audited too. - No QR code, deliberately: the only QR generator in the repo is the third-party `api.qrserver.com` and the subscribe URL is a long-lived credential — unlike the short-lived pairing code it must not leave the server. ### Changed - **The seasonal review tip is translated in all six locales.** The card's tip was built from two hardcoded `it`/`en` tables inside `api/lib/seasonal.php` — a user-facing string the translation audit could not see — so de/fr/es/zh users read English. The card now returns a stable key (`shopping.seasonal_tip_`) that `assets/js/app.js` resolves through `t()`, the same "PHP returns a key, the client translates it" contract as `hint_key`, and the wording lives in `translations/*.json`. The out-of-season block also explains itself now (`seasonal_out_note`): the smart list skips out-of-season fresh produce and brings it back when its season does. - **A third CSS layer re-skins the UI without touching the other two.** `assets/css/elegant.css` loads after `style.css` and `corporate.css` and only re-skins what they already lay out: rounder geometry, a two-layer soft elevation, a floating translucent bottom bar, a translucent header, roomier gutters and a fluid type scale. It deletes no selector, so removing its single `` restores the previous look. It is also where the restyle's dark-mode regressions are repaired — the opened-product tints, `.inv-opened-section` and `--primary` used as a foreground — which neither existing layer answers. ### Fixed - **The seasonal review card asked to remove what the smart list kept suggesting.** The card flagged any catalogue entry the month marks `off`, while the list also exempts the crops that are on the shelf all year: in October the card told you to drop the onions the list was still proposing. Both apply `seasonalIsAllYearCrop()` now. Two matcher bugs surfaced with it: `mel[ae]`, `zucc[ah]` and `rap[ae]` matched as head-noun *prefixes*, so "Melanzane", "Zucchine" and "Rapanelli" counted as all-year crops — the summer produce the winter list exists to hide was exempt from hiding — and the candidate scorer accepted a two-letter tail, so "Zucchero" became "zucca" through the alias "zucche" + "ro". Short stems are matched as whole head nouns now, and only single-letter inflections ("avocados" → "avocado") pass the tail guard. `scripts/test-seasonal-match.php` grows from 57 to 86 assertions, including a card-vs-list consistency lock and a check that every key PHP can emit exists in all six locales (the i18n audit only sees keys used in JS). - **The ⚙️ Config tab was clipped on every phone portrait.** The bottom bar reserved a hard `min-width: 56px` per tab (62px for the Gemini FAB), so seven tabs needed ~398px of row — 462px with the German labels ("Einstellungen") — against the 338px a 360px phone offers. The row overflowed, and because `body { overflow-x: hidden }` there was nothing to scroll: the last tabs (Storico and Config) simply were not there. The tabs now share the row (`flex: 1 1 auto; min-width: 0`, so widths stay as they were where there is room), the icons, logo and FAB scale with the viewport, and below 540px the labels give way to icon-only tabs that keep their accessible name through `aria-label`/`data-i18n-aria` (existing `nav.*` keys — no new translations). That also disambiguates the two tabs that both showed 📋: *Storico* is 🕘 now. ## [1.8.10] - 2026-10-04 Second half of the 2026-10-04 audit: what a `docker build` bakes into the image, how the backup copies the database, and what the API lets out — the CSRF gate on write actions and the error reports that used to leave carrying whatever a client sent. ### Security - **`docker build` no longer bakes the machine it runs on into the image.** `.dockerignore` listed five `data/` files, so `COPY .` dragged in the whole runtime directory: the pantry database, the API/pairing tokens, every cache and `data/mealie/docker-compose.yml`, which holds the Mealie admin password in clear text. Everything mutable under `data/` is excluded now, while the three tracked files the app ships (`data/.htaccess`, `data/.gitkeep`, `data/seasonal_produce_it.json`) are re-included explicitly. Two of the old rules also did nothing for nested files — a pattern without a slash in `.dockerignore` only matches at the context root — so `*.apk` and `__pycache__/` left a 6.7 MB local kiosk APK and a `.pyc` in the image. - **An image built on a developer machine carried twelve files git does not track.** Diffing the built image against `git ls-files` one path at a time found the Health Bridge **signing keystore** (`evershelf-health-bridge/evershelf.jks`), a local certificate (`ca.crt`), 1.5 MB of rotated `data/cron.log.*`, three `logs/evershelf_*.log` application logs, two hand-made logo copies (`assets/img/logo/logo*_backup.png`), a stray `scripts/__pycache__/*.pyc` and whatever APK the developer last built (`releases/evershelf-kiosk.apk`, 6.7 MB). None of them is in the repository and none is needed to run, and all are excluded now: the only untracked files left in the image are the `.env` the Dockerfile creates from `.env.example` and the four MiniLM weights it downloads itself. The context drops from ~114 MB to ~30 MB; `releases/` stays, because `getKioskUpdate()` answers from `releases/kiosk-version.json` (`*.apk` is ignored on purpose — bind-mount your own builds). - **`Dockerfile`, `.dockerignore`, `docs/`, `.github/` and every `*.md` left the build context.** Nothing reads them at runtime, and `COPY .` was publishing them from the web root, where `/Dockerfile` and `/CHANGELOG.md` are free reconnaissance for a scanner. `docker/` stays: the Dockerfile copies `php-evershelf.ini` and `apache-evershelf.conf` out of it, and the Mealie and Avahi code reads `docker/docker-compose.mealie.yml` and `docker/avahi-evershelf.xml` from disk — but the directory is denied in `.htaccess` now, so those two files are no longer served from the web root on a bare install either. Ignoring `docker/` outright (the first version of this) makes **every** build fail at step 5/16 — `COPY docker/php-evershelf.ini` → `COPY failed: file not found in build context or excluded by .dockerignore`. ### Fixed - **A published image and a locally built one are no longer two different artefacts under the same tag.** The MiniLM weights behind offline category classification are gitignored, so GHCR images shipped without them and quietly fell back to the jsdelivr CDN, while an image built on a developer machine carried them. The Dockerfile now fetches the model in a layer of its own, and `scripts/install-transformers-model.sh` retries transient CDN failures that would otherwise take an image build down with them. - **`backup.sh` could lose the newest transactions.** The database runs in WAL mode and the script snapshotted it with `cp`, so everything still sitting in `evershelf.db-wal` stayed out of the backup (and a write in flight could make the copy inconsistent). It now uses SQLite's online backup — `sqlite3 .backup`, or a PHP `PRAGMA wal_checkpoint(FULL)` when the CLI is missing — writes to a `.part` file and renames it, so an interrupted run cannot leave a half-written backup behind for the retention step to keep. The Docker image ships the `sqlite3` CLI, so the fast path is always available there. - **The documented cron had silently done nothing since June.** `INSTALL_DIR` was resolved as `dirname "$0"/..`, the *parent* of the script's directory, so the documented `0 3 * * * /var/www/html/dispensa/backup.sh` looked for `/var/www/html/data/evershelf.db`, found no database and exited 0. That `/..` arrived with `d33b0ca` (2026-06-03): before it, the same line used `dirname "$0"` and worked. The three `dispensa_*.db` snapshots from 2026-04-13 are the last ones a cron ever wrote — everything in `data/backups/` since then came from the backup button in the UI. The path is the script's own directory now, which is right both for a git checkout (`/dispensa/backup.sh`) and for the container (`/var/www/html/backup.sh`). - **The CSRF guard checked 25 actions out of 134.** The list was hand-written and the proof it asked for was a header *or* a JSON content type, so two holes stayed open. Anything not on the list (`chat_save`, `tts_proxy`, `generate_recipe_stream`, the `health_*` writes, …) had no check at all, and `Content-Type: application/json` is not proof of anything: a cross-site `
` sends exactly that content type with a body the attacker chooses, which is the standard way to reach an endpoint that trusts it. Every POST now has to carry `X-EverShelf-Request: 1` — a header a form cannot set, and one that a cross-origin `fetch` can only add after a CORS preflight the server never grants. The content-type fallback survives only for the five actions in `evershelfCsrfExemptPostActions()`, which belong to clients with no browser session to forge: the kiosk APK (`report_error`, `save_settings`), the public log sink (`client_log`), the Health Bridge (`health_ingest`) and the Home Assistant integration (`ha_generate_recipe`). Rejection is a `403 csrf_rejected`, logged. - **One error report could publish the API token on a public tracker.** `report_error` is a public action, and `_createOrCommentGithubIssue()` copied the client's `location.href`, `user_agent`, message, stack trace and `context` object verbatim into an issue. The documented auth accepts `?api_token=…`, and the PWA attaches `location.href` to every error, so an error raised while the app was opened on (or redirected to) such a URL put the token in the issue body and in `data/error_reports.log` — the file that gets copied around by backups. The `context` field was also the largest body `post_max_size` allows: the shipped `php-evershelf.ini` sets 32 MB, so a single report could write ~32 MB into one log line and one issue. Reports are now redacted and capped at every sink. `evershelfRedactSecrets()` strips `key=value` / `"key": "value"` / `Key: value` pairs whose name looks like a credential (`api_token`, `access_token`, `token`, `password`, `secret`, `api_key`, `authorization`, `key`, …), `user:pass@` userinfo in URLs, and tokens recognised by shape (`ghp_…`, `github_pat_…`, `AIza…`, `GOCSPX-…`, `sk-…`, `xox…`); `evershelfReportContextJson()` recurses through the context under a 4 KB budget (1 KB per string, 50 keys per level, 5 levels deep) and always emits valid JSON — invalid UTF-8 and a non-array `context`, which used to be a `TypeError` on the way in, are handled too. - **Publishing to GitHub is a second, explicit opt-in: `REPORT_ENABLED`.** A token in `.env` used to be enough to open issues on a public repository, so a shared `.env` could publish whatever the redaction had not anticipated. `REPORT_ENABLED=false` is the default and stops both `report_error` and `report_bug` before any search or API call (`report_error` answers `{"ok":true,"skipped":"reporting_disabled"}`); `data/error_reports.log` is still written, redacted, so diagnostics are never lost. **Upgrade note:** set `REPORT_ENABLED=true` alongside `GH_ISSUE_TOKEN` to keep receiving automatic issues. `scripts/test-report-redaction.php` pins this down (the leak, the secret shapes, the caps, the false positives, the gates). ### Changed - **Every first-party client sends the CSRF header, and one of them did not.** `assets/js/app.js` already set it whenever a call carried a body; `chat_clear` is a POST with no body, so it would have started failing with the new guard — the wrapper now adds the header for any non-GET method, and the four log/error calls that bypass the wrapper send it explicitly. `mcp-server` and the kiosk (`ErrorReporter`, `SettingsActivity`, `SetupActivity`) send it too; installed kiosk APKs predate this release and keep working through the exempt list, but a script or a fork that posts an AI or inventory action now needs the header. `scripts/test-csrf-guard.php` pins the rule down: the rejected content types, the kept exemptions, that the guard runs before the early-exit branches, and that every exempt action exists. - Web app manifest: the icon set is rebuilt at the sizes a browser actually asks for — 192×192 and 512×512 `any` plus a dedicated 192/512 **maskable** pair, so Android no longer crops the transparent logo to the launcher shape. The two odd-sized entries (557×507, 74×64) are gone. `screenshots` stays absent: the available captures are landscape-only, so Chromium's rich install UI remains off instead of shipping a wrong `form_factor`. - `docs/wiki/Configuration.md` no longer tells people to back the database up with a raw `cp` (and why that is wrong); `docs/wiki/Installation.md` documents the WAL-safe behaviour and `BACKUP_RETENTION_DAYS`. ### Added - `scripts/i18n-audit.py` check `[4]`: it fails when a manifest icon is missing, declares a MIME type that does not match its file, declares `sizes` that differ from the real PNG pixels (read from the IHDR chunk, no Pillow needed), or leaves 192/512 uncovered for both the `any` and the `maskable` purpose. ## [1.8.9] - 2026-10-04 Localisation hotfix: the strings a user reads but that never changed language — tooltips, input placeholders, `aria-label`s, a few status messages and the web app manifest. ### Fixed - **HTML attributes hardcoded in Italian.** 24 `title`/`placeholder`/`aria-label` values had no `data-i18n*` override (settings tabs, search/scan/price buttons, Bring! and health fields, Google Drive code field, empty-list hints…), so they stayed Italian in every locale. - **Placeholders that were already translated but never connected.** The Home Assistant URL / token / media-player / notify-service inputs and the TTS extra-fields box now use their existing keys, so a German user sees `notify.mobile_app_mein_handy` instead of the Italian example. - **Nine strings injected by `app.js` had no key at all** and stayed in the language they were written in: the backup load/error messages, the Bring! rename status and its error tail, the TTS voice/beep statuses and the setup wizard's AI Studio link. - The setup wizard no longer picks between `'configura dopo'` and `'configure later'` with a hardcoded `_currentLang === 'it'` ternary. ### Changed - Web app manifest: the declared icon was `image/svg+xml` for a PNG file with `sizes: "any"`; both icons now declare their real pixel size, and the file gains `lang` and a language-neutral description. - 16 new keys added to all six locales; asset/i18n stamp → `20261004g`. ### Added - `scripts/i18n-audit.py` check `[3]`: it fails when a `title`, `placeholder` or `aria-label` has no `data-i18n*` override and is not an explicitly language-neutral technical example — the regression gate for the bug above. ## [1.8.8] - 2026-10-04 Security and reliability pass over the whole stack: the API token is no longer disclosed on request, the shopping-list logic lives in one place, and the gaps found by the 2026-10-04 audit are closed. > **Upgrading with `API_TOKEN` set?** Your browser will ask for a one-time > **pairing code** (printed in the server log) the first time it loads the UI. > That is the new, intended behaviour — see below. ### Security - **`app_bootstrap` no longer hands out `API_TOKEN` "because you asked nicely".** It used to return the token to any client sending `Sec-Fetch-Site: same-origin` — a header every HTTP client can forge. The token is now disclosed only after presenting a one-time **pairing code** (8 hex chars, 30 min TTL, burned after 50 failed attempts): ```bash grep -i "pairing code" logs/evershelf_*.log | tail -1 # bare metal docker logs evershelf 2>&1 | grep -i "pairing code" | tail -1 # Docker ``` `API_BOOTSTRAP_OPEN=true` restores the old behaviour for fully trusted LANs. - **No authorisation decision is made from client-controlled headers anymore** (`Origin`, `Referer`, `Sec-Fetch-Site` are forgeable). The same-origin bypass was removed from every action, including the ones that run `docker` or rewrite `.env`. - `X-Forwarded-For` is honoured only when the peer is listed in `TRUSTED_PROXIES`, so rate limiting and the pairing attempt counter cannot be evaded by spoofing it. - `mealieWriteEnvKeys()` delegates to the validating `.env` writer (key validation, CR/LF/NUL stripping, comment preservation) instead of writing raw values. - `LOCK_EX` on every `file_put_contents()` call-site under `api/`: concurrent requests can no longer interleave writes to the same JSON state file. - `sw.js` is **network-first** and pre-caches the ZBar/Quagga bundles — the old cache-first worker could pin a stale `app.js` indefinitely, defeating the `Cache-Control: no-cache` headers. - Secrets and build artifacts untracked and added to `.gitignore`. ### Fixed - **Startup dead-end on "API token required".** The pairing dialog is a `.modal-overlay` (z-index 200) and was rendered *behind* the splash preloader (200000) and the network-error overlay (300000). Since `_initApp()` aborts when it cannot authenticate, the preloader was never removed: the app hung on the splash with the code field invisible underneath. The auth overlays now sit at z-index 400000 and the splash shows an explicit *"pair this device"* message instead of a generic token prompt. - **The pairing code looked like it was never printed.** `EverLog` wrote the message as the snake_case token `api_pairing_code`, so the documented `grep "pairing code"` (with a space) matched nothing. The message is now the literal words `API pairing code`, the stable key is preserved as `ctx.event`, and README/SECURITY document the exact command. - **Shopping list: items still in abundance stayed on the list.** `bringCleanupObsolete()` and `internalShoppingCleanupObsolete()` each carried their own copy of the spec/marker/cleanup logic and had drifted apart. They now share `evershelfSmartItemsIndex()`, `evershelfShoppingRowStillNeeded()`, `evershelfBuildShoppingSpec()` and a single `EVERSHELF_SYNC_MARKERS` constant, and the family-stock guard is applied consistently on all four paths. A round-trip lock in the regression test asserts that every spec the auto-add can build is recognised by the cleanup — the exact property whose violation caused the bug. - `loadEnv()` now `putenv()`s every key in addition to filling `$_ENV`, so code using `getenv()` sees the same values as `env()`. - Uncaught errors return JSON instead of a truncated HTML page (`Throwable` is caught in the router) and the `.env` cache is reloaded after a write. - `document.write` removed from `index.html` (ZBar loads via `appendChild`). - Inventory depletion: only true crumbs (≤2 g/ml) count as depleted, so usable leftovers are no longer hidden or wiped (1.8.5 regression guard). ### Changed - Smart-shopping reasons are i18n codes resolved by `_localizeSmartReason()`; the last hardcoded Italian strings (screensaver counter, setup-wizard buttons, startup hints) are gone. - Inventory gains an `inventory(expiry_date)` index; the per-family stock query is memoised once per request. ### Added - `scripts/i18n-audit.py` (used-but-missing keys, run in CI) and `scripts/i18n-value-audit.py` (values still identical to English). - `TRUSTED_PROXIES` support in `evershelfClientIp()` and `EVERSHELF_CANONICAL_HOST` for the forced-HTTPS redirect. - Regression tests: `scripts/test-shopping-guards.php` (12 assertions) and `scripts/test-internal-shopping-cleanup.php` (16 assertions). ### Maintenance - CI runs the PHP test suite, `node --check` on every JS/ESM file (including `sw.js` and `mcp-server/src/**`) and `shellcheck -S warning`. Pushing the workflow edit still needs a `workflow`-scoped token. - The last hardcoded user-facing string in the auth overlays (the API-token input placeholder) is now the `startup.token_prompt_placeholder` key, present in all six locales like every other string. - `data/api_pairing.json` untracked on `main` too: a `git merge` keeps files that only one side has, so the runtime state committed by accident in 1.8.7 survived the first remediation there. It is runtime state, never shipped. - `npm audit` findings resolved; Dependabot coverage widened. - `docs/INDEX-*.md` regenerated and `docs/CODEBASE-MAP.md` line references refreshed. ## [1.8.7] - 2026-09-20 ### Added - **Seasonal shopping (free data)** — Italian produce calendar (SeasonalItaly MIT) suggests in-season fruit/veg to add and flags out-of-season items already on the list so you can remove them in one tap. - **Dashboard: unused stock** — top 3 pantry items unused for a while, with actions: recipe, use, edit, or discard. **Opened / partial packs are ranked first**; sealed staples (salt, sugar…) are deprioritized. ## [1.8.6] - 2026-09-20 ### Added - **Shelf-life learning from your choices** — recovers past *Buttato* reasons and labelled expiry packs (live inventory + local DB backups). On the next add, estimates use your median shelf life and shorten by 1 day per prior expired/spoiled throw (shown as −Nd). *Estendi* and future waste/finish also feed the model. Wrong-location waste can suggest the preferred storage place. ### Fixed - Recipe ingredient rows open the Use panel more reliably (larger hit target). ## [1.8.5] - 2026-09-18 ### Fixed - **Products vanishing from inventory** — depletion threshold for “trace / finished” was 20 g/ml, so usable leftovers (and false “è finito?” banners) could hide or wipe stock. Now only true crumbs (≤2 g/ml) are treated as depleted. Vanished-product banners put **restore** before **finished** so a hasty tap does not write off a full pack (as happened with orange honey). - **Inventory long-press removed** — swipe left already opens Use/Discard, swipe right opens Edit; the extra hold-to-open on inventory rows is gone (scroll stays free). ### Changed - **Recipe ingredients → Use** — tapping a pantry-linked ingredient name in a chat/generated recipe opens the Use panel directly. ## [1.8.4] - 2026-09-16 ### Fixed - **Chat → Recipes wrong ingredients** — pantry matching no longer swaps foods on weak cues (e.g. *Riso Carnaroli* → any *Riso…*, *Noci* → bread with walnuts, *Asiago* → unrelated rows). Distinctive tokens win; short substring hits on long names are rejected. `chat_to_recipe` now receives the pantry name list and must copy ingredient names from the chat text. ## [1.8.3] - 2026-09-12 ### Fixed - **List scroll vs open** — inventory / product / alert rows no longer open on a light tap while scrolling. On touch, hold ~0.9s to open; short tap + drag scrolls. Also stopped capturing the pointer until a horizontal swipe is confirmed (that was blocking scroll). ## [1.8.2] - 2026-09-10 ### Changed - **Comprato** — suppresses auto-re-add until that product is **finished again** (not just until next month). Cleared automatically when you mark it finished / it goes to the shopping list as depleted. - **Rimuovi** — still only suppresses for the rest of the calendar month. ## [1.8.1] - 2026-09-10 ### Changed - **Shopping Comprato / Rimuovi** — auto-re-add is suppressed only for the **calendar month** of the operation (not 15 days). On the 1st of the next month those items can be suggested again. ## [1.8.0] - 2026-09-10 ### Fixed - **Finished banners that kept returning** — products already finished (honey crumbs, ghost milk, aligned basil traces) are reconciled/dismissed server-side; ✕ on “è finito?” now confirms finished instead of only hiding until refresh. - **Banner jumps to first alert** — refreshing the dashboard / chart no longer resets the alert carousel; it stays on the banner you were viewing. ### Changed - Finished detection uses the **ledger gap vs stock**, so matching crumbs (e.g. 0.1 conf = 0.1 conf) are cleared silently without asking again. ## [1.7.99] - 2026-09-10 ### Fixed - **“è finito?” after Finito** — confirming finished is stored server-side so the banner does not come back (e.g. eggs) until you restock. ### Changed - **All banner buttons shortened** in every language (Finito / Buttato / Correggi / Giusta / Lascia / Spiega / …). ## [1.7.98] - 2026-09-10 ### Fixed - **Finished banner after “Finito”** — undoing a use was double-counted in the ledger (ghost fractions like **0.233 conf** milk), so “è finito?” came back even after you finished the pack. Repair migration + undo no longer inserts a second ledger row; **Finito** also clears residual ledger gaps. ## [1.7.97] - 2026-09-10 ### Fixed - **“Product gone” crumbs** — leftover traces (e.g. 2.5 g honey) no longer show as inventory anomalies without a way to clear them; they go to the finished banner with **Finito / Buttato**. - **Anomaly dismiss** — “Giusta” now persists (`a_*_missing|phantom` keys) and aligns the ledger to the shown quantity instead of only hiding the banner. - **Finished reconciliation** — clearing stock no longer double-counts crumbs in the ledger. ### Changed - Anomaly banner actions shortened and include **Finito / Buttato** (all languages). ## [1.7.96] - 2026-09-10 ### Fixed - **Extend expiry on opened items** — “Estendi” now always adds **7 days** and is trusted over opened-shelf-life estimates (`expiry_user_set`), so the banner does not keep coming back (e.g. breadcrumbs / oregano). - **Missing Estendi** — shown on all checkable expired/opened banners (not only produce). ### Changed - Shorter banner actions in all languages: **Finito / Buttato / Modifica / Estendi / Sottovuoto** (and equivalents). ## [1.7.95] - 2026-09-10 ### Fixed - **Anti-waste shopping qty** — perishables (zucchini, tomatoes, carrots, …) are capped to finishable shelf life, not a month of produce. Fresh tomatoes no longer inherit the 730-day “passata” shelf life. - **Shopping horizon** — default is **30 days** (or your inferred shopping-trip cycle when known), not “days until month end”. - **Piece-bag floors** — historical bag size (e.g. 9 carrots) no longer overrides the edible-window use rate. - **Depleted food on the list** — medium/low depleted items now auto-sync to the shopping list (unless you removed/blocked them); existing rows refresh qty specs on each cron. - **Ghost anomaly on finished crumbs** — “less stock than expected” no longer appears for trace leftovers already treated as depleted. ### Changed - Shopping plan UI: Auto = 30d / inferred cycle (was “month end”); max 30 days. ## [1.7.94] - 2026-08-23 ### Fixed - **App startup blocked on kiosk** — Merge corruption in `app.js` swallowed the `_i18nFallback` declaration; `loadTranslations()` / `t()` threw on boot and the preloader never progressed. - **Spesa scan after spend prompt** — Closing the spend modal via overlay or ✕ now clears the scan gate and resumes the scanner (centralized in `closeModal()`). ## [1.7.93] - 2026-08-23 ### Added - **Spesa session list** — Under “Identify with AI”, editable list of products added this trip (name, brand, qty); manual barcode/name/AI tabs hidden in shopping mode. - **Inventory swipe: Use / Discard chooser** — swipe left opens a picker: **Use** or **Discard** (quantity + waste reason). Tap still opens Use; swipe right remains Edit. ### Changed - **Spesa spend prompt** — Barcode scan starts only after you save or skip the optional spend amount. - **Spesa AI** — Manual “Identify with AI” button only; removed automatic AI timers/countdowns. - **Spesa auto-add** — Idle countdown on the add form shortened from 30s to 27s. - **Inventory opened section** — Opened products listed first (after favourites) with green / orange / red row backgrounds by expiry. ### Fixed - **Shopping-list feedback in spesa mode** — “Removed from list” / “Take more…” toasts use server flags and updated IT copy. - **Spesa scan flow** — No family-sibling interrupt mid-scan; duplicate-add cancel keeps you on the form; barcode cache cleared on mismatch retry. ## [1.7.92] - 2026-08-19 ### Fixed - **i18n hardcoded strings** — Removed Italian fallbacks and hardcoded UI text from `app.js`, `index.html`, and API error responses; all user-facing strings now go through translation keys in **it, en, de, fr, es, zh** (1853 keys each). ## [1.7.91] - 2026-08-19 ### Fixed - **Depleted products stuck in Opened alerts** — Trace leftovers (e.g. 19 g butter) were hidden from the inventory list but still shown under Opened; tapping them opened “already exhausted” and **Mark finished** did nothing because `confirmFinished` only deleted zero-qty rows. Crumbs are now cleared (logged as out + removed), Opened filters them out, and alert detail loads depleted stock for Use / Finish actions. ## [1.7.90] - 2026-08-19 ### Fixed - **`spend_stats` PHP crash** — `getSpendStats()` had an unused `PDO $db` parameter but the router called it with no args, causing `ArgumentCountError` on every dashboard load (auto-reports #231–#233). ## [1.7.89] - 2026-08-19 ### Added - **Shopping mode from header** — 🛒 button in the top bar (next to the camera) toggles continuous scan without long-press. - **AI fallback in shopping mode** — if the barcode is not read within 5 s, a cancellable 5 s countdown runs before Gemini Vision identification starts. - **Add-form exit confirmation** — leaving the add page with an unsaved product prompts to save or discard (6 languages). - **Optional spend tracking** — at the end of a shopping session you can record the total amount; monthly panel on the dashboard with trend. - **Gemini in bottom nav** — elevated center tab right after Recipes; removed from the header to free space on mobile. ### Fixed - **Shopping modal vs AI** — the AI countdown no longer starts over the “How much did you spend?” prompt; Cancel truly blocks AI; at most one fallback per scanned product. - **False “Unusual package” banners** — piece goods (`pz`) with label weight (400 g bread, 500 g nectarines) no longer trigger package alerts. - **Milk quantity banners** — corrected ml vs pack interpretation in review thresholds. ### Changed - Simplified header camera tooltip (`scan.hint_short`); shopping/AI/add-form labels in **it, en, de, fr, es, zh**. - PWA cache bump (`evershelf-v9`). ## [1.7.88] - 2026-08-10 ### Changed - **Project website** — all docs/README/wiki/OpenAPI links now point to **[https://evershelf.site/](https://evershelf.site/)** (replacing `evershelfproject.dadaloop.it`). ## [1.7.87] - 2026-08-09 ### Changed - **Shopping list uses anti-waste qty** — list row badges recompute from the edible shelf-life horizon; specs synced/added include `Compra:`/`Almeno:` with that capped qty; ♻️ hint on perishable rows; remaining need after partial buy also respects the edible window. - **README** — Shopping List section documents anti-waste purchase qty and partial restock behaviour. ## [1.7.86] - 2026-08-09 ### Added - **Anti-waste purchase horizon** — for perishables (produce, fresh foods with shelf life ≤21 days), shopping suggested qty uses `min(plan_days, shelf_life)` so items like zucchini are sized to what you can finish before they spoil, not the full month. Softens “bag/bunch” floors when capped; shows reason “Anti-waste: finishable in ~N days”. ## [1.7.85] - 2026-08-08 ### Fixed - **Shopping list stays after partial restock** — buying less than the planned need (e.g. 3 L of milk when ~12 L are needed) no longer removes the item; the list keeps the generic name and updates remaining qty (“still need N”). - **Generic shopping titles** — new list rows store `shopping_name` (e.g. Latte) instead of brand-specific product titles. - **Conf/ml quantity math** — package stock is compared in package counts (not ml×packs), so suggestions like “Compra: 9 conf” are realistic again. ## [1.7.84] - 2026-08-08 ### Fixed - **Spesa / continuous scan confirmation** — scanning in continuous mode again opens the add form (quantity, expiry, location) before saving; the camera reopens after confirm. Silent auto-add without the popup was reverted. ## [1.7.83] - 2026-08-05 ### Added - **Simplified Chinese (`zh`)** — full UI translation by [@duetonever](https://github.com/duetonever) (#228, refs #93), language picker entry `简体中文`, and `zh-CN` date/number formatting. Recipe AI status strings and language rule also support Chinese (plus French/Spanish recipe status locales). ## [1.7.82] - 2026-08-05 ### Security - **mcp-server npm overrides** — bump transitive `hono` (≥4.12.34), `ip-address` (≥10.4.0), and `fast-uri` (≥3.1.5) to clear Dependabot alerts (CORS ReDoS, SSRF/trust-boundary issues in address parsing, host confusion). ## [1.7.81] - 2026-08-05 ### Fixed - **Recipes with finished salad / crumbs** — leftovers below the cookable threshold (e.g. **0.2 pz** Iceberg) were still sent to the AI and rounded up to ¼. New recipes now exclude unfinished crumbs; piece rounding no longer invents a usable quarter from scraps. ## [1.7.80] - 2026-07-31 ### Fixed - **Recipe ingredients vs steps (butter)** — residual stock below the inventory “crumb” threshold (e.g. 19 g butter ≤ 20 g) was hidden from `inventory_list`, so the client unlinked the ingredient while steps still said to use it. Recipe enrich now includes residual stock, re-links orphans, and no longer drops pantry links for crumbs; step scrub only allows pantry-linked ingredient words; `chat_to_recipe` runs full post-process. ### Changed - **Share recipe** — replaced the large “Condividi ricetta” button with a discreet share icon at the top-right of the recipe title. ## [1.7.79] - 2026-07-30 ### Fixed - **Shopping list qty “1 apple / 1 orange”** — near month-end the plan horizon collapsed to 1–2 days, so piece goods (fruit/veg) always suggested **1 pz**. Default horizon is now **at least 7 days**; empty/on-list piece items use **average past purchase size** (or a small pack from use frequency). Piece suggestions can go up to ~12 per line (packages still capped at 3). ## [1.7.78] - 2026-07-30 ### Added - **AI master switch + exclusive providers** (#205) — Settings → API Keys: enable/disable all AI; choose exactly one of **Gemini**, **OpenAI (cloud)**, or **Llama** (local/remote OpenAI-compatible: Ollama, llama.cpp, vLLM…). Connection test button reports latency in ms. - Documented in the main **README** (feature table + `.env` examples) and Installation wiki. ### Changed - OpenAI cloud vs Llama are separate providers (`OPENAI_*` vs `LLAMA_*`); legacy `AI_PROVIDER=ollama|vllm|local` maps to `llama`. ## [1.7.77] - 2026-07-30 ### Added - **Pre-built Docker images on GHCR** (#209) — `ghcr.io/dadaloop82/evershelf` (`latest`, semver tags, multi-arch amd64/arm64). `docker compose pull` works; local `build:` still available for contributors. - **OpenAI-compatible AI routing** (#205, MVP) — first cut of `/v1/chat/completions` routing for non-Gemini backends (expanded in **1.7.78** with exclusive Gemini / OpenAI / Llama + master switch + latency test). ## [1.7.76] - 2026-07-30 ### Added - **Custom storage locations** (#79) — Settings → Generali: add places beyond Pantry/Fridge/Freezer/Other (stored as `CUSTOM_LOCATIONS` in `.env`). They appear in inventory tabs and location pickers. - **Shopping/restocking templates** (#101) — Shopping page → Templates: create named product bundles and apply to the shopping list or inventory in one tap. ### Fixed - **Recipe steps vs ingredients** — cooking steps no longer keep mentioning foods (e.g. butter) that were stripped from the ingredients list or never in the pantry; prompts tightened + post-process scrub. ## [1.7.75] - 2026-07-30 ### Added - **Edit from Use / discard** (#215) — pencil on the Use page hero (and discard modal) opens the same inventory edit sheet as elsewhere. - **Pin favourite products** (#98) — star on each inventory row; favourites stay in a **Favourites** section at the top of the list. State is stored on the product and survives restocks. ## [1.7.74] - 2026-07-30 ### Added - **Weather influence for “A ritmo mio”** — optional setting (off by default) under Recipes. Pick a city via Open-Meteo geocoding (no API key); when Fuel Mode generates a recipe, local conditions (hot/cold/rain…) bias dish style. Preview in settings; badge on the recipe result. Attribution: Open-Meteo (CC BY 4.0). Documented in README under Health Bridge & Fuel Mode. ## [1.7.73] - 2026-07-30 ### Added - **Continuous barcode scanning** (#217) — tap **Continuous** on the scan page (or long-press the header camera). Each barcode is added automatically with smart location/expiry defaults, then the camera reopens for the next item. Tap **Done** to exit. Unknown products still open the full add form. ### Fixed - **Same product, different best-before dates** (#214) — sealed stock rows now merge only when location **and** expiry date match. Two packs of the same barcode with different BB dates stay as separate inventory rows (also applies to multi-batch add and CSV import). ## [1.7.72] - 2026-07-30 ### Added - **CSV inventory import** — 📥 button next to Export on the inventory list (and in Settings). Upload a CSV matching the export schema, review validation + preview, then confirm twice before writing. Unknown columns / invalid rows are rejected; importable rows upsert products and add stock. Fully localized (en/it/de/fr/es). ## [1.7.71] - 2026-07-29 ### Added - **Share recipe** — from the recipe view, share ingredients (scaled to persons) and steps via the system share sheet, WhatsApp, or clipboard. ## [1.7.70] - 2026-07-29 ### Fixed - **Recipe “Use ALL”** — now uses the same checkbox + slide-to-confirm safety as the main Use page (previously only a one-tap danger button). ## [1.7.69] - 2026-07-28 ### Fixed - **HA recipes → Ricette archive** — `ha_generate_recipe` now saves the generated recipe into the EverShelf Ricette tab (same upsert as the app: one recipe per meal per day). Pass `save: false` to skip. ## [1.7.68] - 2026-07-28 ### Added - **`ha_generate_recipe` API** — Home Assistant (and other clients) can generate a full pantry recipe with the same options as the app (`meal`, `persons`, `options` / fuel·veloce·scadenze·…, `meal_plan_type`). Returns structured JSON with `title`, `main_ingredients`, `summary`, plus the full `recipe` object. Pair with HA integration ≥ 1.3.0 (`evershelf.generate_recipe` + event `evershelf_recipe_generated`). ## [1.7.67] - 2026-07-28 ### Fixed - **Smart shopping false “Presto/Urgente”** — depleted product variants (e.g. “Uova”) no longer stay Urgent when another product in the same shopping family still has stock (e.g. 9 “uova medie”). Family coverage now always applies; the 30-day “recently exhausted” exception was wrongly keeping buy suggestions. - **Shopping list cleanup** — internal list removes stale ⚡/🟠 auto-rows when the family is stocked again (Uova/Cipolla/Burro no longer linger as Urgente). - **Use ALL / Throw ALL / recipe deplete** — same checkbox + slide-to-confirm safety on banner finish-all, discard-all, throw-all, throw/use quantities that wipe a location, and recipe “use all”. ## [1.7.66] - 2026-07-27 ### Fixed - **“Use ALL / Finished” mis-taps** — a single inventory row used to skip confirmation entirely (likely how a full pack could be wiped in one tap). Now always requires an acknowledge checkbox + slide-to-confirm, with a large warning that ALL stock will be removed. ## [1.7.65] - 2026-07-26 ### Changed - **Fuel Mode meal budget** — subtracts today’s silent intake (cooked recipes + pantry uses) and redistributes remaining kcal/protein across meals still ahead, so “at my pace” recipes don’t overload the day. ## [1.7.64] - 2026-07-26 ### Added - **i18n completeness** — Health / Fuel Mode / Mealie / shopping keys filled for DE, FR, ES; English is now the universal `t()` fallback (never Italian for other locales). ### Security - **mcp-server** — `fast-uri` → 3.1.4; `@hono/node-server` → 2.0.12 (overrides); Node engine ≥20. Clears Dependabot / Trivy alerts on the lockfile. ### Fixed - **Scanner OOM (#216)** — cap barcode enhance canvas size and catch `getImageData` RangeError on high-res Android cameras. - **Gemini 2.5 for new keys (#212)** — drop `gemini-2.5-flash` from the fallback chain (blocked for new Google AI users); prefer 3.5 / 3.1-lite. - **SQLite busy auto-reports (#218)** — client retries `database_busy` (503) briefly and no longer opens GitHub issues for that expected case. - **Hardcoded Italian UI** — shopping-mode toasts, uncategorized label, “from recipe” specs, Fuel badge fallbacks now use i18n keys. ### Changed - **README** — Health Bridge & Fuel Mode featured as the primary NEW highlight (Home Assistant remains documented below). ## [1.7.63] - 2026-07-26 ### Added - **Health Bridge keep-alive** — persistent notification + battery-optimization exemption so OEMs don’t kill background sync (APK 1.0.3+). - **Fuel Mode as bio-driven generation** — “at my pace” builds the meal from profile goal + today’s activity + pantry; auto-enabled when Health is on; generate button switches to Fuel-mode wording. - **Silent intake from EverShelf only** — recipe cook (once per recipe/day) and pantry “use” (estimated kcal); no manual meal diary. ### Changed - Removed the manual “I ate this” meal-diary action (no extra food diary). ## [1.7.62] - 2026-07-26 ### Added - **Settings → Health** — dedicated tab for personal/biological data with master enable switch (greys out when off). - **Health Bridge Android app** — multilingual setup wizard; QR pairing passes EverShelf URL + token; Health Connect sync. APK built/released by GitHub Actions (`health-bridge-latest`). ## [1.7.61] - 2026-07-26 ### Added - **Fuel Mode (“at my pace”)** — Optional recipe option that builds a deterministic meal calorie/protein budget from a health profile + today’s activity (manual entry initially; Health Bridge later). APIs: `health_status`, `health_ingest`, `health_profile_save`, `health_bridge_token_create`, `health_unlink`. Recipe result shows target vs estimated nutrition match. ### Fixed - **Recipe expiry “expires today”** — Calendar-day `days_left` (no `julianday('now')` time skew); prompts include real expiry dates and prefer the soonest lot; freezer not treated as urgent. ## [1.7.60] - 2026-07-25 ### Fixed - **Critical Gemini cost leak (`classify_category`)** — Smart shopping / Bring sync / family matching called `computeShoppingName()` on every cron cycle (~every 5 minutes). That path hit Gemini for multi-word names, with a fat Bring catalog prompt (~990 input tokens/call), no `thinkingBudget: 0`, and a cache that often failed to persist (empty/truncated answers). Result: **1,000+ classify calls/month** with almost no user action. Settings also under-reported cost using old 2.5 Flash rates while traffic used `gemini-3.5-flash`. - **What we changed to stop it** - Gemini shopping-name classification is **off by default** and **hard-blocked in CLI/cron**; AI runs only when saving a product (HTTP) if a name is still needed. - Durable cache with file lock, negative-cache TTL for misses, purge of truncated junk entries; daily hard cap (default 40). - Slim one-word prompt (no 200-item catalog dump); `thinkingBudget: 0` injected for all Gemini payloads; classifiers prefer **`gemini-2.5-flash-lite`**. - Usage accounting records thinking tokens; Info tab costs use real per-model rates (including 3.5 Flash). - Shelf-life prewarm reduced to 1 item per cron cycle. - **Shopping urgency noise** — “Urgent” / “Soon” limited to frequent staples with real consumption that are empty or nearly empty; medium/low predictions are no longer auto-added to the list. - **Eggs / undo restocks** — `[Undone]` / `[Annullato]` transactions no longer count as purchases; high/critical and expired-only stock are not hidden from predictions. ### Added - **Clear search** — × button on inventory and catalog search bars to wipe the query in one tap. ## [1.7.59] - 2026-07-17 ### Fixed - **Inflated “estimated spend”** — Location moves (`[Spostamento]`) no longer count as consumption. Short-history daily rates use calendar days (min 7), not a 1–2 day activity burst. Hard caps: ≤250 g/day (or 2 L/day), ≤3 packs per suggested line, ≤€25 per priced line. Regression tests: `php scripts/test-shopping-guards.php`. - **Inventory edit save** — `BEGIN IMMEDIATE` / commit helpers so quantity, package size, and vacuum seal persist (PDO transaction mismatch). - **Inventory search** — Search query is preserved when opening/editing a product. - **Package size wipe** — Non-`conf` edits no longer send `package_size: 0` and clear `default_quantity`. - **Scale button** — Removed duplicate ⚖️ icon on “Read from scale” in the edit modal. - **Button contrast** — Secondary/default buttons use a tinted surface + stronger border (no white-on-white on cards/modals). ### Added - **Rename by tapping the title** — In the edit modal, tap the product name to rename inline (no extra name/brand fields). Custom titles lock via `products.name_user_set` so barcode rescans keep them. - **`api/lib/shopping_guards.php`** — Central guards for consumption stats, suggested qty, and price totals. ## [1.7.58] - 2026-07-16 ### Fixed - **Shopping list without Bring!** — Depleting a product now always targets the EverShelf built-in list (`shoppingAddDepletedProduct`). Bring! credentials are no longer required for auto-add on finish. - **Hidden list rows** — The 15-day purchase/remove blocklist no longer hides rows already stored in `shopping_list` when `SHOPPING_MODE=internal` (blocklist only gates auto re-add). - **Finished → buy again** — Finishing a product clears that family's blocklist entry so it can reappear on the list immediately. - **Family stock** — Auto-add skips a generic name when another product in the same `shopping_name` family still has stock. - **German “Butter” generic** — Maps to *Burro* / *Butter* (was truncated to “Bur”). ### Changed - **Shopping architecture** — EverShelf owns the shopping list; Bring! is an optional mirror (`SHOPPING_MODE=bring`). Settings copy and toasts say “shopping list”, not “Bring!”. - **API** — Inventory use responses include `added_to_shopping` (legacy `added_to_bring` kept as alias). ## [1.7.57] - 2026-07-08 ### Added - **Product detail sheet** — Tap an inventory row for a 2×2 action grid: Use, Used all, Create recipe, Discard; edit in header. - **Use form idle countdown** — 15 s reverse progress on submit for piece/package units (grams/ml require manual entry). - **Corporate UI guide** — [`docs/CORPORATE-UI.md`](docs/CORPORATE-UI.md) documents tokens, button intents, and list interaction patterns. ### Changed - **Inventory nav label** — “List” / “Lista” (and equivalents) instead of “Pantry” / “Dispensa” for the list page; location tab names unchanged. - **Swipe left** — Opens Use quantity screen instead of consuming one unit immediately. - **Grams/ml defaults** — Empty quantity field on Use page; user must enter amount (no auto-fill or idle submit). ## [1.7.56] - 2026-07-07 ### Added - **Inventory swipe guide** — Banner above the list explains swipe left = use one, swipe right = edit (all location tabs). - **Per-row swipe cues** — Edge labels (← Use / Edit →) and centered hint on each inventory row. ### Changed - **Inventory swipe input** — Pointer events (mouse drag + touch); row padding adjusted for edge labels. ## [1.7.55] - 2026-07-06 ### Added - **Shopping list urgency API** — `shopping_list` and `ha_shopping_items` return `urgency`, `urgent`, `urgency_label`, `urgency_color` per row (from smart shopping cache + spec markers). - **Urgent row styling** — Critical/high items get colored left border, tinted background, and badge on the shopping page. - **Internal list auto-add** — Cron runs `internalShoppingAutoAddCritical()` when `SHOPPING_MODE=internal` (Bring code unchanged). - **15-day shopping blocklist** — `SHOPPING_REMOVED_BLOCK_DAYS` (default 15): `shopping_remove` and *Bought* suppress cron/UI re-add; `shopping_add` respects blocklist. ### Changed - **Bring disabled by default** — `SHOPPING_MODE=internal`; setup wizard skips Bring credentials; UI labels are mode-aware (not Bring-branded). - **Internal list deduplication** — Merges generic + specific duplicates (e.g. *Milk* + *Fresh mozzarella*) on load and after add. - **Pantry search** — Relevance scoring (name/shopping_name first); no longer matches all dairy when searching *milk*/*latte*; flat sorted results; searches all locations when filtering. - **Shopping footer count** — Piece count uses packages, not raw grams/ml (fixes inflated totals). ### Fixed - **Internal list after purchase** — `shoppingRemoveProductFromList()` updates `shopping_list` when not using Bring; shopping flow always calls client remove in internal mode. - **Expired alerts** — Depleted stock excluded from expired section; 0.9 *pz* displays as 1, not "0 pcs". ## [1.7.54] - 2026-07-04 ### Added - **Add form idle auto-submit** — After 30 s inactivity the Add button submits with reverse progress bar; any interaction resets the timer. - **Barcode scan persistent cache** — Resolved barcodes stored in `localStorage` (up to 500 entries) for instant repeat scans. - **Scan engine preload** — ZBar WASM and Tesseract preloaded at app start, in shopping mode, and when opening Scan. ### Changed - **Faster barcode scan** — Native `BarcodeDetector` first (ZBar after 700 ms); delayed OCR when native is active; optimistic lookup (form immediately, `product_save` in background); shopping fast path (no overlay). - **Shopping mode** — Identification shown in status bar instead of full-screen spinner. ### Fixed - **Inventory edit (swipe)** — ID comparison uses `==` instead of `===` (SQLite returns strings): edit screen opens again. - **Smart shopping cron** — Added `CRON_LOG_PATH` in `constants.php`; 5-minute job no longer fatals. ## [1.7.53] - 2026-07-04 ### Added - **Mealie integration** — Self-hosted recipe book linked to EverShelf: pantry-based recipe pick, offline cache, automatic sync. - **Mealie guided setup** — Settings → Recipes: Docker discovery, install via `docker compose`, URL/token configuration with UI progress. - **Configurable recipe engine** — `RECIPE_SOURCE`: Auto (Mealie → Gemini), Mealie only, Gemini only. - **Recipes → shopping list** — `RECIPE_SHOPPING_MODE`: off / manual confirm / auto-add missing ingredients to Bring! or internal list. - **Recipe settings reorganized** — Tabbed layout: Engine / Mealie / Shopping / Preferences; collapsible Mealie advanced options. ### Changed - **Mealie setup API** — Same-origin access for discovery/install/configure (LAN browser users without manual API token copy). - **Shopping list swipe** — Thresholds and gestures fixed; *Bought · at home* removes from list only (no scan). - **Inventory tap** — Tap opens Use screen with quantity; swipe left/right for quick use and edit. - **PWA service worker** — Dynamic base path (`/dispensa/` etc.); SW disabled on LAN IPs with self-signed certs. ### Fixed - **Mealie configure** — API token obtained automatically with default credentials when the instance is already running. - **Compose write** — `docker-compose.yml` written under `data/mealie/` (writable by www-data). ## [1.7.52] - 2026-07-04 ### Added - **Inventory swipe (#80)** — Swipe left: quick use 1 unit (with undo toast); swipe right: edit item. - **Price sparklines (#81)** — Mini SVG trend in shopping price column (last 5 cache values). - **Mealie integration** — `mealie_list`, `mealie_import`, `mealie_status` API + MCP tools (requires `MEALIE_URL` + `MEALIE_API_TOKEN`). - **Mealie offline cache** — sync recipes to `data/mealie_cache.json`; `RECIPE_SOURCE` (gemini/mealie/auto) and `MEALIE_OFFLINE` (online/offline/auto) in settings. - **MCP HTTP transport** — Remote Streamable HTTP server (`npm run start:http` in `mcp-server/`). ### Fixed - Shopping list section: «Cipolla Dorata» no longer classified as fish (`orata` regex false positive); uses smart category when available. ## [1.7.51] - 2026-07-04 ### Added - **MCP server (beta)** — `mcp-server/` companion for Claude Desktop, Cursor, HA LLM: inventory, expiry, shopping, recipes (`docs/wiki/MCP.md`). - **PWA service worker** — Basic app-shell cache (`sw.js`) for offline UI load. - **Offline barcode catalog** — Weekly cron sync from free sources; lookup works offline after sync. ### Fixed - **#206** — `product_save` INSERT column order matched UPDATE/`productSaveParams()` (new products no longer have shifted DB fields). - **#207** — Docker image includes PHP `zip` and `intl` extensions (`libzip-dev`, `libicu-dev`). - **Settings `.env` write** — Fallback to SQLite `env_overrides` when `.env` is not writable; `.env` permissions should be `660` for www-data. ### Changed - **Shopping list** — Compact mobile UI, swipe actions, plan-days horizon, generic product names, price aligned to suggested qty, scroll preserved on refresh, reduced auto-polling. - **Piece consumption** — Event-based estimates for `pz` items (e.g. onions); quantities respect conf/pz units in display and pricing. ## [1.7.50] - 2026-07-03 ### Changed - **Compact shopping list (mobile)** — Quantity next to title, urgency via colored border only, clearer prices, **Bought** / **Remove** buttons without emoji, collapsible sections, inline suggestions below the list, footer items/pieces count, swipe right = Bought, pull-to-refresh. - **Offline supermarket** — List + forecast cache in localStorage; remove and Bought queued offline. - **Prices** — Estimate aligned to `suggested_qty` (monthly quantity); tap price for AI estimate detail. - **Bought scan** — Warning when barcode does not match the list item. - **Idle** — No automatic return to home while on the shopping list (or Bought scan/add flow). ## [1.7.49] - 2026-06-06 ### Changed - **Shopping list refresh** — Prominent quantities (prior-month consumption), **Bought** with barcode scan and pantry add, smaller prices, mobile-first UI, Bring sync every 45s. ## [1.7.48] - 2026-06-06 ### Fixed - **Products that "vanish"** — Fridge/pantry moves logged (`[Move]`); automatic fallback when deducting from wrong location; mandatory confirm when a recipe consumes all stock of an ingredient. ## [1.7.47] - 2026-06-06 ### Fixed - **Recipe parse_error (e.g. piadina)** — Robust JSON parsing for Gemini recipe responses (balanced braces, truncated JSON repair, object-shaped steps). JSON mode enabled for all recipe endpoints. ## [1.7.46] - 2026-06-06 ### Added - **Offline barcode catalog** — Local `barcode_catalog` table synced weekly from free databases (Open Food Facts IT/world/v0, Open Products/Beauty/Pet Facts, UPCitemdb). Works without internet after sync. Configure via `BARCODE_OFFLINE_ENABLED`, `BARCODE_OFFLINE_SYNC_DAYS`, `BARCODE_LOOKUP_TIMEOUT` in `.env`. ### Fixed - **Barcode 8030582017181 / save errors** — Broader free-source lookup, offline catalog first, save retry on scan, safe duplicate merge (no merge when two different barcodes), `lookup_barcode`/`search_barcode` check local + offline catalog. ## [1.7.45] - 2026-06-06 ### Fixed - **Duplicate catalog products** — Adding stock now auto-merges duplicate catalog entries (same barcode, same name, or similar AI/OFF name) so two packages accumulate on one product instead of splitting inventory across duplicate rows. ## [1.7.44] - 2026-06-06 ### Fixed - **AI product insert (UNIQUE barcode / wrong title)** — Saving an AI-identified product no longer creates duplicate catalog rows or overwrites Open Food Facts data with generic AI guesses. Barcode conflicts merge into the existing product; categories are normalized server-side; the scan flow prefers catalog matches that already have a barcode. ## [1.7.43] - 2026-06-06 ### Fixed - **False expired banner for dry bread products** — Grated bread / breadcrumbs (`pane grattugiato`, panko, etc.) no longer inherit fresh-bread 4-day opened shelf life; they use 90 days in pantry. - **Banner "Edit" → product not found** — Expired-banner edit now loads inventory first (same as other banner types); `editInventoryItem` retries once from the server and refreshes stale alerts if the row is gone. - **Stale banner alerts** — Skip zero-quantity inventory rows; server-side `opened` stats are cross-checked against live stock before showing. - **Opened products still OK** — Banner skips opened items with safety level `ok` or still edible per server stats (they remain visible in the dashboard "Opened" section only). - **Use page for weight products (g/ml)** — Fraction buttons (¼, ½, ¾, all) with gram/ml amounts; default quantity is half of stock instead of 1 g; duplicate-use guard no longer blocks a different amount silently. - **“Finished” on already-empty products** — Marking depleted items (e.g. from Recent shortcuts) no longer fails silently; confirms exhausted state, adds to Bring!/shopping, explains product is still in catalog. `use_all` on empty inventory reconciles instead of fake success. ## [1.7.42] - 2026-06-11 ### Added - **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES. - **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage. - **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments. ### Fixed - **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context. - **SQLite database locked (#201–#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`. - **Smart shopping timeout (#203–#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories. - **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`. - **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented. ## [1.7.41] - 2026-06-08 ### Fixed - **Docker/Traefik server unreachable** — PHP 8.2 deprecation notices (`LoggingPDO::prepare`) were emitted as HTML before JSON, breaking `fetch().json()` on the startup health check; API bootstrap now suppresses HTML error output in production. - **Traefik HTTPS redirect loop** — `.htaccess` skips the HTTPS redirect when `X-Forwarded-Proto: https` is already set (compatible with Traefik `sslheader` middleware); no need to disable `.htaccess` manually. - **LoggingPDO PHP 8.2** — `#[\ReturnTypeWillChange]` on `prepare()` to eliminate deprecation noise in error logs. ## [1.7.40] - 2026-06-08 ### Added - **Qty unit badges** — Quantity inputs show the active unit (g, ml, conf, pz, …) on use, add, recipe-use, edit and throw modals; scale live label “Inserimento in …”. - **Recipe shopping suggestions** — AI recipes can list optional missing ingredients with one-tap add to Bring!/shopping list. - **Recipe frozen badge** — Freezer items flagged in pantry lines and recipe UI; prompt rule for cooking from frozen. - **Health check `db_writable`** — Startup diagnostic detects non-writable SQLite file (common Docker volume issue). - **`scripts/triage-open-issues.php`** — Maintenance helper to comment/close GitHub issues via encrypted token. - **Ops CLI scripts** — `audit-finished-shopping.php`, `backfill-finished-shopping.php`, `sync-shopping-bring.php`, `install-transformers-model.sh` (offline Xenova classifier bootstrap). ### Fixed - **SQLite database locked** — `PRAGMA busy_timeout` 10s + `dbWithRetry()` on `inventory_update` under cron/PWA contention. - **Barcode duplicate on save** — `saveProduct` merges or returns 409 instead of HTTP 500 on UNIQUE barcode. - **EverLog CLI crash** — Safe cast of `REQUEST_METHOD` when null (kiosk/cron). - **Shopping scan crash** — `currentPage` → `_currentPageId` in `_applySpesaScanUI`. - **Recipe quantities** — Piece products use 1 pc base; serving caps for onions, leafy greens, minestrone; pantry-only post-processing; conf/g display fixes. - **Smart shopping purchased block** — Server-side blocklist + shopping mode sync prevents cron from re-adding bought items. ### Changed - **Docker behind Traefik** — Apache `SetEnvIf X-Forwarded-Proto https HTTPS=on` to avoid redirect loops. ## [1.7.39] - 2026-06-06 ### Added - **`resolve_barcode` API** — Single round-trip: local catalog lookup plus **parallel** external search (Open Food Facts IT/world, UPC Item DB, Open Products Facts, Open Beauty Facts via `curl_multi`). Results are stored in SQLite `barcode_cache` for instant repeat scans. - **Shopping scan barcode fast path** — In shopping mode, a successful scan opens the **add form directly** (skips the intermediate action page). - **Session barcode cache** — In-memory cache avoids duplicate API calls when scanning many items in one trip. - **Manual expiry flag (`expiry_user_set`)** — User-entered expiry dates are kept when changing location, vacuum seal, or moving stock; only auto-estimated dates are recalculated. - **Family sibling 24h dedup** — After confirming a similar in-stock product is OK, the check prompt is suppressed for the same `shopping_name` family for 24 hours (synced via `family_sibling_confirmed` in app settings). - **Family sibling stock line** — Shopping scan prompt shows readable stock (e.g. `4 conf (20g each)`); new `family_sibling_check` / `family_sibling_stock` strings in IT/EN/DE/FR/ES. - **Quick-edit product notes** — Notes field in the inline name/brand editor on the product action page. ### Fixed - **Kiosk / WebView stability** — Guard `$_SERVER['REQUEST_METHOD']` when null; fix JS temporal-dead-zone crashes (`setProgress`, `enriched` → `enrichedRaw`, `duplicateNames`); lazy-load ZBar WASM so kiosk startup no longer OOM-crashes. - **Empty barcode SQL error** — Multiple products with `barcode = ''` violated SQLite UNIQUE; empty strings are normalized to `NULL` (migration included). - **Shopping scan ghost products** — Finished/catalog AI candidates and scan recents no longer show zero-stock items in shopping mode; `family_sibling_suggest` requires live inventory quantity. - **Insalata di riso misclassification** — Prepared rice salads (e.g. Ponti) map to `pasta` instead of fresh `verdura`; server and client rules aligned. - **Family sibling prompt readability** — Quantity and question text use high-contrast colours on the dark overlay. - **Move after use / recipe move** — Respects manually set expiry (`expiry_user_set`); purchased items marked on blocklist after shopping add. ### Changed - **Barcode lookup** — Replaced sequential API waterfall (up to ~15s) with parallel fetch (~1–2s first hit); 30-minute negative cache for unknown codes. - **Local barcode search** — Automatically tries EAN-13 / UPC-A variant barcodes. ## [1.7.38] - 2026-06-04 ### Fixed - **Finished products on shopping list** — Depleted items are now added to Bring! under their generic `shopping_name` (e.g. “Affettato”). If the generic is already on the list, the specific variant is appended to the specification instead of being skipped. Confirming a ghost/finished product from the dashboard banner also triggers this flow. - **Unstable shopping total** — Dashboard, Shopping tab, Home Assistant and screensaver now share one **weekly canonical total** (`PRICE_UPDATE_WEEKS=1`). Totals use **1 package per list item** (no more day-to-day swings from smart-shopping suggested quantities). AI prices are fetched only for items missing from cache; manual 🔄 refresh forces an update. - **Screensaver price mismatch** — Screensaver waits for the canonical total sync before displaying the amount, matching the other surfaces. ### Changed - **Shopping list UI** — Generic list entries show the group name with specific finished variants underneath (same pattern as smart shopping suggestions). ## [1.7.37] - 2026-06-04 ### Fixed - **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names. - **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button). ### Changed - **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes. ## [1.7.36] - 2026-06-04 ### Added - **Recipe ingredient stock hints** — Pantry ingredients in generated and archived recipes now show a small line under each item: how much you have in stock and how much would remain after use. Quantities are summed across all storage locations. - **Zero-waste use-all rule** — When the leftover would be less than **5% of the full sealed package** (or **10%** when less than one full unit is left on an opened pack), the recipe quantity is automatically bumped to use everything on hand (♻️ badge + note in all 5 languages). - **Ghost product detection** — Dashboard anomaly banner now surfaces products that vanished from inventory (ledger says stock should exist but no rows remain), with a restore prompt and quantity input. - **`inventory_restore_ghost` API** — Restores a vanished product row from the banner without losing transaction history. - **`product_merge` API** — Merges duplicate product records (inventory, transactions, aliases) into a single canonical product. - **Maintenance scripts** — `scripts/sync-i18n.py` (5-language key sync), `scripts/re-enrich-recipe.php` (re-apply stock hints to archived recipes), `scripts/merge-duplicate-products.php` (batch duplicate merge). ### Fixed - **Unified shopping total** — Dashboard, Shopping page and screensaver now share one canonical server-side total (`shopping_total_cache`); background refresh runs during screensaver too. - **Recipe stream auth** — `generate_recipe_stream` and other direct `fetch()` calls now send the API token consistently, fixing 401 errors during recipe generation. - **Home Assistant auth compatibility** — HA integration endpoints accept the configured API token without breaking legacy setups. - **Security hardening** — API bootstrap modularised; scale SSE relay and sensitive routes require auth; env migration script for legacy installs. - **Dashboard banner i18n** — Fixed raw translation keys (`dashboard.banner_*`) showing in the UI; full sync across IT/EN/DE/FR/ES with cache bust. - **Ghost banner permanently hidden** — Removed incorrect `fin_*` hide logic that suppressed vanished-product alerts after a false "finished" confirmation. - **`deleteInventory` / `use_all` dedup** — Inventory deletions now log transactions; duplicate `use_all` within 60 s is deduplicated; `confirmFinished` reconciles ledger mismatches. - **Duplicate product prevention** — `saveProduct` blocks creating a second product with the same normalised name. - **Recipe qty normalization** — conf+weight ingredients (e.g. ceci, basilico) now keep recipe amounts in grams/ml instead of copying the inventory conf count; use-all percentage is calculated on the sealed package size, not current stock. ## [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 - **HA sensor enrichment** — All HA sensor attributes that list products now include full product details: `location`, `brand`, `category`, `days_remaining`, `opened_at`, `vacuum_sealed`, `default_quantity`, `package_unit`, `product_id`, `inventory_id`. Applies to `expiring_list`, the new `expired_list`, and the new `low_stock_list`. - **HA `expired_list` attribute** — `sensor.evershelf_overview` now exposes `expired_list` (full details for all expired items, not just a count). - **HA `low_stock_list` attribute** — New attribute listing all items with quantity ≤ 1 with full product info. - **HA `sensor=product` endpoint** — New `GET /api/?action=ha_sensor&sensor=product` returns the full inventory with all product details. Optional filters: `&id=N`, `&name=...`, `&location=...`. - **Inventory edit safety guard** — Confirm dialog when saving a quantity that is unusually large for its unit (e.g. 183 conf), preventing accidental data loss from unit-confusion typos. - **Bread shelf-life in fridge** — Opened shelf-life rules added for piadina/crescia (2 days), packaged sliced bread/bauletto (4 days), and generic bread (3 days). ### Fixed - **Recipe AI ingredient substitution** — Added explicit rule to both recipe prompts preventing Gemini from substituting ingredient forms (e.g. fresh tomatoes ↔ passata, fresh milk ↔ UHT ↔ cream, flour 00 ↔ wholemeal). - **HA cron webhook payload** — Expiry alert webhook items now include full product details (brand, category, location, days_remaining, opened_at, vacuum_sealed) instead of only name/qty/unit/expiry_date. ### Docs - `docs/wiki/Home-Assistant.md` — Documented new `sensor=product` endpoint, full product schema table, enriched webhook payload example, and Lovelace/automation template examples using `location` and `days_remaining`. ## [1.7.26] - 2026-05-26 ### Added - **Monthly stats panel** — Third rotating card in the insight banner (anti-waste → nutrition → monthly stats, 1 minute each). Shows products consumed this month with a trend vs. the previous calendar month (↑/↓/→ with % delta), animated horizontal category bars, and badges for items added, wasted, and top-used product. Falls back gracefully when the current month has no transactions. Closes [#100](https://github.com/dadaloop82/EverShelf/issues/100). - **Extended smart-shopping horizon for staples** — Items consumed ≥ 4 times/month now get a 28-day look-ahead window; ≥ 2 times/month get 21 days. Frequently used staples no longer disappear from the smart list between restocks. Closes [#98](https://github.com/dadaloop82/EverShelf/issues/98). ### Fixed - **TTS test interactive confirmation** — Test timeout raised from 4 s to 10 s; instead of an error, the UI shows a YES/NO prompt ("Did you hear it?") so users can confirm or report failure explicitly. - **`end()` PHP 8 reference error** — `_offFetchProduct()` passed the result of `??` directly to `end()`, which requires a variable. Fixed with a temporary variable. - **Database migration crash on fresh installs** — `migrateDB()` tried to rename the `transactions` table before it existed. A `sqlite_master` guard now calls `initializeDB()` and returns early when the schema is absent. Closes [#131](https://github.com/dadaloop82/EverShelf/issues/131), [#133](https://github.com/dadaloop82/EverShelf/issues/133). - **Health-check crash on empty database** — `db_row_count` query was executed even when the `inventory` table was missing, causing a fatal PDO error. The query is now skipped until the schema is fully initialised. Closes [#132](https://github.com/dadaloop82/EverShelf/issues/132). - **Insight banner stuck on one panel** — Rotation interval was 1 hour (effectively invisible); now 60 seconds. `_applyInsightPhase` also now skips empty panels instead of always falling back to the anti-waste card, so the rotation works correctly even when a panel has no data. - **Untranslated OpenFoodFacts category labels** — Categories stored as OFF slugs (`en:plant-based-foods-and-beverages`, `en:dairies`, …) were shown raw. A new `_normalizeCat()` PHP function maps ~60 OFF slugs to Italian app categories; counts are re-aggregated after normalisation so `en:dairies` + `en:milk` both contribute to `latticini`. ## [1.7.25] - 2026-05-25 ### Added - **Home Assistant integration** — Full bidirectional HA support: inventory sensor (`sensor.evershelf_*`) exposes item counts, expiring items, shopping total, opened items and next-expiry info. Webhooks fire on inventory changes (add/use/shopping). Daily cron alert notifies via HA for items expiring within the configured threshold. TTS announces cooking steps through HA Media Player. New Settings tab 🏠 with connection test, TTS preset (Piper, Google, Nabu Casa), webhook config, and YAML snippet for `configuration.yaml`. Resolves [#111](https://github.com/dadaloop82/EverShelf/issues/111). - **Offline mode** — Full offline-first support. Full-screen overlay on network loss; "Continue offline" button after 3 s, auto-enter after 8 s. Inventory and settings are synced to `localStorage` at startup and cached on every successful API call. Writes (add/use/update/delete) are queued and synced on reconnect with optimistic UI updates. Pending operations survive page refresh and are re-synced automatically at next startup. AI/network-dependent sections (anti-waste chart, nutrition analysis, recipe generator, price fetching, Gemini chat) are hidden in offline mode. `remoteLog` and `reportError` are buffered offline and flushed on restore. Broken external images replaced with a grey placeholder. - **Offline-computed dashboard** — While offline, `inventory_summary` and `stats` (expiring/expired/opened) are derived client-side from the local cache so all dashboard stat cards and expiry alerts show accurate data. ### Fixed - **Offline banner flood** — Opened items in the offline `stats` response lacked `is_edible`; `!undefined` evaluated to `true`, causing every opened item to be shown as "not edible" in the dashboard banner. Field is now set to `true` (client-side shelf-life check already handles genuinely expired items). - **Version update badge showing older versions** — `_checkWebappUpdate` used `latestTag !== _loadedVersion` (inequality only), so running a newer dev build triggered an "update available" badge for an older GitHub release. Now uses `_semverGt(latest, current)` so only genuinely newer releases trigger the badge. - **Bring! items re-appearing after manual purchase removal** — `removeBringItem` and `confirmShoppingItemFound` now call `_markBringPurchased` immediately, and `autoAddCriticalItems` respects the blocklist for depleted items. - **Barcode lookup false "not found"** — New `_offFetchProduct()` tries three barcode candidates (given, UPC-A↔EAN-13 conversion) across two Open Food Facts locales with auto-retry. - **Partial throw from expired-items banner** — "Butta" now opens the throw modal (qty + location) instead of silently deleting the entire inventory row. - **Related stock display when scanning branded products** — When scanning a product, the action page now shows a green card listing any inventory items from the same generic family already at home. ## [1.7.24] - 2026-05-21 ### Fixed - **Dark mode resets to Auto on every reload** — `dark_mode` was never saved to `.env` (missing from `saveSettings` and `getServerSettings`). It is now fully server-side like all other settings; `localStorage` retains only a pre-render hint for the flash-prevention IIFE. - **Cooking timer — no sound or speech on Android kiosk** — Three independent root causes fixed: (1) `AudioContext` was created fresh outside a user gesture, starting in `suspended` state and failing silently; a shared pre-unlocked context (`_sharedAudioCtx`) is now created during user gestures (`startCookingMode`, `addCookingTimer`). (2) The `_cookingTTS` gate (for step narration) was incorrectly blocking timer alarm speech — timer alerts now always speak regardless of that flag. (3) `_kioskBridge.speak()` (native Android TTS) was never considered as a fallback when `window.speechSynthesis` is absent in the WebView. - **Scale use ignored for conf products** — `_scaleAutoFillUse()` returned early when `_activeUnit !== 'sub'`, but conf products default to `conf` mode. The function now auto-switches to sub mode before processing the weight reading. Scale button (`btnUse`) is also now visible for conf products that have a g/ml package unit. - **Kiosk — native settings button reappearing unexpectedly** — `closeModal()` was calling `setNativeSettingsVisible(true)`, restoring the native Android settings button after every modal close. `_injectKioskOverlay()` now permanently hides the native button; scattered per-modal show/hide calls removed; a ⚙️ web button opens the in-app settings page. - **SQLite database locked during inventory update** — `updateInventory()` made 3–4 separate write statements without a transaction; a concurrent cron job could acquire the write lock between them, causing a `database is locked` PDO error. All writes are now wrapped in `beginTransaction()`/`commit()`, with the Bring! HTTP sync deferred to after `commit()`. Closes [#109](https://github.com/dadaloop82/EverShelf/issues/109), [#110](https://github.com/dadaloop82/EverShelf/issues/110). - **Depleted-item urgency incorrect** — Items with zero quantity were assigned urgency based on recency of use rather than consumption frequency. Urgency is now computed from `usesPerMonth` only, so frequently-used depleted items are correctly flagged as urgent. - **0.5 conf use and decimal display** — Default mode on the use-quantity page is now conf for conf products; fraction buttons (½, ¼, ¾) work correctly; conf decimals are shown in the transaction history log. - **Bring! health check token warning** — Token validity warning was shown even for valid tokens; health check is now restored with correct token-format detection. - **Recipe quantities for conf+weight products** — Quantities are now calculated correctly when a conf product has a gram-based package unit. - **Shopping settings not syncing across clients** — `shopping_*` keys were missing from `serverKeys` in `_applySyncedSettings`; shopping settings were client-local. All shopping keys now sync from server on load. ### Added - **Native shopping list** — Built-in shopping list (no Bring! required) as an alternative mode (`SHOPPING_MODE=internal`). Resolves [#105](https://github.com/dadaloop82/EverShelf/issues/105). - **Google Drive backup via localhost OAuth** — GDrive backup no longer requires a public domain; the OAuth redirect flow uses `http://localhost` via a temporary local server, compatible with self-hosted setups. Resolves [#107](https://github.com/dadaloop82/EverShelf/issues/107). ### Changed - **All settings fully server-centralised** — Removed remaining `localStorage` usage for user preferences; all settings are now read from and written to `.env` via the API. Preferences are shared across all devices (desktop, phone, kiosk) automatically. ## [1.7.23] - 2026-05-18 ### Added - **⚙️ Generali tab** — new first tab in Settings groups all global settings: language, currency, theme, screensaver, zero-waste tips, inventory export. Old Language tab removed. - **DB auto-cleanup** — `RECIPE_RETENTION_DAYS` (default 7) and `TRANSACTION_RETENTION_DAYS` (default 7) added to `.env`; old rows are deleted automatically every cron cycle, followed by `VACUUM` to compact the database. Manual trigger: `GET /api/?action=db_cleanup`. - **Vacuum-sealed expiry grace period** — `VACUUM_EXPIRY_EXTENSION_DAYS` (default 30): vacuum-sealed products are only flagged as expired N days *after* the printed date, preventing false alarms on long-lasting items like cured meats. - **Gemini AI usage tracking** — monthly and yearly token/cost stats now shown in Settings → ℹ️ Info tab, using tracked data from `data/ai_usage.json`. Cost rates configurable via `GEMINI_COST_25F_IN/OUT` and `GEMINI_COST_20F_IN/OUT` in `.env`. ### Changed - **Auto theme is now time-based** — "Automatico" mode switches to dark at 20:00 and back to light at 07:00, based on server/device clock (not OS preference). Re-evaluates every 5 minutes; ideal for always-on kiosk displays. - **`dispensa.db` auto-deleted** — if the legacy empty `dispensa.db` file appears alongside `evershelf.db`, it is now removed automatically by the health check. - **ZeroWaste tips and screensaver timeout** — these settings were not being persisted to `.env` on save (missing from POST payload); fixed. ## [1.7.22] - 2026-05-17 ### Fixed - **DB name corrected** — `health_check` now looks for `evershelf.db` (was wrongly looking for `dispensa.db`). Auto-migration included: if `evershelf.db` is missing but `dispensa.db` exists, it is renamed automatically on startup. - **Removed legacy `data/dispensa.db`** — the old database file has been deleted; only `evershelf.db` is used. - **Conditional checks** — Bring!, TTS, Scale and Internet checks only run when the respective feature is enabled in `.env` (no more false ❌/⚠️ for unconfigured features). - **Backups check** — no longer checks if `data/backups/` is writable by www-data (cron writes as root). Now checks that backup files actually exist and the most recent one is recent. - **Bring! token check** — reads `data/bring_token.json` file instead of looking for a non-existent `BRING_ACCESS_TOKEN` env var. ### Changed - **Warning popup with 5s countdown** — when non-critical checks fail at startup, a styled popup appears showing each warning with its label and a plain-language hint explaining the problem. A countdown bar auto-closes the popup after 5 seconds, then the app starts normally. - **Error blocking popup** — when critical checks fail, a clear blocking panel shows with title "Errore critico", each failed check listed with its explanation hint, and a Retry button. The app does not start. - **`db_legacy` check added** — warns (optional) if the old `dispensa.db` file is still present alongside `evershelf.db`. - **32 total checks** — added `db_legacy`, `tts_url`, `scale_gateway` to the check set (conditional). - **Hint messages** — every check now has an Italian-language `hint` field explaining what is wrong and how to fix it. ## [1.7.21] - 2026-05-20 ### Changed - **Startup health check** — Complete redesign from a banner checklist to a **real-time progress bar**. The bar fills smoothly as each of 29 diagnostic checks runs, with the current check name shown below in real time. Warnings (⚠️) are displayed as amber badges that remain visible for 2 seconds before the app proceeds. Critical failures turn the bar red and show a detailed error block with a Retry button. - **29 comprehensive checks**: PHP version, 8 PHP extensions (pdo_sqlite, curl, json, mbstring, openssl, fileinfo, zip, intl), PHP memory/timeout/upload config, data directory, rate_limits dir, backups dir, disk write test, free disk space, SQLite connection, required tables, integrity (PRAGMA quick_check), WAL mode, DB size, inventory row count, .env file, Gemini AI key, Bring! credentials, Bring! token, cURL SSL, internet reachability. - Warnings now clearly visible: each non-critical failure shows as a named amber badge (e.g. "⚠️ Bring! token") that cannot be missed. ## [1.7.20] - 2026-05-20 ### Added - **Startup health check** — During the splash screen, the app now runs a comprehensive server-side diagnostic before loading: PHP version, required extensions (pdo_sqlite, curl, mbstring, json), `data/` directory writability, SQLite database connection and table integrity, `.env` file presence, Gemini AI key and Bring! token. Results are displayed as an animated checklist (✅ / ⚠️ / ❌). Critical failures (DB, extensions, data dir) block the app with a clear error message and a "Retry" button — the app never starts silently broken. Non-critical warnings (missing Gemini key, Bring! token) are shown as amber items but do not block startup. - New `?action=health_check` PHP endpoint (early-exit, no rate-limit, no auth). - New translation keys `startup.*` in all 5 languages (IT, EN, DE, FR, ES). ## [1.7.19] - 2026-05-19 ### Added - **Zero-waste tips during cooking** — When cooking mode is active, a ♻️ card appears below each step that generates reusable scraps (peels, cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Gemini generates the tips as part of the recipe JSON at no extra API cost. Tips are dismissible per-step and reset on recipe restart. Opt-in toggle in Settings → Zero-waste tips (default OFF). Resolves [#76](https://github.com/dadaloop82/EverShelf/issues/76). - New translation keys `cooking.zerowaste_*` and `settings.zerowaste.*` in all 5 languages (IT, EN, DE, FR, ES). ## [1.7.18] - 2026-05-19 ### Added - **Dark mode** — New theme selector in Settings (Appearance card): **Off (Light)**, **On (Dark)**, **Auto (follows system)**. Applied immediately on page load to prevent white flash. Resolves [#78](https://github.com/dadaloop82/EverShelf/issues/78). - **Export inventory** — New 📤 button in inventory page header opens a modal to download the inventory as **CSV** (UTF-8 with BOM, Excel-compatible) or open a **print-ready HTML page** (auto-triggers print dialog for PDF). Export card also available in Settings tab. Resolves [#64](https://github.com/dadaloop82/EverShelf/issues/64). - `translations/de.json`: fixed missing `log.recipe_prefix` key. ## [1.7.17] - 2026-05-19 ### Added - **French translation (🇫🇷 Français)** — Complete `translations/fr.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77). - **Spanish translation (🇪🇸 Español)** — Complete `translations/es.json` with all 1049 translation keys. Resolves [#77](https://github.com/dadaloop82/EverShelf/issues/77). - Language selector in Settings now shows all 5 languages: 🇮🇹 Italiano, 🇬🇧 English, 🇩🇪 Deutsch, 🇫🇷 Français, 🇪🇸 Español. - Default fallback language changed from Italian to English (for users with unsupported browser locale). - Setup wizard "Done" screen and navigation buttons localised for French and Spanish. ## [1.7.16] - 2026-05-17 ### Added - **Barcode scan history** — Last 20 scanned products are stored server-side (SQLite `app_settings`) and shown as chips in the scan page (`#scan-recents-chips`). Tapping a chip selects the product directly — no need to scan again. Resolves [#68](https://github.com/dadaloop82/EverShelf/issues/68). - **Full server-side user-data centralisation** — All user preferences previously siloed in `localStorage` per-device are now synced to the server via `app_settings_save` and loaded back at startup via `app_settings_get`. Affected data: shopping tags, pinned Bring! items, location preferences (use/move), auto-added Bring! entries, Bring! purchased blocklist, no-expiry dismissed products. Data is now shared across all devices (desktop, phone, kiosk, Android app). - **One-time localStorage migration** — On first load, any data found in the old localStorage keys (`shopping_tags`, `_userPinnedBring`, `_prefUseLoc`, `_prefMoveLoc`, `_autoAddedBring`, `_bringPurchasedBlocklist`, `_noExpiryDismissed`, `evershelf_scan_recents`) is automatically migrated to the server and the local keys are removed. ## [1.7.15] - 2026-05-16 ### Added - **Full i18n audit** — Comprehensive sweep of all user-visible strings in `app.js` and `index.html`. 25+ new translation keys added across `it.json`, `en.json`, `de.json`, covering: vacuum toast, TTS voice controls, timer step labels, product note labels, error messages, expiry form, barcode hint, category select placeholder, cooking step fallback, `form.select_placeholder`, `btn.yes_short`/`no_short`, `add.vacuum_question`, `add.vacuum_saved`, `move.vacuum_seal_rest`, `cooking.step_fallback`, `error.prefix`/`unknown`, `product.select_variant`, and more. - **Splash screen redesign** — Logo displayed prominently, spinner below, app version shown at the bottom; version label injected dynamically at boot time so it never gets out of sync. Minimum 3-second display duration enforced: `_splashStart` is recorded before `DOMContentLoaded`; the fade-out is delayed by the remaining time if the app loads faster than 3 s. - **Demo GIF in README** — `assets/img/demo.gif` (processed at 2× speed, ~36 s) added to the `## 📸 Screenshots` section. - **`pz`/`conf` unit labels translated** — "pz" now shows as "pcs" in English and "Stk" in German; "conf" shows as "pkg" / "Pkg". All `unitLabels` objects in JS now use `t('units.pz')` / `t('units.conf')`. ### Fixed - **Camera button (📷) opened kiosk SettingsActivity on Android** — The native `btnSettings` ImageButton in the kiosk layout was positioned `top|end` with `alpha=0.12` (nearly invisible), sitting directly on top of the HTML scan button in the webapp header. Every tap on the 📷 button was intercepted by the native View and opened `SettingsActivity`. Fixed: moved `btnSettings` to `bottom|end` (above the bottom nav bar, `marginBottom=80dp`) and increased `alpha` to `0.28` so it is clearly separate from the header. Kiosk versionCode bumped to 16. - **Camera button (📷) opened settings on Android Chrome/Brave** — `pointerleave` fired before `pointerup` when finger drifted slightly, cancelling the long-press timer and leaving the browser to dispatch a synthetic `click` that bubbled to an unintended handler. Fixed: added `setPointerCapture` (prevents `pointerleave` during touch) and `preventDefault` (blocks synthetic click); replaced `pointerleave` with `pointercancel` handler. Added `touch-action: manipulation` to `.header-scan-btn` CSS. - **Logo white background on splash screen** — Re-processed both `logo.png` and `logo_icon.png` with fuzz 35% alpha extraction, removing the white background that was visible against the dark splash background (`#0f172a`). - **Recipe button label** — Shortened to "Ricetta" / "Recipe" / "Rezept" for compact display in the inventory quick-action modal. - **Quantity decimal precision** — `qtyNum` in recipe/cooking ingredient buttons and `conf` fallback display in inventory cards now limited to 1 decimal place (was showing 7+ decimal places from raw AI output, e.g. `0.25353223 conf`). - **"Errore" / "Error" fallback strings** — All remaining Italian hardcoded `'Errore'` fallbacks in `showToast()` calls replaced with `t('error.generic')`. Italian fallback strings removed from buttons that already used `t()`. - **README Italian phrases** — "La quantità è giusta (2 pz)", "🤖 Spiega", "Latte / Affettato / Panna da cucina", "Buon appetito!", "L'ho buttato" replaced with English equivalents in the README. - **Appliance chips translated** — `renderAppliances()` now shows translated names (e.g. "Air fryer" in EN, "Heißluftfritteuse" in DE) for all known canonical Italian appliance names via `_applianceDisplayName()` lookup. `addApplianceQuick` toast no longer hardcoded Italian. Remove-button title translated. - **Gemini API key not preserved on settings save** — `saveSettings()` was overwriting `s.gemini_key = ""` when the Gemini input field was empty (it is intentionally not pre-populated for security). Key is now preserved if the input is blank. `_geminiAvailable` is re-fetched from the server after every settings save so the recipe buttons reflect the real state immediately. ## [1.7.14] - 2026-05-16 ### Added - **In-app bug report form** — "Segnala un problema" now opens a modal form instead of redirecting to GitHub. Users can select type (Bug / Feature / Question), write title and description, optionally add reproduction steps. A GitHub issue is created directly with labels and app metadata attached. ### Fixed - **Kiosk settings button** — "Apri configurazione kiosk" in webapp settings was showing a toast asking to tap a gear icon that no longer exists. Now calls `openNativeSettings()` bridge directly (opens Android SettingsActivity). Fallback for old APKs shows a proper "update the kiosk app" hint. - **False update badge** — `manifest.json` version was `1.7.12` while the app header showed `v1.7.13`, causing the server to report an older deployed version and triggering a spurious update notification. - **Kiosk settings gear disappeared** — Race condition where Kotlin's `onPageFinished` injects `#_kiosk_overlay` before JS runs; JS found the element already present and returned early without ever restoring the native gear button. Fixed: JS no longer hides the native gear on load; `closeModal()` restores it with `setNativeSettingsVisible(true)`. - **`openNativeSettings()` fragile typeof check** — Android `@JavascriptInterface` methods are not always detected as `'function'` by typeof; replaced with try/catch. ## [1.7.13] - 2026-05-16 ### Fixed - **Fresh-install crash: `no such column: undone`** — The `transactions` table was created in `initializeDB()` without the `undone` column, but the composite index `idx_transactions_pid_type_undone` immediately referenced it, crashing every new installation at first DB access. Added `undone INTEGER DEFAULT 0` to the transactions schema in `initializeDB()`. - **Race condition: `duplicate column name: package_unit`** — Concurrent API requests on a new installation could all pass the `PRAGMA table_info` guard simultaneously and each try to `ALTER TABLE products ADD COLUMN package_unit`, with all but the first failing with a PDOException. Wrapped all `ALTER TABLE … ADD COLUMN` calls in try/catch to silently ignore duplicate-column errors. ## [1.7.12] - 2026-05-13 ### Fixed - **"Use first" banner showed a calculated expiry date** — `_renderUseExpiryHint` was displaying a *calculated* shelf-life date (from opening date) instead of the actual one. When `opened_at` is set, the banner now shows "That one [in the fridge], opened X days ago — use it first!" using the new `use.expiry_warning_opened` translation key. - **"Use All / Done" in recipes deleted the inventory row** — `submitRecipeUse(true)` was sending `use_all: true` to the API, which executed a direct `DELETE` on the inventory row without any confirmation. The function now calculates the exact quantity from the available items (`_recipeUseContext.items`) and sends a regular `inventory_use` with an explicit quantity. - **Recipes: `qty_number` returned in grams for piece-counted (`pz`) items** — The AI prompt and PHP post-processing now instruct Gemini to express `qty_number` as whole pieces for ingredients with unit `pz` (sliced bread, crackers, etc.). The ingredient list in the prompt includes `[use whole PIECES]` for each `pz` product. The PHP fallback for `pz` items without `default_quantity` no longer divides by 100, but uses the AI-returned `qty_number` if it is a plausible count, otherwise defaults to 1. ### Added - **Translation key `use.expiry_warning_opened`** — New key in `it.json`, `en.json`, `de.json` with `{loc}` (location) and `{when}` (days since opening) placeholders. ## [1.7.11] - 2026-05-12 ### Added - **Scan page redesign** — The scanner page has been completely redesigned for tablet and mobile: - **2× fixed zoom** — hardware zoom if available, otherwise automatic CSS `scale(2)`. - **Torch** — in-viewport button with toast feedback and visual state indicator. - **Camera flip** — front/back switch with persistence in settings. - **3 input tabs** — Barcode / Name / AI for quick access to each scanning mode. - **Recent products** — chips for the last 6 scanned products (localStorage), with category icon. - **Live code overlay** — partially detected barcode shown as overlay in the viewport during partial scan. - **Confirm overlay** — checkmark + product name displayed for 900 ms on successful recognition. - **Guide corners** — visual alignment frame for barcode centering. - **AI Number OCR** — after 4 s without a scan, a "Read numbers with AI" button appears; Gemini analyses the video frame and returns barcode digits even when the optical scanner fails. - **PHP `gemini_number_ocr` endpoint** — New POST endpoint; accepts a base64 JPEG image, asks Gemini to locate the EAN-13 / EAN-8 code printed on the product, and returns the digits or `not_found`. ### Fixed - **False consumption anomaly positives (e.g. "Mozzarella 3 pcs")** — Removed the `untracked` direction (consumption higher than recorded purchases), which was generating banners for every product with untracked purchase history. Only `phantom` and `missing` anomalies are now reported. - **"~0 g/week" consumption prediction** — The model now requires a minimum of 5 transactions (was 3) and a time span of at least 7 days; predictions where consumption is < 15% of the baseline are skipped, eliminating false positives for products with few closely-spaced transactions. - **Suggestion dropdown on the Name field (scan page)** — Removed `list="common-products"` from the input field; the datalist is no longer triggered on tablets. ## [1.7.10] - 2026-05-11 ### Fixed - **"Set expiry" banner did nothing** — `editBannerNoExpiry()` was calling `openEditInventoryModal()` which does not exist. Fixed to call `editInventoryItem()` (the correct function used by all other banner handlers). Added a prefetch of `inventory_list` because `currentInventory` is empty on the dashboard. - **"Product not found" when opening modal from a banner** — `currentInventory` is always empty on the dashboard; the inventory fetch now happens before opening the modal (same pattern as `editReviewItem` and `weighBannerItem`). - **Expired banner on opened UHT milk** — The banner was showing "Expired!" instead of "Opened too long". Items with `opened_at` now display "Opened X days ago in [location]" in both the title and the banner detail. - **Generic milk shelf life 4 → 7 days** — Milk without qualifiers (e.g. "Milk") was treated as fresh (4 days). Fresh milk is still handled explicitly (`latte fresco/intero/parzial/scremato` → 3 days); the generic case now defaults to 7 days (UHT default). Fix applied in both PHP (`database.php`) and JS (`app.js`). - **Stale `opened_at` on sealed packages after split** — When a use operation splits a row into "whole sealed packages + opened fraction", the sealed-packages row was not clearing `opened_at`. All 3 split code paths now execute `opened_at = NULL` on the sealed row. - **`inventory_update` was not recording transactions** — The quantity-edit modal updated inventory without creating transaction records. The quantity difference is now automatically recorded as `in` or `out` with a `[Manual correction]` note, preventing false positives in the anomaly detector. - **False consumption anomalies after restocking** — The prediction baseline was using only the restock quantity (`restockQty`), ignoring pre-existing stock, causing `actual > expected` systematically. New baseline: `current_qty + consumed_since_last_restock`, which correctly reflects the real situation regardless of prior stock levels. - **Anomaly banner firing on almost all products** — Two fixes: 1. `expected = 0` no longer generates a "more" anomaly (the model assumed you should have run out, but you restocked). 2. "More than expected" threshold raised to 400% (was 30%); "less than expected" threshold remains at 30%. - **Expired section showing already-discarded products** — The `expired` query was missing `AND i.quantity > 0`; discarded products (qty=0) with a past expiry kept appearing. Query fixed and orphan rows cleaned from the DB. - **Hardcoded Italian string `scade il` in banner** — Replaced with the correct i18n key. - **Docker: `SQLSTATE[HY000][14] unable to open database file`** — `_ensureDataDir()` in `database.php` now creates the `data/` directory if missing and attempts `chmod(0775)` if not writable, resolving the error on freshly mounted Docker volumes. ### Added - **Complete i18n** — Added ~25 missing translation keys for kiosk UI, Gemini responses, banners, scanner, shopping, and appliances across all 3 language files (`it.json`, `en.json`, `de.json`). Total: 934 keys per language. ## [1.7.8] - 2026-05-10 ### Added - **Transfer to Recipes from chat** — When the Gemini Chef chat generates a recipe, a "📥 Transfer to Recipes" button appears. Pressing it triggers Gemini to convert the chat text into a complete structured JSON (title, meal, ingredients, steps); the backend enriches each ingredient with `product_id` and `location` via fuzzy-match (identical to `generateRecipe`); the recipe is saved and opens directly in the Recipes section with all "Use" buttons and full cooking mode. - **"Open recipe" button** — After a successful transfer, the "📥 Transfer to Recipes" button transforms into "📖 Open recipe" (same DOM element), preventing overlap. - **Create a recipe from an ingredient** — In the action panel of every inventory item, a "👨‍🍳 Create a recipe with this" button appears (teal, full width). Pressing it, Gemini generates a recipe using that ingredient as the star (same pipeline as `chatToRecipe`: inventory fuzzy-match enrichment, `meal=null`, 8192 token max). - **Meal not auto-categorized** — Recipes generated from chat or from an ingredient are no longer auto-categorized (`meal` remains null); the meal tag in the UI is only shown when explicitly set. ### Fixed - **Smart shopping: false "running low" alert** — If a product in grams/ml was nearly exhausted (e.g. Butter 30 g = 12%) but the same product was also available as a sealed package (Butter 1 pack = 99%), the system still flagged "running low". Now checks whether the `shopping_name` family has stock from other products; if so, the alert is suppressed. - **Corrupted translation JSON** — The `action` section was duplicated in `de.json`, `en.json`, and `it.json`, causing JSON parse errors that blocked CI/CD. The spurious duplicate section has been removed. ## [1.7.7] - 2026-05-10 ### Fixed - **Smart shopping family suppression** — The `recentlyExhausted` logic (products finished < 14 days ago) was incorrectly bypassing the `shopping_name` family suppression, causing false positives: products like Vanilla Yogurt appeared urgent even with 2 kg of Yogurt in stock. `recentlyExhausted` now only bypasses the token-based loose match; family suppression by `shopping_name` always applies. - **Shelf-life pre-warming in cron** — The cron now calls `prewarmShelfLifeCache()` every 5 minutes, pre-loading via Gemini AI the shelf life of opened inventory items (max 5 items per cycle) before the user views them. This eliminates the noticeable delay on first click of "Opened on…". ## [1.7.6] - 2026-05-10 ### Fixed - **`shopping_name` truncated (Piadina)** — The product "Piadine medie" had `shopping_name='Pi'` (truncated), preventing it from grouping correctly in its family. Fixed to `Piadina`. - **Family merges in DB** — Grana Padano now under `Formaggio` (was a `Grana` singleton), Prosciutto cotto now under `Affettato`, Panna acida now under `Panna`. - **`daily_rate` over the actual active period** — The daily consumption rate was using `first_in → now` as the window, diluting the rate with periods when the product was already exhausted (e.g. garlic exhausted at day 34 was calculated over 60+ days). Now uses `first_in → last_activity` (last purchase or last use), giving more accurate reorder predictions. - **Stable anomaly dismiss key** — The dismiss key was using `product_id + round(expected)`, which changed with every new transaction, causing already-dismissed anomalies to reappear. Now uses `product_id + direction` (phantom/missing/untracked) — stable as long as the direction does not change. - **Smart shopping: products exhausted < 14 days ago** — Products finished within the last 14 days are no longer suppressed by the token-coverage check or the shopping_name family check: if you just ran out, you probably want to restock regardless of equivalent stock on hand. - **Chat pruning** — `chatSave()` now deletes messages beyond the 200 most recent after each save, preventing unbounded growth of the `chat_messages` table. ## [1.7.5] - 2026-05-10 ### Added - **Vacuum sealed prompt on item use** — After using a conf/weighted-unit item that still has remaining stock, a sliding popup asks "🔒 Messo sotto vuoto?" with Sì/No buttons and an 8-second auto-dismiss countdown bar. Default is Sì if the item was previously sealed, No otherwise. Works for all container units (conf, g, kg, ml, l) and any item previously marked as vacuum sealed. - **Multi-function appliance awareness in recipes** — When the user sets a multi-function appliance (Cookeo, Bimby, Thermomix, Monsieur Cuisine, Instant Pot, Multicooker, Robot da cucina) in Settings, all Gemini recipe prompts (chat, recipe generation, weekly meal plan) now explicitly instruct the AI to consolidate as many cooking steps as possible into that single machine. Each appliance's available functions (rosolare, tritare, vapore, cuocere a pressione, etc.) are listed and the AI is required to indicate the specific mode/program at each step. - **Server-side Bring! cleanup in cron** — `bringCleanupObsolete()` now runs every 5 minutes via cron without requiring any client page load. Items auto-added by the app (identified by `⚡`/`🟠`/`🛒` markers in their Bring! spec) are automatically removed when the smart shopping engine no longer flags them as needed. Works across all devices/clients. - **`shopping_name` in `inventory_list` API** — The `inventory_list` endpoint now returns the `shopping_name` field from the products table, enabling family-based stock matching in the client-side cleanup fallback. ### Fixed - **Bring! cleanup: false token match (Succo/Frutta)** — `bringCleanupObsolete` previously indexed smart items by product name tokens. "Pera Italiana **Succo** e polpa **frutta**" (shopping_name: "Pere") caused "Succo" and "Frutta" to be retained on Bring! indefinitely even when fully stocked. Now indexes **only** by `shopping_name` tokens. - **Bring! cleanup: expired items with fresh family stock (Verdure)** — When a product is expired but its `shopping_name` family has ≥50% fresh stock from other products (e.g. Minestrone tradizione scaduto 01/05 but 590g fresh Verdure in freezer/pantry), it is no longer flagged as `critical` and is removed from the shopping list. - **Bring! remove: catalog items not removed (Formaggio/Käse)** — `bringRemoveItem()` and `bringCleanupObsolete()` now try both the Italian display name and the Bring! internal German catalog key (e.g. `Käse` for `Formaggio`). Previously, catalog items with a German key were silently not removed. - **Barcode scanner: EAN auto-submit on manual input** — Typing or pasting a valid 8/13-digit EAN in the manual barcode field now auto-submits immediately without needing to press a button. Checksum validation gives a warning toast for invalid codes without blocking entry. - **Shopping list: `isExpiringSoon` false positives** — Products bought in bulk that expire naturally in 3 days (e.g. fresh produce) were flagged `medium` urgency on the shopping list despite having 100%+ stock. Now requires `pctLeft < 50%` before triggering. - **Shopping list: expired batch with fresh restock suppressed** — Products with an expired batch AND a recent fresh restock (≥50% fresh stock) are no longer flagged `critical` for shopping. The expired-batch UI banner on the dashboard handles the disposal prompt instead. - **Shopping list: cross-device cleanup** — Client-side `cleanupObsoleteBringItems()` now detects app-added items by their spec markers (`⚡`/`🟠`/`🛒`) instead of a per-device localStorage map, making cleanup work correctly on all clients including newly logged-in devices. Throttle reduced from 30 minutes to 3 minutes. - **API fetch caching disabled** — All `api()` calls in the frontend now set `cache: 'no-store'` to prevent stale data from browser cache. - **Shopping page multi-client sync** — Added 45-second polling on the shopping page so changes made on another device are reflected automatically. ### Added - **AI price estimation for shopping list** — Each item on the Bring! shopping list now shows an estimated retail price badge (per unit and total). Prices are fetched from Gemini AI and cached server-side for 3 months (`PRICE_UPDATE_MONTHS`). The running estimated total is displayed both in the shopping tab and as a green pill badge on the dashboard stat card. - **Dashboard price total badge** — The shopping stat card on the dashboard shows a green `ca. €X.XX` badge (top-right, same position as the old urgency badge). It updates in real-time as prices are calculated and persists across navigation via `sessionStorage`. - **Background price refresh** — Prices are fetched silently every 2 minutes even when not on the shopping tab, keeping the dashboard badge current without user interaction. - **Smart quantity estimation** — The price payload uses `smart_shopping` data (consumption patterns) to send the correct buy quantity per item; falls back to Bring! spec parsing, then to `qty=1, unit=conf` for manually-added items. ### Fixed - **`stat-price-total` not visible on dashboard** — The total was only computed when `shoppingItems` was populated (i.e. shopping tab had been visited). Now uses `sessionStorage._pricetotal` as fallback so the badge is visible immediately on any page. - **Price bar reloading on every tab switch** — `renderShoppingItems` now checks if ALL items are already cached with matching qty/unit; if so, it applies prices from cache instantly with no loading bar or API call. - **`stat-price-total` real-time update** — Dashboard stat now increments as each individual item is priced (not only after the entire fetch completes). - **Broken emoji in `log.title`** — Corrupted `\uFFFD` character in `it.json` and `de.json` replaced with `📒`. - **`PRICE_CACHE_PATH` undefined crash** — Server-side constant was used inside functions that were called before the define; moved define to the very top of `api/index.php` (line 19). Affected: all `get_shopping_price` and `get_all_shopping_prices` calls from 16:33–16:40 on 2026-05-07. ## [1.7.1] - 2026-05-04 ### Fixed - **Destructive actions now require confirmation** — "Butta tutto" (`throwAll`) and "Finisci tutto" (`submitUseAll`) now display a confirmation modal before executing. The modal features a 5-second auto-confirm countdown bar (red) with an "Annulla" cancel button, matching the scale auto-confirm UX pattern already in use. - **History undo button visibility** — The ↩ undo button in the transaction log was using `color: var(--text-muted)` making it nearly invisible. It now uses a red tint background + border (`#f87171`) with larger font size (1rem) for easy tap targeting. - **History undo uses custom modal** — `undoTransactionEntry()` previously used the native browser `confirm()` dialog (broken in Android WebView kiosk mode). It now uses the same `_showDestructiveConfirm()` modal with countdown. ### Added - **Demo mode (JS frontend)** — Full client-side demo experience: Gemini is treated as available, Bring! write operations silently no-op, and a mock pantry + shopping list is shown; activated via `?demo=1` URL param or `.env` `DEMO_MODE=true`; a "DEMO" badge is injected in the header and Settings is hidden to prevent accidental writes - **Graceful Bring! no-key state** — When Bring! credentials are not configured the shopping tab shows a friendly localised message with a direct link to the Settings page instead of a raw API error - **Use-quantity guard** — Consuming more than the quantity stocked at the selected location is now blocked before the API call; the quantity input shakes (CSS `input-shake` animation) and a toast shows `use.error_exceeds_stock` - **Kiosk: smart auto-discovery rewrite** — `autoDiscover()` now uses `ExecutorCompletionService` + `NetworkInterface` (replaces deprecated `WifiManager`), 60 parallel threads, 600 ms TCP pre-check per host, real-time UI feedback every 120 ms, ports `[443, 80, 8080, 8443]`; VPN/cellular interfaces (tun, ppp, rmnet, pdp, ccmni, etc.) are filtered out and `wlan*`/`eth*` interfaces are prioritised - **Kiosk: permissions button transform** — After permissions are granted, the button changes to "✅ Permessi concessi — Continua →" (green background, dark text) and advances to step 3 on tap, replacing the separate "permissions granted" card - **Kiosk: gateway auto-pre-configuration** — On successful gateway install `finishSetup()` POSTs `scale_enabled=true` + `scale_gateway_url=ws://127.0.0.1:8765` to the server's `save_settings` endpoint so the webapp is scale-ready immediately after setup - **Kiosk: ErrorReporter init at setup start** — `SetupActivity.onCreate()` now calls `ErrorReporter.init()` with any previously saved URL, ensuring errors in step 4 (gateway install) are reported even before the user confirms the server URL ### Fixed - **Kiosk: wrong subnet scanned** — The previous implementation picked up VPN/tun interfaces and scanned a 10.x.x.x range instead of the device's actual Wi-Fi LAN; fixed by filtering interface names and preferring `wlan`/`eth` - **Kiosk: port 443 missing from discovery** — HTTPS servers were never reachable during auto-discovery; ports list extended to `[443, 80, 8080, 8443]` - **Kiosk: gateway install status=1 silent failure** — `PackageInstaller.STATUS_FAILURE` (status 1) showed an error card but never called `ErrorReporter`; `ErrorReporter.reportMessage()` is now called with status code, message, and package name - **Screensaver toggle in web settings** — The screensaver row was missing a `` inside the `` wrapper, so no slider was rendered; corrected to use the same `toggle-row` / `toggle-switch` / `toggle-slider` structure as all other settings toggles - **antiwaste.title translation** — IT and DE locale files were missing the `antiwaste.title` key, causing a raw key string to appear in the anti-waste section header; added to both `it.json` and `de.json` ### Kiosk (v1.4.0 → v1.5.0) - `autoDiscover()` fully rewritten (CompletionService, NetworkInterface, TCP pre-check, real-time feedback, correct LAN subnet) - Port 443 added to discovery scan - Permissions button transforms after grant (`onPermissionsGranted()`) - `ErrorReporter.init()` called at `SetupActivity.onCreate()` - `ErrorReporter.reportMessage()` called on gateway install failure - `finishSetup()` pre-configures gateway via `save_settings` API call ## [1.6.0] - 2026-05-03 ### Added - **Dashboard skeleton loading** — Stat cards (Dispensa / Frigo / Freezer) show an animated shimmer placeholder (`…`) instead of the jarring `0` flash that appeared for 3–5 seconds before data loaded; the loading class is applied before the API call and removed atomically when data arrives - **Webapp startup preloader** — Full-screen spinner overlay during initial app load, fades out after the dashboard is ready - **Webapp update notification** — A dismissible top banner alerts the user when a newer GitHub release is available (checked once every 6 hours, comparison based on `published_at`) - **Native Android update banners** — Both Kiosk (v1.4.0) and Scale Gateway (v2.1.0) show a native top bar when a newer APK is available, with one-tap download and install ### Fixed - **APK install conflict** — Replaced `ACTION_VIEW`-based APK install with the `PackageInstaller.Session` API (API 21+) in both Kiosk and Scale Gateway; the session-based approach correctly handles: - `STATUS_PENDING_USER_ACTION` → automatically launches the system confirmation dialog - `STATUS_SUCCESS` → success toast - `STATUS_FAILURE_CONFLICT` / `STATUS_FAILURE_INCOMPATIBLE` → `AlertDialog` offering to uninstall the old app (signature mismatch) before reinstalling - **Cooking mode z-index** — Update banner and app header are now hidden when `body.cooking-mode-active` is set, and the cooking overlay z-index was raised to `99998` so it can no longer be obscured by UI chrome - **Version-aware error reporting** — GitHub Issues are only created when the client is running the latest released version, avoiding noise from stale deployments; non-semver tag names (e.g. `"latest"`) are treated as "always up-to-date" - **XOR-obfuscated GitHub token** — The PAT used for GitHub API calls is stored as an XOR-encoded hex string in both the PHP backend and Kotlin apps to prevent accidental exposure via secret scanning ### Kiosk (v1.3.0 → v1.4.0) - FileProvider + `REQUEST_INSTALL_PACKAGES` permission added - APK download destination moved to `getExternalFilesDir(null)` (no storage permission needed) - `PackageInstaller` self-update with signature-conflict recovery - BLE scale gateway update banner with download + install flow ### Scale Gateway (v2.0.0 → v2.1.0) - Same FileProvider + permission + `PackageInstaller` changes as Kiosk - Update banner for self-update - CI workflow now triggers on `develop` branch (in addition to `main`) ## [Unreleased] - 2026-04-30 ### Fixed - **Low-qty banner false positive** — A "suspiciously low quantity" review alert is now suppressed for a partially-used inventory entry when one or more sibling entries for the same product (identified by barcode, or name+brand as fallback) exist in other locations with stock > 0. Prevents noise like "191 ml of milk" when 11 sealed packages are stored in the pantry. ### Changed - **Non-alarmist expired banner** — Banner icon, CSS class, and title suffix now adapt to the `getExpiredSafety()` level: - `ok` (long-life products, freezer within margin): green banner, ✅ icon, "— Scaduto (ancora ok)" - `warning` (items that should be inspected): amber/yellow banner, 👀 icon, "— Scaduto (controlla)" - `danger` (raw meat, dairy, fish, etc.): unchanged red 🚫 banner and "— Scaduto!" title - Added `expiry.expired_suffix_ok` and `expiry.expired_suffix_warning` i18n keys to all three language files (IT/EN/DE) - Added `banner-expired-ok` and `banner-expired-warning` CSS variants (green / amber) in `style.css` ## [1.5.0] - 2026-04-28 ### Added - **Expired banner for opened products** — Products whose opened-product shelf-life has passed (e.g. fridge cream opened 6 days ago) now appear in the top notification banner, not just the dashboard list - **Safety-aware expired banner** — Each expired banner item shows a contextual safety tip (from `getExpiredSafety()`); danger-level items (fridge dairy/meat/fish) get an intense red banner and **Discard** as the primary button; safe/warning items keep the original button order - **AI model fallback** — All Gemini API endpoints (expiry scan, product identification, chat, recipe non-streaming, shopping name classifier) now try `gemini-2.5-flash` first and fall back to `gemini-2.0-flash` automatically, matching the resilience already in place for recipe streaming - **Friendly AI quota message** — When the AI returns a quota/rate-limit error the user sees "Quota AI esaurita. Riprova tra qualche minuto." instead of the raw API error string - **Cooking TTS auto-read** — Each recipe step is read aloud automatically when navigating forward or backward; the first step is also read when entering cooking mode - **Cooking timer 10-second warning** — When a cooking timer reaches 10 seconds the TTS announces "Attenzione! [label]: mancano 10 secondi!" - **Cooking recipe completion announcement** — "Recipe complete! Enjoy your meal!" is spoken via TTS when the last step is confirmed ### Fixed - **Cooking TTS gate** — `speakCookingStep()` was blocked by the global `tts_enabled` setting; the `_cookingTTS` toggle (🔊/🔇 button) is now the only gate; browser Web Speech API is used by default without requiring TTS configuration in Settings - **Anomaly dismiss label** — The "Quantity is correct" button now appends the current inventory quantity, e.g. "Quantity is correct (2 pcs)", so the action is unambiguous - **i18n sync** — Added `timer_warning_tts`, `recipe_done_tts`, `error.ai_quota` keys to all three language files (IT/EN/DE) ### Added - **Generic shopping names** — Products are grouped by type ("Latte", "Affettato", "Pasta") rather than brand; computed via an expanded keyword map with Google Gemini AI as fallback for unknown products - **Bring! auto-migration** — Existing list items with old specific names are silently migrated to generic names on every list load, throttled to once per 10 minutes - **Bring! catalog coverage** — All 93 shopping_name values now resolve to a German Bring! catalog key (icons and categories in the Bring! app); 24 aliases added to cover previously unmatched names - **Auto-add to Bring! on depletion** — When a product reaches zero the app adds it to Bring! automatically using the generic shopping name, with the specific product name and brand in the specification field - **Finished-product confirmation banner** — Instead of silently deleting zero-stock entries, a banner prompts the user to confirm; banner title includes the last 3 digits of the product barcode for easier identification - **Anomaly detection banner** — Dashboard notifications for suspicious inventory/transaction mismatches and consumption prediction errors, with one-tap inline correction - **SSE recipe streaming** — Recipe generation streams live via Server-Sent Events; Gemini agent feedback is shown in real time as it is generated - **Smart alert banners** — Configurable expired-only mode with explanatory messages; banner buttons are fully internationalized ### Fixed - **Scale double-deduction** — Multiple BLE stable readings of the same weight no longer fire duplicate `inventory_use` events; JS preserves the confirmation sentinel on submit and PHP rejects a second `out` transaction for the same product within 12 seconds - **Kiosk native TTS** — CI workflow now builds the APK on `develop` branch too; the native Android `TextToSpeech` bridge bypasses Web Speech API voice-availability issues without requiring offline voice packs - **TTS voice loading** — Retries for up to 10 seconds on page load; shows a message if no voices are available and offers a manual refresh button - **Bring! migration** — Corrected two bugs: wrong removal API (`DELETE /item` → `PUT remove=item`) and wrong purchase key sent to Bring! (Italian shopping name → German catalog key), which previously created Italian/German duplicate entries - **Gemini 429 rate limiting** — API calls are retried with exponential backoff; recipe requests are capped at 5 per minute with a dedicated rate-limit bucket ### Performance - **Gemini calls centralized** — All Gemini API requests go through a single `callGemini()` helper with intelligent backoff; Gemini removed from the product-selection and bringSuggest flows in favour of fast offline logic ## [1.3.0] - 2026-04-18 ### Added - **Expired product banner** — Dashboard notifications for expired products with use, throw away, edit, and dismiss actions - **Expiring soon banner** — Dashboard notifications for products expiring within 3 days with use, edit, and dismiss actions - **Priority-sorted notifications** — Banner alerts sorted by urgency: expired > expiring > suspicious quantities > consumption predictions - **Swipe navigation** — Touch swipe left/right to browse banner notifications, with dot indicators and arrow buttons - **Quick-access buttons** — Inventory page shows 4 recently used and up to 8 most popular products for quick selection - **Recent & popular products API** — New `recent_popular_products` endpoint - **Auto-refresh** — Banner notifications refresh every 5 minutes while on the dashboard - **Edit from expiry banner** — Correct expiry dates directly from expired/expiring notifications ### Fixed - **Negative scale values** — BLE scale readings with negative weight are now ignored - **Banner re-appearing after edit** — Editing from a banner now persists the confirmation so it doesn't reappear on dashboard reload - **False consumption predictions** — Manual inventory edits (updated_at > last restock) now use the correct baseline for prediction calculations - **Kiosk overlay blocking header** — Removed injected exit/refresh buttons from the web app header in kiosk mode ## [1.2.0] - 2026-04-13 ### Changed - **Project renamed** from "Dispensa Manager" to **EverShelf** - Contact email updated to `evershelfproject@gmail.com` - Docker service, container, and volume renamed to `evershelf` - SQLite database renamed from `dispensa.db` to `evershelf.db` - All localStorage keys migrated: `dispensa_*` → `evershelf_*` - Apache config file renamed to `evershelf.conf` - CI workflow Docker image/container names updated - App name updated in all translations (it, en, de) - Navigation title updated to EverShelf across all languages ### Added - Version badge (`v1.2.0`) in the app header ### Fixed - JS file truncation caused by `sed` in-place edit on large files - Browser cache invalidation via bumped asset version strings (`?v=20260413a`) ## [1.0.0] - 2026-04-10 ### Added - Complete pantry inventory management (Pantry, Fridge, Freezer, Other) - Barcode scanning with QuaggaJS - Open Food Facts barcode lookup - Google Gemini AI integration (product identification, expiry reading, recipes, chat) - Bring! shopping list integration - Smart shopping predictions with cron-based caching - Cooking mode with step-by-step guidance and TTS support - Opened product tracking with reduced shelf-life calculation - Vacuum-sealed product support with extended expiry - Waste vs. consumption tracking (30-day chart) - Expired product safety assessment by category - Weekly meal plan configuration - DupliClick online grocery ordering integration - PWA support (installable, mobile-first) - Local database backup script - Multi-device settings sync via SQLite ### Security - Centralized `.env` configuration (secrets never in code) - Removed all hardcoded credentials and personal data - Input validation on inventory operations - Parameterized SQL queries throughout