Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 | |||
| d80199e4f1 | |||
| 1637cc1020 | |||
| 904a398009 | |||
| bc39361246 | |||
| 7f173770fc | |||
| b83db76a8d | |||
| cfd089a0a3 | |||
| ade121f43f | |||
| 2f665f777b | |||
| f46b12e3ad | |||
| a932d3de11 | |||
| 6120fad40b | |||
| 8ac6fec5a2 | |||
| fe7587e9e4 | |||
| 4f68925a7c | |||
| f4ea9e74e6 | |||
| 8f217fd166 | |||
| b985247b95 | |||
| efbed479df | |||
| 695c23fc21 | |||
| 7a34406b07 | |||
| 50660f634f | |||
| fb06b42107 | |||
| c16067d9e5 | |||
| 605d8590f6 | |||
| 149cff3ca5 | |||
| ec7d172ed9 | |||
| 0479e34c7f | |||
| 730efe4d87 | |||
| be3dceeebb | |||
| 875250626d | |||
| 245d007e29 | |||
| 63a9f70f86 | |||
| 1a6e0c87ce | |||
| 73f43cb296 | |||
| baed815a48 | |||
| 8aa934f5ca | |||
| 83b5eb3063 | |||
| 59c6f9d76c | |||
| bac9485e4e | |||
| 11178af001 | |||
| 4e4a736dba | |||
| 52afdd6bfa | |||
| 3d27433eb3 | |||
| eddb622c85 | |||
| 95c20adbbd | |||
| 6fa2e4d830 | |||
| 6ff1dfe0cc | |||
| 43e0ac9da3 | |||
| 1ce32cb5f0 | |||
| d75cde7eb6 | |||
| 43fe1c7bb5 | |||
| b2c87ae343 | |||
| fbdae35516 | |||
| d9ebc51e71 | |||
| 56ca58bc18 | |||
| b2e0f6d683 | |||
| ddb9bd9f75 | |||
| 965a672abe | |||
| 7249daa8eb | |||
| ec53f7529c | |||
| 1074dff87d | |||
| 3989d11094 | |||
| b010ced1a6 | |||
| cc0fa09219 | |||
| c0a076749e | |||
| 6a41b53174 | |||
| 1d04236bc0 | |||
| 561c6e9809 | |||
| 6857c20893 | |||
| 964de98203 | |||
| e28a6e4e39 | |||
| fd9e2471e0 | |||
| 3c8a9693b2 | |||
| b38bdc45f5 | |||
| 83a0df272a | |||
| 6320b575e0 | |||
| 8ccd218c5a | |||
| 5c1afaaaf5 | |||
| 6245b15420 | |||
| 02f673a164 | |||
| 61bb1b5552 | |||
| cbf4bd54da | |||
| 1cdbdb3b25 | |||
| 837d62c335 | |||
| fa36ba83bf | |||
| 1efeaf9236 | |||
| 573bcd1102 | |||
| 426cc9df7e | |||
| 6f2d6d9944 | |||
| d3eb82eee2 | |||
| 98426bf861 | |||
| 264b1f648e | |||
| b89df961a6 | |||
| 5e34bc90b3 | |||
| 3b100df26c | |||
| 2ecb3cbac6 | |||
| c2004fd0f8 | |||
| fba0947945 | |||
| 3a1f6cfd1e | |||
| 37fb522e8b | |||
| 66f5a03503 | |||
| a37d97dfcd | |||
| 47197d0d66 | |||
| 149621651d | |||
| b5a6daa557 | |||
| ccc2f8907d | |||
| 9e80915a61 | |||
| 7b60f1dbe3 | |||
| 7019160704 | |||
| ac8b5acc0c | |||
| 34df755ba3 | |||
| 87eac171bf | |||
| ef15f3536c | |||
| f77b3259ad | |||
| 5ad24ed73b | |||
| 84934c1908 | |||
| dd0625b253 | |||
| fa0442e2f6 | |||
| a85414d790 | |||
| c07439fea4 | |||
| 8f6934485a | |||
| d7aadff598 | |||
| d8aff8ac04 | |||
| 7364e75881 | |||
| ff25307662 | |||
| 4515ff7246 |
@@ -89,11 +89,73 @@ PRICE_CURRENCY=EUR
|
||||
# PRICE_UPDATE_MONTHS: how many months to cache a price before re-fetching (default 3)
|
||||
PRICE_UPDATE_MONTHS=3
|
||||
|
||||
# ── Cleanup / retention ──────────────────────────────────────────────────────
|
||||
# RECIPE_RETENTION_DAYS: delete auto-generated recipe plans older than N days
|
||||
RECIPE_RETENTION_DAYS=7
|
||||
# TRANSACTION_RETENTION_DAYS: keep stock transaction history for N days.
|
||||
# Smart Shopping uses this history to compute purchase frequencies.
|
||||
# WARNING: values below 30 will cause the shopping list to appear nearly empty.
|
||||
# Minimum enforced at runtime: 30 days.
|
||||
TRANSACTION_RETENTION_DAYS=90
|
||||
|
||||
# ── Local Backup ─────────────────────────────────────────────────────────────
|
||||
# BACKUP_ENABLED: run a daily incremental backup via cron (true/false)
|
||||
BACKUP_ENABLED=true
|
||||
# BACKUP_RETENTION_DAYS: keep local backups for N days (minimum 1)
|
||||
BACKUP_RETENTION_DAYS=3
|
||||
|
||||
# ── Google Drive Backup ───────────────────────────────────────────────────────
|
||||
# GDRIVE_ENABLED: upload the daily backup to Google Drive (requires a service account)
|
||||
GDRIVE_ENABLED=false
|
||||
#
|
||||
# Setup steps:
|
||||
# 1. Create a Google Cloud project and enable the Drive API
|
||||
# 2. Create a Service Account and download the JSON key
|
||||
# 3. Create a Drive folder and share it with the service account email
|
||||
# 4. Paste the JSON content below (or set GDRIVE_SERVICE_ACCOUNT_FILE to the path)
|
||||
# 5. Set GDRIVE_FOLDER_ID to the Drive folder ID (from its URL)
|
||||
#
|
||||
# GDRIVE_SERVICE_ACCOUNT_JSON: full JSON content of the service account key
|
||||
GDRIVE_SERVICE_ACCOUNT_JSON=
|
||||
# GDRIVE_SERVICE_ACCOUNT_FILE: alternative — path to the service account JSON file
|
||||
GDRIVE_SERVICE_ACCOUNT_FILE=
|
||||
# GDRIVE_FOLDER_ID: ID of the Drive folder where backups will be stored
|
||||
GDRIVE_FOLDER_ID=
|
||||
# GDRIVE_RETENTION_DAYS: delete Drive backups older than N days (0 = keep all)
|
||||
GDRIVE_RETENTION_DAYS=30
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────────────
|
||||
# SETTINGS_TOKEN: if set, the Settings screen requires this token to save changes.
|
||||
# Leave empty to allow anyone with access to the server to change settings.
|
||||
SETTINGS_TOKEN=
|
||||
|
||||
# INSTANCE_NAME: display name for this EverShelf instance (used by the HA integration
|
||||
# for Zeroconf discovery label and device name in Home Assistant).
|
||||
# Defaults to the server hostname if left empty.
|
||||
INSTANCE_NAME=
|
||||
|
||||
# ── Home Assistant Integration ────────────────────────────────────────────────
|
||||
# All HA settings can also be configured from the Settings → 🏠 tab.
|
||||
#
|
||||
# HA_ENABLED: master switch for all HA features (webhooks, TTS, sensors)
|
||||
HA_ENABLED=false
|
||||
# HA_URL: base URL of your HA instance — no trailing slash
|
||||
# Examples: http://homeassistant.local:8123 or http://192.168.1.50:8123
|
||||
HA_URL=
|
||||
# HA_TOKEN: Long-Lived Access Token (HA Profile → Security → Long-Lived Access Tokens)
|
||||
HA_TOKEN=
|
||||
# HA_TTS_ENTITY: media_player entity for recipe step TTS (e.g. media_player.living_room)
|
||||
HA_TTS_ENTITY=
|
||||
# HA_WEBHOOK_ID: ID of an HA automation's Webhook trigger
|
||||
HA_WEBHOOK_ID=
|
||||
# HA_WEBHOOK_EVENTS: comma-separated events to fire webhooks for
|
||||
# Available: expiry, shopping_add, stock_update, barcode_scan
|
||||
HA_WEBHOOK_EVENTS=expiry,shopping_add,stock_update
|
||||
# HA_NOTIFY_SERVICE: HA notify service for push alerts (e.g. notify.mobile_app_my_phone)
|
||||
HA_NOTIFY_SERVICE=
|
||||
# HA_EXPIRY_DAYS: days before expiry to trigger expiry alert (default 3)
|
||||
HA_EXPIRY_DAYS=3
|
||||
|
||||
# ── Developer / demo ─────────────────────────────────────────────────────────
|
||||
# DEMO_MODE: when true, all write operations are blocked (for public demos)
|
||||
DEMO_MODE=false
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Build & Release Kiosk APK
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
lint-php:
|
||||
name: PHP Syntax Check
|
||||
@@ -102,7 +105,9 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Always use the built-in GITHUB_TOKEN for checkout (read-only fetch).
|
||||
# WORKFLOW_PAT is only needed for the push step below.
|
||||
token: ${{ github.token }}
|
||||
|
||||
- name: Configure git bot identity
|
||||
run: |
|
||||
@@ -111,6 +116,15 @@ jobs:
|
||||
|
||||
- name: Merge develop → main
|
||||
run: |
|
||||
# ── ROOT CAUSE FIX ──────────────────────────────────────────────────
|
||||
# actions/checkout writes an http.extraheader (AUTHORIZATION: basic …)
|
||||
# that silently overrides any credentials embedded in git remote URLs.
|
||||
# We must clear it BEFORE setting the remote URL with WORKFLOW_PAT,
|
||||
# otherwise GITHUB_TOKEN is always used for the push and workflow-file
|
||||
# changes are rejected.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
git config --local --unset-all http."https://github.com/".extraheader 2>/dev/null || true
|
||||
|
||||
LAST=$(git log --oneline -1 origin/develop)
|
||||
git checkout main
|
||||
git pull --ff-only origin main
|
||||
@@ -118,6 +132,26 @@ jobs:
|
||||
-m "chore: auto-merge develop → main
|
||||
|
||||
Triggered by: $LAST"
|
||||
|
||||
# ── PUSH STRATEGY ───────────────────────────────────────────────────
|
||||
# Priority 1: WORKFLOW_PAT (classic PAT, repo+workflow scopes)
|
||||
# → can push workflow file changes; set as a repo secret.
|
||||
# Priority 2: GITHUB_TOKEN fallback
|
||||
# → cannot push workflow files; strip them from the merge commit.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
PUSH_TOKEN="${{ secrets.WORKFLOW_PAT }}"
|
||||
if [ -z "$PUSH_TOKEN" ]; then
|
||||
WF=$(git diff --name-only origin/main -- .github/workflows/ 2>/dev/null || echo "")
|
||||
if [ -n "$WF" ]; then
|
||||
echo "::warning::WORKFLOW_PAT not set — stripping workflow changes from merge commit:"
|
||||
echo "$WF"
|
||||
git checkout origin/main -- .github/workflows/
|
||||
git diff --cached --quiet || git commit --amend --no-edit
|
||||
fi
|
||||
PUSH_TOKEN="${{ github.token }}"
|
||||
fi
|
||||
|
||||
git remote set-url origin "https://x-access-token:${PUSH_TOKEN}@github.com/${{ github.repository }}.git"
|
||||
git push origin main
|
||||
|
||||
# ── Auto-create GitHub Release on main ───────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Security Scan (Trivy)
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
@@ -11,6 +11,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||
@@ -36,13 +36,39 @@
|
||||
|
||||
---
|
||||
|
||||
> **⚠️ Name disambiguation:** There is an unrelated iOS app also called **EverShelf**, developed and published by [Joshumi Technologies LLC](https://evershelf.joshumi.com/) on the [Apple App Store](https://apps.apple.com/app/evershelf/id6759439940). That application is a **completely separate, independent product** with no affiliation, association, or collaboration with this open-source project. This repository has no connection to Joshumi Technologies LLC, its products, or its services.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features
|
||||
|
||||
> ⚙️ **New in v1.7.23 — Global settings tab, DB auto-cleanup, vacuum-sealed expiry**
|
||||
> A new **Generali** tab groups all global settings (language, currency, theme, screensaver, zero-waste, export) in one place.
|
||||
> Recipes older than `RECIPE_RETENTION_DAYS` and transactions older than `TRANSACTION_RETENTION_DAYS` are deleted automatically every cron cycle, followed by a SQLite `VACUUM` to keep the database small.
|
||||
> Vacuum-sealed products get an extended grace period (`VACUUM_EXPIRY_EXTENSION_DAYS`, default 30 days) before being flagged as expired.
|
||||
> Auto theme now follows **time of day** (dark 20:00–07:00) instead of the OS setting, making it server-friendly.
|
||||
### 🏠 NEW — Home Assistant Integration
|
||||
|
||||
EverShelf has a **native Home Assistant integration** available on HACS.
|
||||
Connect your pantry to your smart home in minutes — no YAML, no manual sensor setup.
|
||||
|
||||
[](https://my.home-assistant.io/redirect/hacs_repository/?owner=dadaloop82&repository=ha-evershelf&category=integration)
|
||||
|
||||
[](https://my.home-assistant.io/redirect/config_flow_start/?domain=evershelf)
|
||||
|
||||
**What you get:**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **16 sensors** | Expiry counts, stock levels by location (pantry / fridge / freezer), shopping list total, AI API usage, last backup timestamp, days to next expiry |
|
||||
| **6 binary sensors** | Expired items, expiring items, expiring today, shopping list active, backup overdue, Bring! connected |
|
||||
| **5 action buttons** | Refresh data, Refresh prices, **Suggest Recipe** (AI — result as HA notification), Sync smart shopping, Clear expired rows |
|
||||
| **Shopping list todo** | Bidirectional sync — add, remove, check off items directly from HA |
|
||||
| **Expiry calendar** | Every product's expiry date as a native HA calendar event — works with the calendar card and any calendar automation |
|
||||
| **Quick-add text entity** | Type a product name in HA to instantly add it to the shopping list (great for voice assistants / Assist) |
|
||||
| **6 services** | `add_to_shopping`, `mark_used`, `refresh`, `suggest_recipe`, `refresh_prices`, `clear_expired` |
|
||||
| **Auto-discovery** | Detected automatically via Zeroconf/mDNS when `avahi-daemon` runs on the EverShelf host |
|
||||
| **5 languages** | English, Italian, German, French, Spanish |
|
||||
|
||||
> **Requires a self-hosted EverShelf instance.** The integration talks directly to your server — no cloud involved.
|
||||
> Full documentation: [ha-evershelf on GitHub](https://github.com/dadaloop82/ha-evershelf)
|
||||
|
||||
---
|
||||
|
||||
### 📦 Inventory Management
|
||||
- **Export inventory** — Download the full inventory as a UTF-8 CSV (Excel-compatible) or open a print-ready page to save as PDF; export button always visible in the inventory page header
|
||||
@@ -100,7 +126,7 @@
|
||||
|
||||
### 🌙 Appearance
|
||||
- **Dark mode** — Three modes: Light, Dark, and Auto (time-based: dark from 20:00 to 07:00, light otherwise); applies immediately without page reload; auto mode re-evaluates every 5 minutes, so night/day transitions happen automatically even on always-on kiosk displays; theme is applied before the first render to prevent a white flash
|
||||
- **Global settings tab** — A dedicated **⚙️ Generali** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
|
||||
- **Global settings tab** — A dedicated **⚙️ General** tab groups all system-wide settings (language, currency, theme, screensaver, zero-waste tips, export) at the top of the Settings panel
|
||||
|
||||
### �️ Database Maintenance
|
||||
- **Automatic cleanup** — Recipes older than `RECIPE_RETENTION_DAYS` (default 7) and transactions older than `TRANSACTION_RETENTION_DAYS` (default 7) are deleted automatically on every cron cycle; SQLite `VACUUM` runs after each cleanup to keep the file compact
|
||||
@@ -111,7 +137,16 @@
|
||||
- **Mobile-first design** — Optimized for phones, works on tablets and desktop
|
||||
- **Installable** — Add to home screen for a native app experience
|
||||
- **Multi-device** — All user data (shopping tags, pinned items, location preferences, scan history) is stored server-side in SQLite and shared across every device on the same instance; no data is siloed in a single browser's localStorage
|
||||
|
||||
### 📶 Offline Mode
|
||||
- **Automatic detection** — Full-screen overlay appears immediately on network loss; shows a "Continue offline" button after 3 s, and auto-enters offline mode after 8 s
|
||||
- **Local inventory cache** — Inventory is synced to `localStorage` at every startup and on each successful API call; the offline view always reflects the last known state
|
||||
- **Write queue** — Add, use, update and delete operations performed while offline are queued locally and synced to the server automatically on reconnect (including after a page refresh)
|
||||
- **Optimistic UI** — Queued writes are applied immediately to the local cache so the interface stays responsive
|
||||
- **Offline-computed stats** — Expiring and expired items are derived client-side from the cache; dashboard stat cards show real counts instead of zeros
|
||||
- **AI/network sections hidden** — Anti-waste chart, nutrition analysis, recipe generator, price fetching, and Gemini chat are hidden in offline mode; the inventory, history, and manually-managed shopping list remain fully functional
|
||||
- **Broken image fallback** — External product images (Open Food Facts, etc.) that fail to load are replaced with a neutral grey placeholder, keeping the layout intact
|
||||
- **Startup recovery** — If the page is refreshed while operations are queued, they are detected and synced automatically on the next successful startup
|
||||
- **Buffered error reporting** — `remoteLog` and `reportError` calls made while offline are stored locally and flushed to the server (and to GitHub issues) when the connection is restored
|
||||
### ⚖️ Smart Scale Integration (Add-on)
|
||||
- **Bluetooth gateway** — Connects a BLE smart scale to EverShelf via local WebSocket
|
||||
- **SSE relay** — Server-side relay avoids mixed-content (HTTPS→WS) issues
|
||||
@@ -296,6 +331,24 @@ The included `backup.sh` creates local daily backups of your database:
|
||||
0 3 * * * /path/to/evershelf/backup.sh
|
||||
```
|
||||
|
||||
### Google Drive Backup (Optional)
|
||||
|
||||
EverShelf supports automatic daily backups to Google Drive via OAuth 2.0. This works on any server, including private IP / local network setups (no public domain required).
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and select or create a project.
|
||||
2. Enable the **Google Drive API** (`APIs & Services → Enable APIs → Google Drive API`).
|
||||
3. Go to `APIs & Services → Credentials → Create Credentials → OAuth client ID`.
|
||||
4. Application type: **Web application**.
|
||||
5. Add **`http://localhost`** as an Authorized Redirect URI (this is the key — it works even without a real domain).
|
||||
6. Copy **Client ID** and **Client Secret** into EverShelf Settings → Backup.
|
||||
7. Enter your **Google Drive Folder ID** (the last part of the folder URL).
|
||||
8. Click **Authorize with Google** and sign in.
|
||||
9. The browser will redirect to `http://localhost` and may show a connection error — **this is expected**. Copy the full URL from the address bar (e.g. `http://localhost/?code=4%2F0A...`) and paste it into the field that appears in EverShelf, then click **Submit**.
|
||||
|
||||
> **Note:** While the OAuth app is in *Testing* status in Google Cloud Console, you must add your Google account as a test user under `APIs & Services → OAuth consent screen → Test users`.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
@@ -421,6 +474,54 @@ Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed g
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
EverShelf is a community project and contributions of any size are welcome!
|
||||
|
||||
### Easiest way to start — translate EverShelf into your language
|
||||
|
||||
Translations are just JSON files. No coding, no setup — fork → edit → PR.
|
||||
|
||||
```
|
||||
translations/
|
||||
├── it.json ✅ Italian (base)
|
||||
├── en.json ✅ English
|
||||
├── de.json ✅ German
|
||||
├── fr.json ✅ French
|
||||
├── es.json ✅ Spanish
|
||||
├── pt.json ❌ Portuguese — wanted!
|
||||
├── nl.json ❌ Dutch — wanted!
|
||||
└── ... ❌ Your language here!
|
||||
```
|
||||
|
||||
👉 See [issue #93](https://github.com/dadaloop82/EverShelf/issues/93) to claim a language.
|
||||
|
||||
### Other ways to contribute
|
||||
|
||||
| What | Skill needed |
|
||||
|---|---|
|
||||
| 🐛 Report a bug | None |
|
||||
| 📖 Improve the wiki | Markdown |
|
||||
| 🌍 Add a translation | JSON editing |
|
||||
| 🎨 Fix a CSS/UI issue | CSS / HTML |
|
||||
| ⚙️ Implement a feature | PHP / JS |
|
||||
| ⭐ Star the repo | Clicking |
|
||||
|
||||
👉 Browse [`help wanted`](https://github.com/dadaloop82/EverShelf/labels/help%20wanted) issues for good starting points.
|
||||
|
||||
Read [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide (branch naming, code style, how to run locally).
|
||||
|
||||
---
|
||||
|
||||
## 💬 Community
|
||||
|
||||
Join the conversation in [GitHub Discussions](https://github.com/dadaloop82/EverShelf/discussions):
|
||||
- **Vote on upcoming features** — tell us what to build next
|
||||
- **Show your setup** — share your kitchen kiosk
|
||||
- **Ask questions** — get help from the community
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the **MIT License** — see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
+152
-1
@@ -87,11 +87,45 @@ try {
|
||||
ob_end_clean();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup done'
|
||||
. ' (recipes >' . env('RECIPE_RETENTION_DAYS','7') . 'd'
|
||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','7') . 'd' . ")\n";
|
||||
. ', tx >' . env('TRANSACTION_RETENTION_DAYS','90') . 'd' . ")\n";
|
||||
} catch (Throwable $ce) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] DB cleanup warning: ' . $ce->getMessage() . "\n";
|
||||
}
|
||||
|
||||
// ── Daily incremental backup ──────────────────────────────────────────
|
||||
// Create a local backup at most once every 23 h; also push to Google Drive
|
||||
// if GDRIVE_ENABLED=true. The guard prevents multiple backups per day even
|
||||
// though the cron runs every 5 minutes.
|
||||
if (env('BACKUP_ENABLED', 'true') === 'true') {
|
||||
try {
|
||||
$lastBackupTs = 0;
|
||||
if (file_exists(BACKUP_LAST_TS_PATH)) {
|
||||
$lastData = json_decode(file_get_contents(BACKUP_LAST_TS_PATH), true) ?: [];
|
||||
$lastBackupTs = (int)($lastData['ts'] ?? 0);
|
||||
}
|
||||
if (time() - $lastBackupTs >= 82800) { // 23 h
|
||||
$backupResult = createLocalBackup($db);
|
||||
if ($backupResult['success']) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup local: ' . $backupResult['filename']
|
||||
. ' (' . $backupResult['size_kb'] . 'KB, purged ' . $backupResult['purged'] . " old)\n";
|
||||
if (env('GDRIVE_ENABLED', 'false') === 'true') {
|
||||
$gResult = backupToGDrive($db);
|
||||
if ($gResult['success']) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive: OK'
|
||||
. ' (purged remote: ' . ($gResult['purged_remote'] ?? 0) . ")\n";
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup GDrive warning: ' . ($gResult['error'] ?? 'unknown') . "\n";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup warning: ' . ($backupResult['error'] ?? 'unknown') . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Throwable $be) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Backup error: ' . $be->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$msg = $e->getMessage();
|
||||
echo '[' . date('Y-m-d H:i:s') . '] ERROR: ' . $msg . "\n";
|
||||
@@ -99,3 +133,120 @@ try {
|
||||
_phpErrorReport($msg, $e->getFile(), $e->getLine(), $e->getTraceAsString(), get_class($e));
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ── Home Assistant: expiry alerts ─────────────────────────────────────────────
|
||||
// Fire one HA webhook per expiring item (once per day guard via a simple flag file).
|
||||
if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
try {
|
||||
$haFlagFile = __DIR__ . '/../data/ha_expiry_notified_' . date('Y-m-d') . '.json';
|
||||
if (!file_exists($haFlagFile)) {
|
||||
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
ORDER BY i.expiry_date ASC LIMIT 20"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$expiredItems = $db->query(
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Normalise rows to full product format
|
||||
if (!function_exists('_haFormatProduct')) {
|
||||
function _haFormatProduct(array $row): array {
|
||||
$daysRemaining = null;
|
||||
if (!empty($row['expiry_date'])) {
|
||||
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||
$daysRemaining = (int)$diff->format('%r%a');
|
||||
}
|
||||
return [
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||
'name' => $row['name'],
|
||||
'brand' => $row['brand'] ?? null,
|
||||
'category' => $row['category'] ?? null,
|
||||
'quantity' => (float)($row['quantity'] ?? 0),
|
||||
'unit' => $row['unit'] ?? '',
|
||||
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? null,
|
||||
'location' => $row['location'] ?? null,
|
||||
'expiry_date' => $row['expiry_date'] ?? null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||
];
|
||||
}
|
||||
}
|
||||
$expiringItems = array_map('_haFormatProduct', $expiringItems);
|
||||
$expiredItems = array_map('_haFormatProduct', $expiredItems);
|
||||
|
||||
if (!empty($expiringItems)) {
|
||||
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||
_fireHaWebhook('expiry_alert', [
|
||||
'count' => count($expiringItems),
|
||||
'items' => $expiringItems,
|
||||
'type' => 'expiring_soon',
|
||||
'days' => $expiryDays,
|
||||
'summary' => $names,
|
||||
]);
|
||||
// Also send HA notification if service configured
|
||||
if (env('HA_NOTIFY_SERVICE', '') !== '') {
|
||||
$msg = count($expiringItems) . ' product(s) expiring within ' . $expiryDays . ' days: ' . $names;
|
||||
_sendHaNotify($msg, ['expiring_items' => $expiringItems]);
|
||||
}
|
||||
echo '[' . date('Y-m-d H:i:s') . '] HA expiry_alert fired: ' . count($expiringItems) . " items\n";
|
||||
}
|
||||
|
||||
if (!empty($expiredItems)) {
|
||||
$expNames = implode(', ', array_column($expiredItems, 'name'));
|
||||
_fireHaWebhook('expiry_alert', [
|
||||
'count' => count($expiredItems),
|
||||
'items' => $expiredItems,
|
||||
'type' => 'expired',
|
||||
'summary' => $expNames,
|
||||
]);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] HA expired fired: ' . count($expiredItems) . " items\n";
|
||||
}
|
||||
|
||||
// Mark as done for today
|
||||
file_put_contents($haFlagFile, json_encode(['ts' => time(), 'expiring' => count($expiringItems ?? []), 'expired' => count($expiredItems ?? [])]));
|
||||
// Clean up old flag files (keep last 7 days)
|
||||
foreach (glob(__DIR__ . '/../data/ha_expiry_notified_*.json') as $oldFlag) {
|
||||
$flagDate = str_replace([__DIR__ . '/../data/ha_expiry_notified_', '.json'], '', $oldFlag);
|
||||
if ($flagDate < date('Y-m-d', strtotime('-7 days'))) @unlink($oldFlag);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $haE) {
|
||||
echo '[' . date('Y-m-d H:i:s') . '] HA expiry hook warning: ' . $haE->getMessage() . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Avahi/mDNS discovery registration ─────────────────────────────────────────
|
||||
// If avahi-daemon is running on this host, register the _evershelf._tcp service
|
||||
// so that Home Assistant can auto-discover this instance via Zeroconf.
|
||||
if (function_exists('shell_exec')) {
|
||||
try {
|
||||
$avahiService = '/etc/avahi/services/evershelf.xml';
|
||||
// Only create/update if avahi-daemon is installed and the file doesn't exist yet
|
||||
if (!file_exists($avahiService) && (shell_exec('which avahi-daemon 2>/dev/null') || shell_exec('which avahi-publish 2>/dev/null'))) {
|
||||
$template = __DIR__ . '/../docker/avahi-evershelf.xml';
|
||||
if (file_exists($template)) {
|
||||
$xml = file_get_contents($template);
|
||||
@file_put_contents($avahiService, $xml);
|
||||
echo '[' . date('Y-m-d H:i:s') . '] Avahi mDNS service registered at ' . $avahiService . "\n";
|
||||
}
|
||||
}
|
||||
} catch (Throwable $avahiE) {
|
||||
// Non-fatal: avahi not available
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ function getDB(): PDO {
|
||||
? new LoggingPDO('sqlite:' . DB_PATH)
|
||||
: new PDO('sqlite:' . DB_PATH);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
// Set a busy timeout to prevent "database is locked" errors under high concurrency.
|
||||
// This gives SQLite up to 5 seconds to acquire a lock before throwing an exception.
|
||||
$db->setAttribute(PDO::ATTR_TIMEOUT, 5); // PDO::ATTR_TIMEOUT is in seconds for MySQL, but not directly for SQLite.
|
||||
// For SQLite, we use PRAGMA busy_timeout.
|
||||
$db->exec('PRAGMA journal_mode = WAL;');
|
||||
$db->exec('PRAGMA busy_timeout = 5000;'); // 5000 milliseconds = 5 seconds
|
||||
|
||||
$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||||
$db->exec("PRAGMA journal_mode=WAL");
|
||||
$db->exec("PRAGMA foreign_keys=ON");
|
||||
@@ -119,6 +126,16 @@ function initializeDB(PDO $db): void {
|
||||
}
|
||||
|
||||
function migrateDB(PDO $db): void {
|
||||
// Guard: if core tables don't exist yet (e.g. DB file present but empty / partial init),
|
||||
// run initializeDB first so all tables are created, then return — no ALTER TABLE needed.
|
||||
$productsExists = $db->query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='products'"
|
||||
)->fetchColumn();
|
||||
if (!$productsExists) {
|
||||
initializeDB($db);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add package_unit column if missing
|
||||
$cols = $db->query("PRAGMA table_info(products)")->fetchAll();
|
||||
$colNames = array_column($cols, 'name');
|
||||
@@ -244,6 +261,36 @@ function migrateDB(PDO $db): void {
|
||||
// Ensure composite indexes exist (added in v1.7.5 for performance)
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_type_date ON transactions(type, created_at)");
|
||||
$db->exec("CREATE INDEX IF NOT EXISTS idx_transactions_pid_type_undone ON transactions(product_id, type, undone)");
|
||||
|
||||
// Internal shopping list table (v1.8.0) — used when SHOPPING_MODE=internal
|
||||
$shopTables = $db->query("SELECT name FROM sqlite_master WHERE type='table' AND name='shopping_list'")->fetchAll();
|
||||
if (empty($shopTables)) {
|
||||
$db->exec("
|
||||
CREATE TABLE shopping_list (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
raw_name TEXT NOT NULL DEFAULT '',
|
||||
specification TEXT NOT NULL DEFAULT '',
|
||||
added_at INTEGER DEFAULT (strftime('%s','now')),
|
||||
sort_order INTEGER DEFAULT 0
|
||||
)
|
||||
");
|
||||
$db->exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_list_name ON shopping_list(lower(name))");
|
||||
}
|
||||
|
||||
// Add is_favorite column to recipes if missing (#124)
|
||||
$recCols = array_column($db->query("PRAGMA table_info(recipes)")->fetchAll(), 'name');
|
||||
if (!in_array('is_favorite', $recCols)) {
|
||||
try { $db->exec("ALTER TABLE recipes ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
|
||||
// Add nutriments_json column to products if missing (#118)
|
||||
$prodCols2 = array_column($db->query("PRAGMA table_info(products)")->fetchAll(), 'name');
|
||||
if (!in_array('nutriments_json', $prodCols2)) {
|
||||
try { $db->exec("ALTER TABLE products ADD COLUMN nutriments_json TEXT DEFAULT NULL"); }
|
||||
catch (PDOException $e) { if (strpos($e->getMessage(), 'duplicate column') === false) throw $e; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -417,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||
|
||||
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
|
||||
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
|
||||
// Packaged sliced bread — preservatives help a bit
|
||||
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
|
||||
// Generic bread / sandwich bread in fridge
|
||||
if (preg_match('/\bpane\b/', $cat)) return 3;
|
||||
|
||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||
if (preg_match('/\bketchup\b/', $n)) return 90;
|
||||
|
||||
+2423
-271
File diff suppressed because it is too large
Load Diff
+674
-5
@@ -596,13 +596,37 @@ body {
|
||||
}
|
||||
.offline-banner-retry:hover { background: rgba(255,255,255,0.38); }
|
||||
|
||||
/* Pulsing dot shown in the banner while the offline cache is being read */
|
||||
.offline-banner-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #f87171;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
animation: offline-dot-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes offline-dot-pulse {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* When server is offline, block interactions with the main content */
|
||||
body.server-offline .app-content {
|
||||
body.server-offline:not(.offline-mode) .app-content {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
/* In offline-mode the app is usable; just a subtle left-border indicator */
|
||||
body.offline-mode .app-content {
|
||||
border-left: 3px solid rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
/* Hide the "Retry" button in the banner when in offline mode — use the Continue button instead */
|
||||
body.offline-mode .offline-banner-retry {
|
||||
display: none;
|
||||
}
|
||||
body.server-offline .bottom-nav {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
@@ -1945,6 +1969,46 @@ body.server-offline .bottom-nav {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* — Scan status bar — */
|
||||
.scan-status-bar {
|
||||
position: absolute;
|
||||
bottom: 38px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
}
|
||||
.scan-status-method {
|
||||
font-size: 0.58rem;
|
||||
color: rgba(255,255,255,0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
font-family: monospace;
|
||||
}
|
||||
.scan-status-msg {
|
||||
font-size: 0.74rem;
|
||||
color: rgba(255,255,255,0.9);
|
||||
background: rgba(0,0,0,0.55);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
max-width: 92%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.scan-status-msg:empty { visibility: hidden; }
|
||||
.scan-status-msg.state-partial { color: #fbbf24; }
|
||||
.scan-status-msg.state-invalid { color: #f87171; background: rgba(239,68,68,0.28); }
|
||||
.scan-status-msg.state-confirmed { color: #4ade80; background: rgba(74,222,128,0.22); }
|
||||
.scan-status-msg.state-retry { color: #fb923c; }
|
||||
|
||||
/* — Viewport overlay controls (torch / zoom / flip) — */
|
||||
.scan-viewport-controls {
|
||||
position: absolute;
|
||||
@@ -2567,6 +2631,17 @@ body.server-offline .bottom-nav {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.shopping-pantry-hint {
|
||||
font-size: 0.72rem;
|
||||
color: #15803d;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
[data-theme="dark"] .shopping-pantry-hint {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.shopping-item-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -3027,10 +3102,82 @@ body.server-offline .bottom-nav {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.product-preview-small {
|
||||
padding: 12px;
|
||||
/* Action and Use page hero card */
|
||||
#page-action .product-preview-small,
|
||||
#page-use .product-preview-small {
|
||||
padding: 14px 16px;
|
||||
gap: 14px;
|
||||
border-left: 4px solid var(--primary);
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* action page: slightly larger name */
|
||||
#page-action .use-hero-name {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
/* barcode pill on action page */
|
||||
.action-pill-barcode { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
.use-hero-icon {
|
||||
font-size: 2.4rem;
|
||||
width: 52px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.use-hero-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.use-hero-name {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.use-hero-brand {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.use-hero-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.use-meta-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: 99px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Expiry pill colours */
|
||||
.use-pill-ok { background: #dcfce7; color: #166534; }
|
||||
.use-pill-warn { background: #fef9c3; color: #854d0e; }
|
||||
.use-pill-soon { background: #fed7aa; color: #7c2d12; }
|
||||
.use-pill-expired { background: #fee2e2; color: #991b1b; }
|
||||
/* Quantity pill */
|
||||
.use-pill-qty { background: #e0f2fe; color: #0c4a6e; }
|
||||
|
||||
.product-preview img, .product-preview-small img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
@@ -3040,8 +3187,11 @@ body.server-offline .bottom-nav {
|
||||
}
|
||||
|
||||
.product-preview-small img {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-preview-emoji {
|
||||
@@ -4130,6 +4280,7 @@ body.server-offline .bottom-nav {
|
||||
.recipe-result .recipe-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -4166,6 +4317,35 @@ body.server-offline .bottom-nav {
|
||||
color: #3730a3;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Appliance/mode badge shown inline next to a step text */
|
||||
.recipe-step-appliance {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 12px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.72rem;
|
||||
color: #15803d;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Regen choice panel */
|
||||
.recipe-regen-choice {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.recipe-regen-choice-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
margin: 0 0 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Recipe ingredient use buttons */
|
||||
.recipe-ingredients {
|
||||
@@ -5583,6 +5763,26 @@ body.cooking-mode-active .app-header {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Related stock hint (same generic family, different brand/product) */
|
||||
.action-related-stock-card {
|
||||
background: #f0fdf4;
|
||||
border: 1.5px solid #86efac;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.82rem;
|
||||
color: #166534;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.action-related-stock-card strong { color: #15803d; }
|
||||
.related-stock-item { display: inline-block; margin-right: 8px; }
|
||||
[data-theme="dark"] .action-related-stock-card {
|
||||
background: rgba(21, 128, 61, 0.12);
|
||||
border-color: #166534;
|
||||
color: #86efac;
|
||||
}
|
||||
[data-theme="dark"] .action-related-stock-card strong { color: #4ade80; }
|
||||
|
||||
/* ===== ACTION BUTTONS GRID ===== */
|
||||
.action-buttons-3col {
|
||||
display: grid;
|
||||
@@ -6376,6 +6576,117 @@ body.cooking-mode-active .app-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ===== RECIPE FAVORITES (#124) ===== */
|
||||
.recipe-fav-badge {
|
||||
margin-left: auto;
|
||||
font-size: 1.1rem;
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-archive-card-fav {
|
||||
border-left: 3px solid #f59e0b;
|
||||
}
|
||||
|
||||
.btn-recipe-fav {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.8rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
transition: color 0.2s, transform 0.15s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-recipe-fav:hover { color: #f59e0b; transform: scale(1.2); }
|
||||
.btn-recipe-fav.active { color: #f59e0b; }
|
||||
|
||||
/* ===== PORTION RESCALER (#123) ===== */
|
||||
.recipe-persons-ctrl {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 0 6px;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-persons-adj {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-persons-adj:hover { background: var(--accent, #6366f1); color: #fff; }
|
||||
|
||||
#recipe-persons-display {
|
||||
white-space: nowrap;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* ===== MACRONUTRIENT PANEL (#118) ===== */
|
||||
.macro-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
|
||||
.macro-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.macro-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.macro-bar-wrap {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-secondary, #1e2a3a);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.macro-bar-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.macro-val {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.macro-val small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ===== SCREENSAVER ===== */
|
||||
.screensaver-overlay {
|
||||
position: fixed;
|
||||
@@ -6765,6 +7076,82 @@ body.cooking-mode-active .app-header {
|
||||
}
|
||||
.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; }
|
||||
|
||||
/* ===== MONTHLY STATS PANEL ===== */
|
||||
.ms-main-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin: 12px 0 8px;
|
||||
}
|
||||
.ms-main-num {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 700;
|
||||
color: #6366f1;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.ms-main-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.ms-main-label {
|
||||
font-size: .85rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.ms-trend {
|
||||
font-size: .8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ms-cats-section {
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
.ms-cats-title {
|
||||
font-size: .68rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.ms-cat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.ms-cat-name {
|
||||
font-size: .74rem;
|
||||
color: #cbd5e1;
|
||||
min-width: 78px;
|
||||
max-width: 78px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ms-cat-bar-wrap {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: #1e293b;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ms-cat-bar {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
width: 0;
|
||||
}
|
||||
.ms-cat-cnt {
|
||||
font-size: .7rem;
|
||||
color: #64748b;
|
||||
min-width: 22px;
|
||||
text-align: right;
|
||||
}
|
||||
.ms-badges-row {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ===== SETUP WIZARD ===== */
|
||||
.setup-wizard-content {
|
||||
max-width: 480px;
|
||||
@@ -7133,6 +7520,7 @@ body.cooking-mode-active .app-header {
|
||||
--bg: #0f172a;
|
||||
--bg-card: #1e293b;
|
||||
--bg-dark: #020617;
|
||||
--bg-secondary: #263448;
|
||||
--text: #e2e8f0;
|
||||
--text-light: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
@@ -7384,3 +7772,284 @@ body.cooking-mode-active .app-header {
|
||||
color: var(--primary-light);
|
||||
}
|
||||
/* @media prefers-color-scheme: auto handled in JS */
|
||||
|
||||
/* ===== DARK MODE — EXTENDED COMPONENT OVERRIDES ===== */
|
||||
|
||||
/* ── Inventory badges ── */
|
||||
[data-theme="dark"] .badge-location { background: #0c2a4e; color: #7dd3fc; }
|
||||
[data-theme="dark"] .badge-category { background: #1e293b; color: #94a3b8; }
|
||||
[data-theme="dark"] .badge-qty { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .badge-expiry { background: #2a1a00; color: #fcd34d; }
|
||||
[data-theme="dark"] .badge-expired { background: #2a0808; color: #fca5a5; }
|
||||
|
||||
/* ── Urgency / priority badges ── */
|
||||
[data-theme="dark"] .badge-critical { background: #2a0808; color: #fca5a5; }
|
||||
[data-theme="dark"] .badge-high { background: #2a1200; color: #fdba74; }
|
||||
[data-theme="dark"] .badge-medium { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .badge-low { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .badge-freq-high { background: #2e0d1a; color: #f9a8d4; }
|
||||
[data-theme="dark"] .badge-tag-add { background: #1e293b; color: #94a3b8; }
|
||||
|
||||
/* ── Smart shopping badges ── */
|
||||
[data-theme="dark"] .smart-freq-badge.freq-suggest { background: #0c2a4e; color: #7dd3fc; }
|
||||
[data-theme="dark"] .smart-freq-badge.freq-suggest-approx { background: #0c1f3a; color: #93c5fd; font-style: italic; }
|
||||
[data-theme="dark"] .smart-pred-badge { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .smart-pred-badge.pred-urgent { background: #2a0808; color: #fca5a5; }
|
||||
[data-theme="dark"] .smart-pred-badge.pred-soon { background: #2a1200; color: #fdba74; }
|
||||
[data-theme="dark"] .smart-bring-badge { background: #0c2a4e; color: #7dd3fc; }
|
||||
|
||||
/* ── AW trend mini-cards ── */
|
||||
[data-theme="dark"] .aw-tcard-good { background: #0f2a1a; border-color: #166534; }
|
||||
[data-theme="dark"] .aw-tcard-ok { background: #1c1300; border-color: #78350f; }
|
||||
[data-theme="dark"] .aw-tcard-bad { background: #2a0808; border-color: #7f1d1d; }
|
||||
|
||||
/* ── Alert sections ── */
|
||||
[data-theme="dark"] .alert-danger { background: #2a0808; border-color: var(--danger); }
|
||||
[data-theme="dark"] .alert-item { background: rgba(255,255,255,0.04); }
|
||||
[data-theme="dark"] .alert-item-qty { background: rgba(255,255,255,0.06); }
|
||||
[data-theme="dark"] .alert-review { background: #1c1300; border-color: #78350f; }
|
||||
[data-theme="dark"] .alert-review h3 { color: #fcd34d; }
|
||||
[data-theme="dark"] .alert-opened { background: #0c1f3a; border-color: #1e3a8a; }
|
||||
[data-theme="dark"] .alert-opened h3 { color: #7dd3fc; }
|
||||
[data-theme="dark"] .alert-item-badge.opened { background: #1e40af; }
|
||||
|
||||
/* ── Opened expiry badges ── */
|
||||
[data-theme="dark"] .opened-expiry-ok { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .opened-expiry-soon { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .opened-expiry-urgent { background: #2a0808; color: #fca5a5; }
|
||||
|
||||
/* ── Alert banner: gradient overrides ── */
|
||||
[data-theme="dark"] .alert-banner.banner-expired { background: #2a0808; border-color: #7f1d1d; }
|
||||
[data-theme="dark"] .banner-expired .alert-banner-title { color: #fca5a5; }
|
||||
[data-theme="dark"] .banner-expired .alert-banner-counter { color: #f87171; }
|
||||
[data-theme="dark"] .alert-banner.banner-expiring { background: #1c1300; border-color: #78350f; }
|
||||
[data-theme="dark"] .banner-expiring .alert-banner-title { color: #fdba74; }
|
||||
[data-theme="dark"] .banner-expiring .alert-banner-counter { color: #fb923c; }
|
||||
[data-theme="dark"] .alert-banner.banner-expired-ok { background: #0f2a1a; border-color: #166534; }
|
||||
[data-theme="dark"] .banner-expired-ok .alert-banner-title { color: #86efac; }
|
||||
[data-theme="dark"] .banner-expired-ok .alert-banner-counter { color: #4ade80; }
|
||||
[data-theme="dark"] .alert-banner.banner-expired-warning { background: #1c1300; border-color: #78350f; }
|
||||
[data-theme="dark"] .banner-expired-warning .alert-banner-title { color: #fde68a; }
|
||||
[data-theme="dark"] .banner-expired-warning .alert-banner-counter { color: #fcd34d; }
|
||||
[data-theme="dark"] .alert-banner.banner-expired-danger { background: #2a0808; border-color: #7f1d1d; border-width: 2px; }
|
||||
[data-theme="dark"] .banner-expired-danger .alert-banner-title { color: #fca5a5; }
|
||||
[data-theme="dark"] .alert-banner.banner-prediction { background: #1a1040; border-color: #6d28d9; }
|
||||
[data-theme="dark"] .banner-prediction .alert-banner-title { color: #c4b5fd; }
|
||||
[data-theme="dark"] .banner-prediction .alert-banner-counter { color: #a78bfa; }
|
||||
[data-theme="dark"] .alert-banner.banner-anomaly { background: #1a1200; border-color: #c2410c; }
|
||||
[data-theme="dark"] .banner-anomaly .alert-banner-title { color: #fdba74; }
|
||||
[data-theme="dark"] .alert-banner.banner-no-expiry { background: #0f2a1a; border-color: #166534; }
|
||||
[data-theme="dark"] .banner-no-expiry .alert-banner-title { color: #86efac; }
|
||||
|
||||
/* ── Alert banner: default text & close ── */
|
||||
[data-theme="dark"] .alert-banner-title { color: #e2e8f0; }
|
||||
[data-theme="dark"] .alert-banner-detail { color: #94a3b8; }
|
||||
[data-theme="dark"] .alert-banner-close { color: #94a3b8; background: rgba(255,255,255,0.06); }
|
||||
[data-theme="dark"] .banner-safety-warning { color: #fdba74; }
|
||||
[data-theme="dark"] .banner-safety-ok { color: #86efac; }
|
||||
[data-theme="dark"] .banner-safety-danger { color: #fca5a5; }
|
||||
|
||||
/* ── Banner action buttons ── */
|
||||
[data-theme="dark"] .btn-banner-ok { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .btn-banner-edit { background: #1a1040; color: #c4b5fd; }
|
||||
[data-theme="dark"] .btn-banner-ai { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .btn-banner-weigh { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .btn-banner-confirm { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .btn-banner-use { background: #0c2a4e; color: #7dd3fc; }
|
||||
[data-theme="dark"] .btn-banner-throw { background: #2a0808; color: #fca5a5; }
|
||||
[data-theme="dark"] .btn-banner-throw-primary { background: #dc2626; color: #fff; }
|
||||
[data-theme="dark"] .btn-banner-use-danger { background: #1e293b; color: #64748b; }
|
||||
[data-theme="dark"] .btn-banner-vacuum { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .btn-banner-edit2 { background: #0c2a4e; color: #7dd3fc; }
|
||||
|
||||
/* ── Review items ── */
|
||||
[data-theme="dark"] .review-item { background: rgba(255,255,255,0.04); }
|
||||
[data-theme="dark"] .review-item-meta { color: #94a3b8; }
|
||||
[data-theme="dark"] .review-warn { color: #fca5a5; }
|
||||
[data-theme="dark"] .review-qty-value { background: #2a0808; color: #fca5a5; }
|
||||
[data-theme="dark"] .btn-review-ok { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .btn-review-ok:active { background: #0d2416; }
|
||||
[data-theme="dark"] .btn-review-edit { background: #1a1040; color: #c4b5fd; }
|
||||
[data-theme="dark"] .btn-review-edit:active { background: #140d36; }
|
||||
|
||||
/* ── Chat UI ── */
|
||||
[data-theme="dark"] .chat-header-bar { background: var(--bg-card); border-color: var(--border); }
|
||||
[data-theme="dark"] .chat-title { color: #818cf8; }
|
||||
[data-theme="dark"] .chat-suggestion { background: #1a1040; border-color: #3730a3; color: #a5b4fc; }
|
||||
[data-theme="dark"] .chat-suggestion:active { background: #2e1a4a; }
|
||||
[data-theme="dark"] .chat-gemini { background: var(--bg-card); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
|
||||
[data-theme="dark"] .chat-gemini strong { color: #818cf8; }
|
||||
[data-theme="dark"] .chat-input-bar { background: var(--bg-card); border-color: var(--border); }
|
||||
|
||||
/* ── Settings status ── */
|
||||
[data-theme="dark"] .settings-status.success { background: #0f2a1a; color: #86efac; }
|
||||
[data-theme="dark"] .settings-status.error { background: #2a0808; color: #fca5a5; }
|
||||
|
||||
/* ── Inventory status bar ── */
|
||||
[data-theme="dark"] .inventory-status-bar {
|
||||
background: linear-gradient(135deg, #0c2a4e 0%, #1a1040 100%);
|
||||
border-color: #1e3a8a;
|
||||
}
|
||||
[data-theme="dark"] .inventory-status-bar .inv-status-title { color: #7dd3fc; }
|
||||
[data-theme="dark"] .inventory-status-bar .inv-status-total { color: #e2e8f0; background: rgba(0,0,0,0.3); }
|
||||
[data-theme="dark"] .inventory-status-bar .inv-status-item { color: #93c5fd; background: rgba(0,0,0,0.2); }
|
||||
|
||||
/* ── Use inventory info ── */
|
||||
[data-theme="dark"] .use-inventory-info { background: #0c2a4e; color: #7dd3fc; }
|
||||
[data-theme="dark"] #use-expiry-hint { background: #2a1e00; border-color: #78350f; color: #fde68a; }
|
||||
[data-theme="dark"] #page-use .product-preview-small { border-left-color: var(--primary); }
|
||||
[data-theme="dark"] #page-action .product-preview-small { border-left-color: var(--primary); }
|
||||
[data-theme="dark"] .action-pill-barcode { background: #1e293b; color: #94a3b8; }
|
||||
[data-theme="dark"] .use-pill-ok { background: #14532d; color: #86efac; }
|
||||
[data-theme="dark"] .use-pill-warn { background: #422006; color: #fde68a; }
|
||||
[data-theme="dark"] .use-pill-soon { background: #431407; color: #fdba74; }
|
||||
[data-theme="dark"] .use-pill-expired { background: #450a0a; color: #fca5a5; }
|
||||
[data-theme="dark"] .use-pill-qty { background: #0c2a4e; color: #7dd3fc; }
|
||||
|
||||
/* ── Recipe components ── */
|
||||
[data-theme="dark"] .recipe-expiry-note { background: #2a1e00; color: #fde68a; }
|
||||
[data-theme="dark"] .recipe-tools-banner { background: #1a1040; border-color: #3730a3; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-tool-chip { background: #2e1a4a; color: #c4b5fd; }
|
||||
[data-theme="dark"] .recipe-step-appliance { background: #052e16; border-color: #166534; color: #4ade80; }
|
||||
[data-theme="dark"] .recipe-regen-choice { background: #1e293b; border-color: #334155; }
|
||||
[data-theme="dark"] .recipe-regen-choice-title { color: #94a3b8; }
|
||||
[data-theme="dark"] .recipe-subtype-chip { background: #1c1300; border-color: #78350f; color: var(--text); }
|
||||
[data-theme="dark"] .recipe-subtype-chip:has(input:checked) { background: #2a1e00; border-color: #d97706; }
|
||||
|
||||
/* ── Bug report pills ── */
|
||||
[data-theme="dark"] .bug-type-pill { background: var(--bg-card); border-color: var(--border); color: var(--text-light); }
|
||||
|
||||
/* ── Shopping tag menu ── */
|
||||
[data-theme="dark"] .shopping-tag-menu-container { background: #1a2336; }
|
||||
|
||||
/* ── Edit unknown card ── */
|
||||
[data-theme="dark"] .edit-unknown-card.highlight { background: #1c1300; border-color: var(--warning); }
|
||||
|
||||
/* ── AI match image ── */
|
||||
[data-theme="dark"] .ai-match-img { background: var(--bg-card); }
|
||||
|
||||
/* ── Inline edit button ── */
|
||||
[data-theme="dark"] .btn-edit-inline { background: rgba(30,41,59,0.92); border-color: var(--border); color: var(--text); }
|
||||
|
||||
/* ── Setup wizard ── */
|
||||
[data-theme="dark"] .setup-body p { color: var(--text-muted); }
|
||||
[data-theme="dark"] .setup-footer { border-color: var(--border); }
|
||||
[data-theme="dark"] .setup-skip-link { color: var(--text-muted); }
|
||||
[data-theme="dark"] .setup-skip-link:hover { color: var(--text-light); }
|
||||
|
||||
/* ── Appliance remove active ── */
|
||||
[data-theme="dark"] .appliance-item .appliance-remove:active { background: #2a0808; }
|
||||
|
||||
/* ===== NETWORK ERROR OVERLAY ===== */
|
||||
#network-error-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(6, 8, 20, 0.97);
|
||||
z-index: 300000; /* highest: above screensaver(10000), cooking(99999), preloader(200000) */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
#network-error-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.net-error-body {
|
||||
text-align: center;
|
||||
padding: 2.5rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.net-error-icon {
|
||||
font-size: 5.5rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 1.75rem;
|
||||
animation: net-pulse 2.2s ease-in-out infinite;
|
||||
display: block;
|
||||
filter: drop-shadow(0 0 32px rgba(248, 113, 113, 0.35));
|
||||
}
|
||||
#network-error-overlay.restored .net-error-icon {
|
||||
animation: none;
|
||||
filter: drop-shadow(0 0 32px rgba(74, 222, 128, 0.45));
|
||||
}
|
||||
#network-error-overlay.checking .net-error-icon {
|
||||
animation: net-spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes net-pulse {
|
||||
0%, 100% { opacity: 0.45; transform: scale(0.92); }
|
||||
50% { opacity: 1; transform: scale(1.06); }
|
||||
}
|
||||
@keyframes net-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.net-error-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #f87171;
|
||||
margin-bottom: 0.75rem;
|
||||
letter-spacing: -0.02em;
|
||||
transition: color 0.4s;
|
||||
}
|
||||
#network-error-overlay.restored .net-error-title {
|
||||
color: #4ade80;
|
||||
}
|
||||
.net-error-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: #94a3b8;
|
||||
max-width: 420px;
|
||||
line-height: 1.6;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.net-error-status {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.88rem;
|
||||
color: #475569;
|
||||
min-height: 1.3em;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
/* "Continue in offline mode" button — appears after 3 s */
|
||||
.net-error-continue-btn {
|
||||
margin-top: 2.2rem;
|
||||
background: rgba(255,255,255,0.07);
|
||||
border: 1px solid rgba(255,255,255,0.22);
|
||||
color: #94a3b8;
|
||||
border-radius: 10px;
|
||||
padding: 0.7rem 1.6rem;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s, transform 0.3s, opacity 0.3s;
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
.net-error-continue-btn.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.net-error-continue-btn:hover {
|
||||
background: rgba(255,255,255,0.16);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ─── Offline mode: hide AI and network-dependent UI ────────────────────────
|
||||
Sections that require a live server response or AI are hidden so the user
|
||||
isn't confronted with empty/broken widgets while offline. */
|
||||
body.offline-mode #waste-chart-section,
|
||||
body.offline-mode #nutrition-section,
|
||||
body.offline-mode #quick-recipe-bar,
|
||||
body.offline-mode .header-gemini-btn,
|
||||
body.offline-mode #btn-suggest,
|
||||
body.offline-mode #btn-fetch-prices,
|
||||
body.offline-mode .recipe-generate-btn {
|
||||
display: none !important;
|
||||
}
|
||||
/* Smart-shopping AI section: show as disabled rather than disappearing entirely */
|
||||
body.offline-mode #smart-shopping {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.6);
|
||||
}
|
||||
|
||||
+2360
-292
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
{"ts":1779204302,"filename":"evershelf_2026-05-19_1525.db","size_kb":444}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" standalone='no'?>
|
||||
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
|
||||
<service-group>
|
||||
<name replace-wildcards="yes">EverShelf Pantry (%h)</name>
|
||||
<service>
|
||||
<type>_evershelf._tcp</type>
|
||||
<port>80</port>
|
||||
<txt-record>path=/api/</txt-record>
|
||||
<txt-record>version=1.0</txt-record>
|
||||
<txt-record>app=evershelf</txt-record>
|
||||
</service>
|
||||
</service-group>
|
||||
@@ -0,0 +1,308 @@
|
||||
# Home Assistant Integration
|
||||
|
||||
EverShelf integrates natively with [Home Assistant](https://www.home-assistant.io/) to bring your pantry data into your smart-home automations.
|
||||
|
||||
**Capabilities:**
|
||||
- 📡 **REST sensors** — expose pantry counts as HA sensor entities (expiring, expired, shopping list, total items)
|
||||
- 🔔 **Webhooks** — trigger HA automations on pantry events (expiry alerts, shopping additions, stock updates)
|
||||
- 📣 **Push notifications** — send alerts to your phone via any HA `notify.*` service
|
||||
- 🔊 **TTS on smart speakers** — read recipe steps aloud on any HA `media_player` entity
|
||||
- ⚙️ **In-app config panel** — configure everything from Settings → 🏠 tab (no need to edit `.env` manually)
|
||||
|
||||
---
|
||||
|
||||
## Quick Setup
|
||||
|
||||
1. **Generate a Long-Lived Access Token** in Home Assistant:
|
||||
- Open HA → your **Profile** (bottom-left avatar) → **Security** → **Long-Lived Access Tokens** → **Create Token**
|
||||
- Copy the generated token — you won't see it again.
|
||||
|
||||
2. **Open EverShelf Settings** → tab **🏠 Home Assistant**.
|
||||
|
||||
3. Fill in **Home Assistant URL** (e.g. `http://homeassistant.local:8123`) and paste the token.
|
||||
|
||||
4. Click **Test connection** — you should see ✅.
|
||||
|
||||
5. Enable the features you want (TTS, Webhooks, REST Sensors) and click **Save HA settings**.
|
||||
|
||||
---
|
||||
|
||||
## REST Sensors
|
||||
|
||||
Add EverShelf pantry data as native HA sensor entities that update automatically.
|
||||
|
||||
### Endpoints
|
||||
|
||||
| URL | Returns | Sensor |
|
||||
|-----|---------|--------|
|
||||
| `/api/?action=ha_sensor` | Items expiring soon (≤`HA_EXPIRY_DAYS` days) | `sensor.evershelf_overview` |
|
||||
| `/api/?action=ha_sensor&sensor=expired` | Expired items count | `sensor.evershelf_expired` |
|
||||
| `/api/?action=ha_sensor&sensor=shopping` | Shopping list item count | `sensor.evershelf_shopping` |
|
||||
| `/api/?action=ha_sensor&sensor=total` | Total pantry items | `sensor.evershelf_total` |
|
||||
| `/api/?action=ha_sensor&sensor=product` | Full inventory — all items with complete details | `sensor.evershelf_products` |
|
||||
| `/api/?action=ha_sensor&sensor=product&id=42` | Full details for inventory row `id=42` | — |
|
||||
| `/api/?action=ha_sensor&sensor=product&name=milk` | Full details for items whose name contains "milk" | — |
|
||||
| `/api/?action=ha_sensor&sensor=product&location=frigo` | All items in a specific location | — |
|
||||
|
||||
### Generate & Copy YAML
|
||||
|
||||
In Settings → 🏠 Home Assistant → **REST Sensors** card, click **Copy YAML** to get a ready-to-paste `configuration.yaml` block that already contains your EverShelf URL.
|
||||
|
||||
### Manual YAML example
|
||||
|
||||
```yaml
|
||||
# configuration.yaml
|
||||
sensor:
|
||||
- platform: rest
|
||||
name: "EverShelf Overview"
|
||||
unique_id: evershelf_overview
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor"
|
||||
scan_interval: 300 # seconds
|
||||
value_template: "{{ value_json.state }}"
|
||||
json_attributes:
|
||||
- expiring_soon
|
||||
- expiring_3d
|
||||
- expired_items
|
||||
- total_items
|
||||
- shopping_items
|
||||
- expiring_list # full product details for expiring items
|
||||
- expired_list # full product details for expired items
|
||||
- low_stock_list # full product details for items with quantity ≤ 1
|
||||
- next_expiry_name
|
||||
- next_expiry_date
|
||||
- days_to_next_expiry
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
|
||||
- platform: rest
|
||||
name: "EverShelf Shopping Count"
|
||||
unique_id: evershelf_shopping
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=shopping"
|
||||
scan_interval: 180
|
||||
value_template: "{{ value_json.state }}"
|
||||
unit_of_measurement: "items"
|
||||
|
||||
# Full product inventory — each item includes all details (location, brand, category, …)
|
||||
- platform: rest
|
||||
name: "EverShelf Products"
|
||||
unique_id: evershelf_products
|
||||
resource: "http://YOUR_EVERSHELF_URL/api/?action=ha_sensor&sensor=product"
|
||||
scan_interval: 600
|
||||
value_template: "{{ value_json.state }}"
|
||||
json_attributes:
|
||||
- items
|
||||
- last_updated
|
||||
unit_of_measurement: "items"
|
||||
```
|
||||
|
||||
Restart Home Assistant after editing `configuration.yaml`.
|
||||
|
||||
Every product entry inside `expiring_list`, `expired_list`, `low_stock_list`, and `sensor=product` responses follows the same schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_id": 42,
|
||||
"inventory_id": 7,
|
||||
"name": "Latte intero",
|
||||
"brand": "Parmalat",
|
||||
"category": "Lattiero-caseari",
|
||||
"quantity": 2.0,
|
||||
"unit": "conf",
|
||||
"default_quantity": 1000.0,
|
||||
"package_unit": "ml",
|
||||
"location": "frigo",
|
||||
"expiry_date": "2025-06-15",
|
||||
"days_remaining": 3,
|
||||
"opened_at": "2025-06-10",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
```
|
||||
|
||||
Field details:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `product_id` | int | Products table ID |
|
||||
| `inventory_id` | int | Inventory row ID |
|
||||
| `name` | string | Product name |
|
||||
| `brand` | string\|null | Brand (if set) |
|
||||
| `category` | string\|null | Category (if set) |
|
||||
| `quantity` | float | Current quantity in inventory |
|
||||
| `unit` | string | Unit (`conf`, `g`, `ml`, `pz`, …) |
|
||||
| `default_quantity` | float | Default package size (e.g. 1000 for 1-litre carton) |
|
||||
| `package_unit` | string\|null | Unit of the default package (`g`, `ml`) |
|
||||
| `location` | string\|null | Storage location (`frigo`, `freezer`, `dispensa`, …) |
|
||||
| `expiry_date` | string\|null | ISO date `YYYY-MM-DD` |
|
||||
| `days_remaining` | int\|null | Days until expiry (negative = already expired) |
|
||||
| `opened_at` | string\|null | ISO date when the package was opened |
|
||||
| `vacuum_sealed` | bool | Whether the item is vacuum-sealed |
|
||||
|
||||
---
|
||||
|
||||
## Webhook Automations
|
||||
|
||||
EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
||||
|
||||
### Create the HA Webhook Automation
|
||||
|
||||
1. HA → **Settings** → **Automations & Scenes** → **Create Automation**
|
||||
2. Click **Add Trigger** → choose **Webhook**
|
||||
3. HA generates a **Webhook ID** — copy it
|
||||
4. Paste the ID into **Settings → 🏠 Home Assistant → Webhook ID**
|
||||
5. Select which events should trigger the webhook
|
||||
|
||||
### Supported Events
|
||||
|
||||
| Event key | When it fires |
|
||||
|-----------|--------------|
|
||||
| `expiry` | Daily cron — items expiring within `HA_EXPIRY_DAYS` days |
|
||||
| `shopping_add` | Item added to the shopping list |
|
||||
| `stock_update` | Inventory quantity changed |
|
||||
| `barcode_scan` | (reserved for future use) |
|
||||
|
||||
### Webhook Payload (POST body)
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "expiry_alert",
|
||||
"timestamp": "2025-06-12T08:00:00+00:00",
|
||||
"data": {
|
||||
"type": "expiring_soon",
|
||||
"count": 3,
|
||||
"days": 3,
|
||||
"summary": "Milk, Yogurt, Butter",
|
||||
"items": [
|
||||
{
|
||||
"product_id": 42,
|
||||
"inventory_id": 7,
|
||||
"name": "Milk",
|
||||
"brand": "Parmalat",
|
||||
"category": "Dairy",
|
||||
"quantity": 2.0,
|
||||
"unit": "conf",
|
||||
"default_quantity": 1000.0,
|
||||
"package_unit": "ml",
|
||||
"location": "frigo",
|
||||
"expiry_date": "2025-06-14",
|
||||
"days_remaining": 2,
|
||||
"opened_at": "2025-06-10",
|
||||
"vacuum_sealed": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Expiry Alert → Telegram
|
||||
|
||||
```yaml
|
||||
alias: EverShelf Expiry Alert
|
||||
trigger:
|
||||
- platform: webhook
|
||||
webhook_id: "evershelf_webhook_abc123" # ← your Webhook ID
|
||||
action:
|
||||
- service: notify.telegram_bot
|
||||
data:
|
||||
message: >
|
||||
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||
{% for item in trigger.json.data.items %}
|
||||
— {{ item.name }}{% if item.brand %} ({{ item.brand }}){% endif %} ·
|
||||
{{ item.quantity }} {{ item.unit }} · 📍 {{ item.location }} ·
|
||||
expires {{ item.expiry_date }} ({{ item.days_remaining }} days)
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Example: Automation on location
|
||||
|
||||
You can filter by location in the automation template to only alert for fridge items:
|
||||
|
||||
```yaml
|
||||
condition:
|
||||
- condition: template
|
||||
value_template: >
|
||||
{{ trigger.json.data.items | selectattr('location','eq','frigo') | list | length > 0 }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
|
||||
If you prefer to receive push alerts without using webhooks, configure a **HA notify service** directly:
|
||||
|
||||
1. Find your notify service name in HA: **Developer Tools → Services** → search `notify`
|
||||
2. Paste it into **Settings → 🏠 → Notify service** (e.g. `notify.mobile_app_my_phone`)
|
||||
3. Save
|
||||
|
||||
EverShelf will call this service from the cron job whenever expiry alerts fire.
|
||||
|
||||
---
|
||||
|
||||
## TTS on Smart Speakers
|
||||
|
||||
Read recipe steps aloud on an Amazon Echo, Google Home, Sonos, or any HA `media_player`.
|
||||
|
||||
### Configuration
|
||||
|
||||
1. Enter the **Entity ID** of your media player (e.g. `media_player.kitchen_display`)
|
||||
- Find it in HA: **Developer Tools → States**
|
||||
2. Click **Apply HA preset to TTS tab** — this auto-fills the TTS tab with the correct HA endpoint and auth headers
|
||||
3. Save settings
|
||||
|
||||
### How it Works
|
||||
|
||||
When recipe step TTS is triggered, EverShelf calls:
|
||||
|
||||
```
|
||||
POST /api/services/tts/speak
|
||||
Authorization: Bearer <HA_TOKEN>
|
||||
{
|
||||
"entity_id": "media_player.kitchen_display",
|
||||
"message": "Add 200 g of flour and mix well."
|
||||
}
|
||||
```
|
||||
|
||||
The request is proxied through the EverShelf PHP backend (avoids CORS / mixed-content issues).
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
All settings are configurable from `.env` or from the in-app Settings panel.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `HA_ENABLED` | `false` | Master switch for all HA features |
|
||||
| `HA_URL` | _(empty)_ | Base URL of HA instance, no trailing slash |
|
||||
| `HA_TOKEN` | _(empty)_ | Long-Lived Access Token |
|
||||
| `HA_TTS_ENTITY` | _(empty)_ | `media_player` entity for TTS |
|
||||
| `HA_WEBHOOK_ID` | _(empty)_ | Webhook trigger ID from HA automation |
|
||||
| `HA_WEBHOOK_EVENTS` | `expiry,shopping_add,stock_update` | Comma-separated list of events |
|
||||
| `HA_NOTIFY_SERVICE` | _(empty)_ | HA notify service (e.g. `notify.mobile_app_phone`) |
|
||||
| `HA_EXPIRY_DAYS` | `3` | Days before expiry to trigger the daily alert |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Test shows ❌ "Connection failed"**
|
||||
- Verify the URL is reachable from the EverShelf server (not just your browser)
|
||||
- If using HTTPS with a self-signed certificate, the server-side cURL request may fail — use HTTP on the local network instead
|
||||
- Check that port 8123 (or your custom port) is open on the HA host
|
||||
|
||||
**Test shows ❌ "bad_token"**
|
||||
- The Long-Lived Access Token may have expired or been revoked — generate a new one in HA Profile
|
||||
|
||||
**Webhook not firing**
|
||||
- Confirm HA_ENABLED=true and the Webhook ID is exactly as shown in HA
|
||||
- Check the EverShelf cron is running (`/api/cron_smart_shopping.php` every 5 minutes)
|
||||
- For shopping/stock events: verify the event name is in `HA_WEBHOOK_EVENTS`
|
||||
|
||||
**TTS not speaking**
|
||||
- Ensure the media player entity is online in HA (check its state in Developer Tools)
|
||||
- Try the "Apply HA preset to TTS tab" button and send a test from the TTS tab
|
||||
- Check HA logs for `tts.speak` errors (some platforms require `tts_options`)
|
||||
|
||||
**Sensors show unavailable in HA**
|
||||
- The EverShelf URL must be reachable from the HA host
|
||||
- If running EverShelf behind a reverse proxy, ensure `/api/` is accessible
|
||||
- Use `scan_interval` ≥ 60 to avoid hammering the server
|
||||
@@ -5,14 +5,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "it.dadaloop.evershelf.kiosk"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||
minSdk = 24
|
||||
targetSdk = 34
|
||||
versionCode = 17
|
||||
versionName = "1.7.16"
|
||||
targetSdk = 35
|
||||
versionCode = 18
|
||||
versionName = "1.7.17"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -18,7 +18,9 @@ import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.media.AudioManager
|
||||
import android.speech.tts.TextToSpeech
|
||||
import android.speech.tts.UtteranceProgressListener
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowInsetsController
|
||||
@@ -144,6 +146,25 @@ class KioskActivity : AppCompatActivity() {
|
||||
if (res == TextToSpeech.LANG_MISSING_DATA || res == TextToSpeech.LANG_NOT_SUPPORTED) {
|
||||
tts?.language = Locale.getDefault()
|
||||
}
|
||||
tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||
override fun onStart(utteranceId: String?) {}
|
||||
override fun onDone(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsDone)window._kioskTtsDone('$utteranceId')", null)
|
||||
}
|
||||
}
|
||||
@Deprecated("Deprecated in API 21")
|
||||
override fun onError(utteranceId: String?) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId','error')", null)
|
||||
}
|
||||
}
|
||||
override fun onError(utteranceId: String?, errorCode: Int) {
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("if(window._kioskTtsError)window._kioskTtsError('$utteranceId',$errorCode)", null)
|
||||
}
|
||||
}
|
||||
})
|
||||
ttsReady = true
|
||||
}
|
||||
}
|
||||
@@ -466,7 +487,10 @@ class KioskActivity : AppCompatActivity() {
|
||||
if (!ttsReady) return
|
||||
engine.setSpeechRate(rate.coerceIn(0.1f, 4f))
|
||||
engine.setPitch(pitch.coerceIn(0.1f, 4f))
|
||||
engine.speak(text, android.speech.tts.TextToSpeech.QUEUE_FLUSH, null, "kiosk_tts")
|
||||
val params = Bundle().apply {
|
||||
putInt(TextToSpeech.Engine.KEY_PARAM_STREAM, AudioManager.STREAM_MUSIC)
|
||||
}
|
||||
engine.speak(text, TextToSpeech.QUEUE_FLUSH, params, "kiosk_tts")
|
||||
}
|
||||
@JavascriptInterface
|
||||
fun stopSpeech() { tts?.stop() }
|
||||
@@ -774,7 +798,13 @@ class KioskActivity : AppCompatActivity() {
|
||||
val q = DownloadManager.Query().setFilterById(downloadId)
|
||||
val c = (getSystemService(DOWNLOAD_SERVICE) as DownloadManager).query(q)
|
||||
var ok = false
|
||||
if (c.moveToFirst()) ok = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL
|
||||
var dmStatus = -1
|
||||
var dmReason = -1
|
||||
if (c.moveToFirst()) {
|
||||
dmStatus = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS))
|
||||
dmReason = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON))
|
||||
ok = dmStatus == DownloadManager.STATUS_SUCCESSFUL
|
||||
}
|
||||
c.close()
|
||||
if (ok) {
|
||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||
@@ -784,7 +814,12 @@ class KioskActivity : AppCompatActivity() {
|
||||
pollHandler.removeCallbacksAndMessages(null); activeDownloadId = -1
|
||||
setInstallUI("\u274C", getString(R.string.install_error_download), getString(R.string.install_error_download_detail), 0xFFf87171.toInt(), btnEnabled = true, progress = -2)
|
||||
runOnUiThread { activeInstallBtn?.text = getString(R.string.install_btn_retry) }
|
||||
ErrorReporter.reportMessage("install_download_failed", "DownloadManager returned failure for URL: $apkUrl")
|
||||
ErrorReporter.reportMessage(
|
||||
"install_download_failed",
|
||||
"DownloadManager returned failure for URL: $apkUrl",
|
||||
mapOf("dm_status" to dmStatus, "dm_reason" to dmReason,
|
||||
"device" to buildDeviceLabel())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -868,6 +903,11 @@ class KioskActivity : AppCompatActivity() {
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
// Note: setAppPackageName() is intentionally omitted — it causes STATUS_FAILURE (1)
|
||||
// on some OEM/Android versions even when the package name is correct.
|
||||
// setInstallReason is required on Android 14+ (API 34+) for PackageInstaller
|
||||
// to accept self-updates; without it Android 16 returns STATUS_FAILURE=1.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
params.setInstallReason(android.content.pm.PackageManager.INSTALL_REASON_USER)
|
||||
}
|
||||
val sessionId = pi.createSession(params)
|
||||
val session = pi.openSession(sessionId)
|
||||
try {
|
||||
|
||||
@@ -123,6 +123,9 @@ class SettingsActivity : AppCompatActivity() {
|
||||
// Back
|
||||
findViewById<android.widget.ImageButton>(R.id.btnBack).setOnClickListener { finish() }
|
||||
|
||||
// Advanced settings → back to webapp (where HA, Gemini, Bring! etc. are configured)
|
||||
findViewById<MaterialButton>(R.id.btnOpenAppSettings).setOnClickListener { finish() }
|
||||
|
||||
// Test connection
|
||||
findViewById<MaterialButton>(R.id.btnTestConnection).setOnClickListener { testConnection() }
|
||||
|
||||
|
||||
@@ -400,6 +400,7 @@ class SetupActivity : AppCompatActivity() {
|
||||
scaleTestCard.visibility = View.GONE
|
||||
testWeightBox.visibility = View.GONE
|
||||
bleSetupCard.visibility = View.VISIBLE
|
||||
step3NextButtons.visibility = View.VISIBLE // restore nav buttons (back/next)
|
||||
tvSelectedScale.text = ""
|
||||
tvSelectedScale.visibility = View.GONE
|
||||
tvScanStatus.text = getString(R.string.ble_not_confirmed)
|
||||
@@ -960,6 +961,8 @@ class SetupActivity : AppCompatActivity() {
|
||||
testWeightBox.visibility = View.GONE
|
||||
testHasWeight = false
|
||||
findViewById<MaterialButton>(R.id.btnTestConfirm).isEnabled = false
|
||||
// Always re-enable retry so the user is never stuck
|
||||
findViewById<MaterialButton>(R.id.btnTestRetry).isEnabled = true
|
||||
}
|
||||
override fun onWeightReceived(reading: WeightReading) {
|
||||
if (!isInTestMode) return
|
||||
|
||||
@@ -224,6 +224,43 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Advanced / App Settings link -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="IMPOSTAZIONI AVANZATE"
|
||||
android:textColor="#7c3aed"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
android:letterSpacing="0.1"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:background="@drawable/card_background"
|
||||
android:padding="16dp"
|
||||
android:layout_marginBottom="24dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Home Assistant, Gemini AI, Bring!, TTS, notifiche e tutte le altre funzionalità si configurano direttamente nell'app EverShelf."
|
||||
android:textColor="#94a3b8"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnOpenAppSettings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:text="← Torna all'app per le impostazioni avanzate"
|
||||
android:textSize="13sp"
|
||||
android:textAllCaps="false"
|
||||
android:backgroundTint="#7c3aed" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Danger Zone -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
|
||||
+298
-9
@@ -64,7 +64,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
|
||||
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
|
||||
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.23</span>
|
||||
<span class="app-preloader-version" id="preloader-version">v1.7.25</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<h1 class="header-title" onclick="showPage('dashboard')">
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.23</span>
|
||||
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.25</span>
|
||||
</h1>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -169,10 +169,12 @@
|
||||
<div id="expired-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
|
||||
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
|
||||
<div id="dashboard-insight-wrap" style="position:relative">
|
||||
<div id="waste-chart-section" style="display:none"></div>
|
||||
<div id="nutrition-section" style="display:none"></div>
|
||||
<div id="monthly-stats-section" style="display:none"></div>
|
||||
<div id="macros-section" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Alert for soonest expiring items -->
|
||||
@@ -249,6 +251,11 @@
|
||||
</div>
|
||||
<!-- Live partial code preview -->
|
||||
<div class="scan-live-code" id="scan-live-code" style="display:none"></div>
|
||||
<!-- Scan status bar -->
|
||||
<div class="scan-status-bar" id="scan-status-bar">
|
||||
<span id="scan-status-method" class="scan-status-method"></span>
|
||||
<span id="scan-status-msg" class="scan-status-msg" data-i18n="scan.status_ready"></span>
|
||||
</div>
|
||||
<!-- Success flash overlay -->
|
||||
<div class="scan-confirm-overlay" id="scan-confirm-overlay" style="display:none">
|
||||
<div class="scan-confirm-check">✓</div>
|
||||
@@ -331,8 +338,9 @@
|
||||
</div>
|
||||
<!-- Banner: shopping list scan context -->
|
||||
<div id="shopping-scan-target-banner" class="shopping-scan-target-banner" style="display:none"></div>
|
||||
<div class="product-preview product-preview-large" id="action-product-preview"></div>
|
||||
<div class="product-preview product-preview-small" id="action-product-preview"></div>
|
||||
<div class="inventory-status-bar" id="action-inventory-status" style="display:none"></div>
|
||||
<div id="action-related-stock" style="display:none"></div>
|
||||
<div class="action-buttons" id="action-buttons-container">
|
||||
<button class="btn btn-huge btn-success" onclick="showAddForm()">
|
||||
<span class="btn-icon">📥</span>
|
||||
@@ -671,7 +679,7 @@
|
||||
<h2 data-i18n="recipes.title">🍳 Ricette</h2>
|
||||
</div>
|
||||
<div class="recipe-page-container">
|
||||
<button class="btn btn-large btn-success full-width" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
||||
<button class="btn btn-large btn-success full-width recipe-generate-btn" onclick="openRecipeDialog()" data-i18n="recipes.generate">
|
||||
✨ Genera nuova ricetta
|
||||
</button>
|
||||
<div id="recipe-archive" class="recipe-archive"></div>
|
||||
@@ -833,14 +841,16 @@
|
||||
<div class="settings-tabs">
|
||||
<button class="settings-tab active" onclick="switchSettingsTab(this, 'tab-general')" data-tab="tab-general" data-i18n-title="settings.tab_general" title="Generali">⚙️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-api')" data-tab="tab-api" title="API Keys">🔑</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" title="Bring!">🛒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-bring')" data-tab="tab-bring" data-i18n-title="settings.shopping.tab" title="Lista spesa">🛒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-recipe')" data-tab="tab-recipe" title="Ricette">🍳</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-mealplan')" data-tab="tab-mealplan" title="Piano Settimanale">📅</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-appliances')" data-tab="tab-appliances" title="Elettrodomestici">🔌</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-camera')" data-tab="tab-camera" title="Fotocamera">📷</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-security')" data-tab="tab-security" title="Sicurezza">🔒</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-tts')" data-tab="tab-tts" title="Voce (TTS)" data-i18n-title="settings.tab_tts">🔊</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-ha'); _loadHaTab();" data-tab="tab-ha" title="Home Assistant" data-i18n-title="settings.ha.tab">🏠</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-scale')" data-tab="tab-scale" title="Bilancia Smart" data-i18n-title="settings.scale.tab">⚖️</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-backup'); _loadBackupTab();" data-tab="tab-backup" data-i18n-title="settings.backup.tab" title="Backup">💾</button>
|
||||
<button class="settings-tab" onclick="switchSettingsTab(this, 'tab-info'); _loadInfoTab();" data-tab="tab-info" data-i18n-title="settings.info.tab" title="Info">ℹ️</button>
|
||||
</div>
|
||||
<div class="settings-panels">
|
||||
@@ -954,9 +964,36 @@
|
||||
</div>
|
||||
<!-- Bring! Tab -->
|
||||
<div class="settings-panel" id="tab-bring">
|
||||
<!-- Shopping enable + provider -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.bring.title">🛒 Bring! Shopping List</h4>
|
||||
<p class="settings-hint" data-i18n="settings.bring.hint">Credenziali per l'integrazione con la lista della spesa Bring!</p>
|
||||
<h4 data-i18n="settings.shopping.title">🛒 Lista della spesa</h4>
|
||||
<p class="settings-hint" data-i18n="settings.shopping.hint">Configura la lista della spesa integrata o collega Bring!.</p>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.shopping.enable_label">Abilita lista della spesa</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-shopping-enabled" onchange="onShoppingEnabledChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" id="shopping-mode-group">
|
||||
<label data-i18n="settings.shopping.mode_label">Provider</label>
|
||||
<div class="radio-group" style="margin-top:6px">
|
||||
<label class="radio-option">
|
||||
<input type="radio" name="shopping-mode" value="internal" onchange="onShoppingModeChange(this.value)">
|
||||
<span data-i18n="settings.shopping.mode_internal">Interno (senza Bring!)</span>
|
||||
</label>
|
||||
<label class="radio-option" style="margin-left:16px">
|
||||
<input type="radio" name="shopping-mode" value="bring" onchange="onShoppingModeChange(this.value)">
|
||||
<span data-i18n="settings.shopping.mode_bring">Bring! (app esterna)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bring! sub-section (shown only when mode = bring) -->
|
||||
<div class="settings-card" id="bring-subsection" style="display:none;margin-top:12px">
|
||||
<h4 data-i18n="settings.shopping.bring_section_title">Configurazione Bring!</h4>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.bring.email_label">📧 Email Bring!</label>
|
||||
<input type="email" id="setting-bring-email" class="form-input" placeholder="email@esempio.com">
|
||||
@@ -967,6 +1004,37 @@
|
||||
<button class="btn btn-small btn-secondary mt-2" onclick="togglePasswordVisibility('setting-bring-password')" data-i18n="btn.toggle_password">👁️ Mostra/Nascondi</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Smart suggestions + forecast -->
|
||||
<div class="settings-card" style="margin-top:12px">
|
||||
<h4 data-i18n="settings.shopping.ai_section_title">Assistenza AI</h4>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.shopping.smart_suggestions_label">Suggerimenti AI</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-shopping-smart-suggestions">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.shopping.forecast_label">Previsione prodotti in esaurimento</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-shopping-forecast">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px">
|
||||
<label data-i18n="settings.shopping.auto_add_label">Aggiungi automaticamente quando</label>
|
||||
<div class="qty-control" style="margin-top:6px">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', -1, 0, 20)">−</button>
|
||||
<input type="number" id="setting-shopping-auto-add" value="0" min="0" max="20" class="qty-input">
|
||||
<button type="button" class="qty-btn" onclick="adjustQty('setting-shopping-auto-add', 1, 0, 20)">+</button>
|
||||
</div>
|
||||
<p class="settings-hint" data-i18n="settings.shopping.auto_add_suffix">rimasto in magazzino (0 = solo quando esaurito)</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Price Estimation Settings -->
|
||||
<div class="settings-card" style="margin-top:12px">
|
||||
<h4 data-i18n="settings.price.title">💰 Stima Prezzi (AI)</h4>
|
||||
@@ -1253,10 +1321,127 @@
|
||||
</div>
|
||||
</div><!-- /tts-server-section -->
|
||||
|
||||
<button class="btn btn-large btn-secondary full-width mt-2" onclick="testSound()" data-i18n="settings.tts.test_sound_btn">🔔 Esegui Test Suono</button>
|
||||
<button class="btn btn-large btn-accent full-width mt-2" onclick="testTTS()" data-i18n="settings.tts.test_btn">🔊 Invia Test Vocale</button>
|
||||
<div id="tts-test-status" style="display:none;margin-top:8px"></div>
|
||||
<!-- HA TTS quick-fill hint -->
|
||||
<div style="margin-top:12px;padding:10px 12px;background:rgba(3,169,244,0.07);border:1px solid rgba(3,169,244,0.25);border-radius:8px;font-size:0.82rem">
|
||||
<span data-i18n="settings.ha.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Home Assistant Tab -->
|
||||
<div class="settings-panel" id="tab-ha">
|
||||
<!-- Connection card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.title">🏠 Home Assistant</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.hint">Integra EverShelf con Home Assistant: TTS su speaker smart, webhook per automazioni, sensori per la dashboard.</p>
|
||||
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.ha.enabled">✅ Abilita integrazione Home Assistant</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-ha-enabled" onchange="onHaEnabledChange()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="ha-config-section">
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.url_label">🌐 Home Assistant URL</label>
|
||||
<input type="url" id="setting-ha-url" class="form-input" placeholder="http://192.168.1.50:8123">
|
||||
<p class="settings-hint" data-i18n="settings.ha.url_hint">URL base della tua istanza HA (senza slash finale). Es: <code>http://homeassistant.local:8123</code></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.token_label">🔑 Long-Lived Access Token</label>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<input type="password" id="setting-ha-token" class="form-input" style="flex:1" placeholder="eyJhbGci...">
|
||||
<button class="btn btn-secondary" style="flex-shrink:0" onclick="togglePasswordVisibility('setting-ha-token')" data-i18n="btn.toggle_password">👁️</button>
|
||||
</div>
|
||||
<p class="settings-hint" data-i18n="settings.ha.token_hint">Genera un token in HA → Profilo → Token di accesso a lungo termine.</p>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width" onclick="testHaConnection()" data-i18n="settings.ha.test_btn">🔗 Testa connessione HA</button>
|
||||
<div id="ha-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TTS via HA card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.tts_title">🔊 TTS su Speaker Smart</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.tts_hint">Leggi i passi della ricetta su un altoparlante gestito da HA (Sonos, Echo, Google Home, ecc.).</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.tts_entity_label">🔈 Entity ID del media player</label>
|
||||
<input type="text" id="setting-ha-tts-entity" class="form-input" placeholder="media_player.living_room">
|
||||
<p class="settings-hint" data-i18n="settings.ha.tts_entity_hint">Copia l'entity ID del media player da HA → Strumenti sviluppatore → Stati.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.tts_platform_label">🎙️ Piattaforma TTS</label>
|
||||
<select id="setting-ha-tts-platform" class="form-input">
|
||||
<option value="tts.speak" data-i18n="settings.ha.tts_platform_speak">tts.speak (raccomandato)</option>
|
||||
<option value="notify" data-i18n="settings.ha.tts_platform_notify">notify.* (servizio notifiche)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-secondary full-width" onclick="applyHaTtsPreset()" data-i18n="settings.ha.tts_apply_btn">✅ Applica preset HA al TTS</button>
|
||||
<p class="settings-hint mt-2" data-i18n="settings.ha.tts_apply_hint">Configura automaticamente il tab TTS con i parametri HA corretti.</p>
|
||||
</div>
|
||||
|
||||
<!-- Webhook card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.webhook_title">⚡ Automazioni Webhook</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.webhook_hint">EverShelf chiama il webhook HA quando si verificano eventi (prodotto in scadenza, aggiunto alla lista, ecc.).</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.webhook_id_label">🔗 Webhook ID</label>
|
||||
<input type="text" id="setting-ha-webhook-id" class="form-input" placeholder="evershelf_events">
|
||||
<p class="settings-hint" data-i18n="settings.ha.webhook_id_hint">Crea un'automazione in HA con trigger "Webhook" e copia qui l'ID. <a href="#" onclick="showHaWebhookHelp();return false" style="color:var(--accent)">Come farlo?</a></p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.webhook_events_label">📋 Eventi da notificare</label>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;margin-top:4px">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||
<input type="checkbox" id="ha-event-expiry" value="expiry"> <span data-i18n="settings.ha.event_expiry">Prodotti in scadenza (cron giornaliero)</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||
<input type="checkbox" id="ha-event-shopping" value="shopping_add"> <span data-i18n="settings.ha.event_shopping">Aggiunta alla lista della spesa</span>
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:normal">
|
||||
<input type="checkbox" id="ha-event-stock" value="stock_update"> <span data-i18n="settings.ha.event_stock">Aggiornamento scorte (quantità modificata)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.expiry_days_label">📅 Giorni anticipo per scadenze</label>
|
||||
<input type="number" id="setting-ha-expiry-days" class="form-input" min="1" max="30" value="3">
|
||||
<p class="settings-hint" data-i18n="settings.ha.expiry_days_hint">Quanti giorni prima della scadenza inviare l'alert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notify service card -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.notify_title">📱 Notifiche Push</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.notify_hint">EverShelf invia notifiche push tramite il servizio <code>notify.*</code> di HA (Telegram, Pushover, app mobile, ecc.).</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.ha.notify_service_label">📣 Servizio notify</label>
|
||||
<input type="text" id="setting-ha-notify-service" class="form-input" placeholder="notify.mobile_app_mio_telefono">
|
||||
<p class="settings-hint" data-i18n="settings.ha.notify_service_hint">Formato: <code>notify.NOME_SERVIZIO</code>. Lascia vuoto per disabilitare. Richiede token HA configurato.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sensor card (read-only info) -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.ha.sensor_title">📊 Sensori REST per HA</h4>
|
||||
<p class="settings-hint" data-i18n="settings.ha.sensor_hint">HA può leggere i dati dell'inventario via REST polling. Aggiungi questo snippet a <code>configuration.yaml</code>:</p>
|
||||
<div id="ha-sensor-yaml" style="background:var(--bg-secondary,#f1f5f9);border-radius:8px;padding:12px;font-family:monospace;font-size:0.75rem;white-space:pre;overflow-x:auto;max-height:220px;overflow-y:auto;border:1px solid var(--border,#e2e8f0)"></div>
|
||||
<button class="btn btn-secondary full-width mt-2" onclick="copyHaSensorYaml()" data-i18n="settings.ha.sensor_copy_btn">📋 Copia YAML</button>
|
||||
</div>
|
||||
|
||||
<!-- Save button -->
|
||||
<button class="btn btn-large btn-accent full-width" onclick="saveHaSettings()" data-i18n="settings.ha.save_btn">💾 Salva impostazioni HA</button>
|
||||
<div id="ha-save-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||
</div>
|
||||
<!-- Scale Tab -->
|
||||
<div class="settings-panel" id="tab-scale">
|
||||
<div class="settings-card">
|
||||
@@ -1342,6 +1527,93 @@
|
||||
</div>
|
||||
<!-- Language Tab -->
|
||||
|
||||
<!-- Backup Tab -->
|
||||
<div class="settings-panel" id="tab-backup">
|
||||
<!-- Local Backup -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.backup.local_title">💾 Backup Locale</h4>
|
||||
<p class="settings-hint" data-i18n="settings.backup.local_hint">Snapshot giornaliero automatico del database. Massimo 3 giorni di storico (configurabile).</p>
|
||||
<div id="backup-last-info" style="margin-bottom:12px;padding:10px 12px;background:var(--bg-secondary,#f8fafc);border-radius:8px;font-size:0.83rem;color:var(--text-secondary)">
|
||||
<span data-i18n="settings.info.loading">Caricamento…</span>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:14px">
|
||||
<label data-i18n="settings.backup.retention_days" style="flex-shrink:0">Retention (giorni):</label>
|
||||
<input type="number" id="setting-backup-retention-days" class="form-input" style="width:80px" min="1" max="90" value="3">
|
||||
</div>
|
||||
<button class="btn btn-large btn-accent full-width" onclick="_backupNow()" id="btn-backup-now" data-i18n="settings.backup.backup_now">💾 Backup Ora</button>
|
||||
<div id="backup-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||
<!-- List of backups -->
|
||||
<div id="backup-list-container" style="margin-top:14px">
|
||||
<p class="settings-hint" data-i18n="settings.info.loading">Caricamento…</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Google Drive -->
|
||||
<div class="settings-card">
|
||||
<h4 data-i18n="settings.backup.gdrive_title">☁️ Google Drive</h4>
|
||||
<p class="settings-hint" data-i18n="settings.backup.gdrive_hint">Carica automaticamente il backup su Google Drive usando un Service Account.</p>
|
||||
<div class="form-group" style="margin-bottom:10px">
|
||||
<label class="toggle-row">
|
||||
<span data-i18n="settings.backup.gdrive_enabled">Abilita backup Google Drive</span>
|
||||
<span class="toggle-switch">
|
||||
<input type="checkbox" id="setting-gdrive-enabled" onchange="saveSettings()">
|
||||
<span class="toggle-slider"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="gdrive-config-section">
|
||||
<!-- Folder ID (shared between both methods) -->
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.backup.gdrive_folder_id">ID Cartella Drive</label>
|
||||
<input type="text" id="setting-gdrive-folder-id" class="form-input" placeholder="1ABCdef_xyz…">
|
||||
<p class="settings-hint" data-i18n="settings.backup.gdrive_folder_id_hint">Copia l'ID dalla URL della cartella Drive: …/folders/<strong>ID</strong></p>
|
||||
</div>
|
||||
<!-- OAuth 2.0 section -->
|
||||
<div id="gdrive-oauth-section">
|
||||
<details style="margin-bottom:14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px">
|
||||
<summary style="cursor:pointer;font-weight:600;font-size:0.83rem" data-i18n="settings.backup.gdrive_oauth_how_to">📋 Come configurare OAuth 2.0 (passo dopo passo)</summary>
|
||||
<ol style="margin:10px 0 0 16px;font-size:0.8rem;color:var(--text-secondary);line-height:1.8" data-i18n-html="settings.backup.gdrive_oauth_steps"></ol>
|
||||
</details>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.backup.gdrive_client_id">Client ID</label>
|
||||
<input type="text" id="setting-gdrive-client-id" class="form-input" placeholder="1234567890-abc….apps.googleusercontent.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label data-i18n="settings.backup.gdrive_client_secret">Client Secret</label>
|
||||
<input type="password" id="setting-gdrive-client-secret" class="form-input" placeholder="GOCSPX-…">
|
||||
</div>
|
||||
<div class="form-group" style="background:var(--bg-secondary,#f8fafc);border-radius:8px;padding:10px 14px;font-size:0.82rem">
|
||||
<span data-i18n="settings.backup.gdrive_redirect_uri_label">Redirect URI (aggiungi in Google Cloud Console):</span>
|
||||
<code id="gdrive-redirect-uri-display" style="display:block;margin-top:4px;word-break:break-all;color:var(--text-primary);font-size:0.78rem">http://localhost</code>
|
||||
<p class="settings-hint" style="margin-top:6px;margin-bottom:0" data-i18n="settings.backup.gdrive_redirect_uri_hint">Registra questo URI in Google Cloud Console come "URI di reindirizzamento autorizzato". Per le installazioni senza dominio pubblico usa <strong>http://localhost</strong>.</p>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:8px">
|
||||
<button class="btn btn-secondary" onclick="_gdriveAuthorize()" id="btn-gdrive-authorize" data-i18n="settings.backup.gdrive_oauth_authorize">🔑 Autorizza con Google</button>
|
||||
<span id="gdrive-oauth-token-status" style="font-size:0.83rem"></span>
|
||||
</div>
|
||||
<!-- Manual code entry (appears after clicking Authorize) -->
|
||||
<div id="gdrive-code-section" style="display:none;margin-top:12px;padding:12px 14px;background:var(--bg-secondary,#f8fafc);border-radius:8px;border:1px solid var(--border)">
|
||||
<p style="font-size:0.82rem;margin-bottom:8px;font-weight:600" data-i18n="settings.backup.gdrive_code_title">Incolla l'URL o il codice di autorizzazione</p>
|
||||
<p class="settings-hint" style="margin-bottom:8px" data-i18n="settings.backup.gdrive_code_hint">Dopo aver autorizzato su Google, il browser proverà ad aprire <code>http://localhost</code> e mostrerà un errore. Copia l'intero URL dalla barra degli indirizzi e incollalo qui sotto.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<input type="text" id="gdrive-code-input" class="form-input" style="flex:1;min-width:0" placeholder="http://localhost/?code=4%2F0A… oppure solo il codice">
|
||||
<button class="btn btn-primary" onclick="_gdriveSubmitCode()" id="btn-gdrive-submit-code" data-i18n="settings.backup.gdrive_code_submit">Conferma</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Retention + action buttons (shared) -->
|
||||
<div class="form-group" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px">
|
||||
<label data-i18n="settings.backup.gdrive_retention_days" style="flex-shrink:0">Retention Drive (giorni, 0=tutto):</label>
|
||||
<input type="number" id="setting-gdrive-retention-days" class="form-input" style="width:80px" min="0" max="365" value="30">
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
|
||||
<button class="btn btn-secondary" onclick="_gdriveTest()" id="btn-gdrive-test" data-i18n="settings.backup.gdrive_test">🔗 Testa Connessione</button>
|
||||
<button class="btn btn-accent" onclick="_gdrivePushNow()" id="btn-gdrive-push" data-i18n="settings.backup.gdrive_push_now">☁️ Carica Ora su Drive</button>
|
||||
</div>
|
||||
<div id="gdrive-test-status" style="display:none;margin-top:8px" class="settings-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Tab -->
|
||||
<div class="settings-panel" id="tab-info">
|
||||
<!-- Gemini AI Usage card -->
|
||||
@@ -1552,9 +1824,15 @@
|
||||
</div>
|
||||
<div id="recipe-result" style="display:none" class="recipe-result">
|
||||
<div id="recipe-content"></div>
|
||||
<button class="btn btn-large btn-secondary full-width mt-2" onclick="regenerateRecipe()" data-i18n="recipes.regenerate">
|
||||
<button id="recipe-regen-btn" class="btn btn-large btn-secondary full-width mt-2" onclick="showRegenChoice()" data-i18n="recipes.regenerate">
|
||||
🔄 Generane un'altra
|
||||
</button>
|
||||
<div id="recipe-regen-choice" style="display:none" class="recipe-regen-choice">
|
||||
<p class="recipe-regen-choice-title" data-i18n="recipes.regen_choice_title">Cosa vuoi fare con questa ricetta?</p>
|
||||
<button class="btn btn-large btn-warning full-width" onclick="doRegenerateReplace()" data-i18n="recipes.regen_replace">🔄 Genera un'altra (scarta questa)</button>
|
||||
<button class="btn btn-large btn-success full-width mt-2" onclick="doRegenerateSave()" data-i18n="recipes.regen_save_new">💾 Salva nell'archivio e genera nuova</button>
|
||||
<button class="btn btn-large btn-ghost full-width mt-2" onclick="cancelRegenChoice()" data-i18n="action.cancel">Annulla</button>
|
||||
</div>
|
||||
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()" data-i18n="recipes.close_btn">
|
||||
✅ Chiudi
|
||||
</button>
|
||||
@@ -1611,6 +1889,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== NETWORK ERROR OVERLAY ===== -->
|
||||
<div id="network-error-overlay" style="display:none" aria-live="assertive" role="alert">
|
||||
<div class="net-error-body">
|
||||
<div class="net-error-icon" id="net-error-icon">📡</div>
|
||||
<div class="net-error-title" id="net-error-title" data-i18n="error.offline_title">Nessuna connessione</div>
|
||||
<div class="net-error-subtitle" id="net-error-subtitle" data-i18n="error.offline_subtitle">L'app non riesce a raggiungere il server. Verifica la connessione Wi-Fi.</div>
|
||||
<div class="net-error-status" id="net-error-status"></div>
|
||||
<button class="net-error-continue-btn" id="net-error-continue-btn" onclick="_enterOfflineMode()" data-i18n="error.offline_continue" style="display:none">Continua in modalità offline</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== COOKING MODE OVERLAY ===== -->
|
||||
<div id="cooking-overlay" class="cooking-overlay" style="display:none" aria-live="polite">
|
||||
<div id="cooking-flash-overlay" class="cooking-flash-overlay"></div>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.23",
|
||||
"version": "1.7.25",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
+1436
-1262
File diff suppressed because it is too large
Load Diff
+1436
-1262
File diff suppressed because it is too large
Load Diff
+1380
-1214
File diff suppressed because it is too large
Load Diff
+1380
-1214
File diff suppressed because it is too large
Load Diff
+1435
-1262
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user