Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
name: Security Scan (Trivy)
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
|
||||
@@ -11,6 +11,42 @@ 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.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
|
||||
|
||||
@@ -142,7 +142,9 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
if (!file_exists($haFlagFile)) {
|
||||
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
||||
"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')
|
||||
@@ -150,13 +152,44 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$expiredItems = $db->query(
|
||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
||||
"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', [
|
||||
|
||||
@@ -126,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');
|
||||
@@ -267,6 +277,20 @@ function migrateDB(PDO $db): void {
|
||||
");
|
||||
$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; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,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;
|
||||
|
||||
+559
-47
@@ -477,9 +477,13 @@ if (($_GET['action'] ?? '') === 'health_check') {
|
||||
'hint' => $wal !== 'wal' ? 'Journal mode not optimal — will be corrected automatically on next startup' : null];
|
||||
|
||||
// Size & rows
|
||||
$checks['db_size'] = ['ok' => true, 'value' => round(filesize($dbPath)/1024).' KB', 'optional' => true];
|
||||
$cnt = $pdo->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => $cnt.' prodotti in inventario', 'optional' => true];
|
||||
$checks['db_size'] = ['ok' => true, 'value' => round(filesize($dbPath)/1024).' KB', 'optional' => true];
|
||||
if (empty($missing) || !in_array('inventory', $missing)) {
|
||||
$cnt = $pdo->query("SELECT COUNT(*) FROM inventory WHERE quantity > 0")->fetchColumn();
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => $cnt.' prodotti in inventario', 'optional' => true];
|
||||
} else {
|
||||
$checks['db_row_count'] = ['ok' => true, 'value' => '0 prodotti in inventario', 'optional' => true];
|
||||
}
|
||||
} else {
|
||||
foreach (['db_tables', 'db_integrity'] as $k)
|
||||
$checks[$k] = ['ok' => false, 'hint' => 'Cannot verify — DB connection failed'];
|
||||
@@ -797,6 +801,10 @@ try {
|
||||
getStats($db);
|
||||
break;
|
||||
|
||||
case 'monthly_stats':
|
||||
getMonthlyStats($db);
|
||||
break;
|
||||
|
||||
case 'consumption_predictions':
|
||||
getConsumptionPredictions($db);
|
||||
break;
|
||||
@@ -914,6 +922,12 @@ try {
|
||||
case 'recipes_delete':
|
||||
recipesDelete($db);
|
||||
break;
|
||||
case 'recipes_toggle_favorite':
|
||||
recipeToggleFavorite($db);
|
||||
break;
|
||||
case 'macro_stats':
|
||||
getMacroStats($db);
|
||||
break;
|
||||
case 'chat_list':
|
||||
chatList($db);
|
||||
break;
|
||||
@@ -1362,12 +1376,47 @@ function _sendHaNotify(string $message, array $data = []): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a DB inventory+product row into a full product info array
|
||||
* used consistently across all HA sensor attributes and webhook payloads.
|
||||
*/
|
||||
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']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Full product detail SQL fragment reused in all HA queries. */
|
||||
function _haProductSelect(): string {
|
||||
return "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";
|
||||
}
|
||||
|
||||
/**
|
||||
* HA REST sensor endpoint — returns pantry state in Home Assistant-compatible format.
|
||||
* Use with platform: rest in configuration.yaml.
|
||||
*
|
||||
* GET /api/?action=ha_sensor[&sensor=NAME]
|
||||
* Available sensor names: expiring, expired, total, shopping
|
||||
* Available sensor names: expiring, expired, total, shopping, product
|
||||
*/
|
||||
function haInventorySensor(PDO $db): void {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -1376,6 +1425,38 @@ function haInventorySensor(PDO $db): void {
|
||||
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
|
||||
$expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3))));
|
||||
|
||||
// ── sensor=product: full inventory details, optionally filtered ──────────
|
||||
if ($sensor === 'product') {
|
||||
try {
|
||||
$invId = (int)($_GET['id'] ?? 0);
|
||||
$search = trim($_GET['name'] ?? '');
|
||||
$loc = trim($_GET['location'] ?? '');
|
||||
$where = "WHERE i.quantity > 0";
|
||||
$params = [];
|
||||
if ($invId > 0) { $where .= " AND i.id = ?"; $params[] = $invId; }
|
||||
elseif ($search !== '') { $where .= " AND LOWER(p.name) LIKE ?"; $params[] = '%' . mb_strtolower($search, 'UTF-8') . '%'; }
|
||||
if ($loc !== '') { $where .= " AND i.location = ?"; $params[] = $loc; }
|
||||
$stmt = $db->prepare(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
$where ORDER BY p.name ASC"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
echo json_encode([
|
||||
'state' => count($items),
|
||||
'items' => $items,
|
||||
'last_updated' => date('c'),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$expiring = (int)$db->query(
|
||||
"SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL
|
||||
@@ -1402,14 +1483,30 @@ function haInventorySensor(PDO $db): void {
|
||||
$shoppingCount = (int)$db->query("SELECT COUNT(*) FROM shopping_list")->fetchColumn();
|
||||
}
|
||||
|
||||
// Expiring items details
|
||||
// Expiring items details (full product info, all within $expiryDays window)
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.name, i.quantity, p.unit, i.expiry_date
|
||||
FROM inventory i
|
||||
JOIN products p ON p.id = i.product_id
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+7 days')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
ORDER BY i.expiry_date ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Expired items (full product info)
|
||||
$expiredItemsList = $db->query(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Low-stock items (quantity <= 1 but > 0, full product info)
|
||||
$lowStockItemsList = $db->query(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.quantity <= 1
|
||||
ORDER BY i.quantity ASC, p.name ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Opened items
|
||||
@@ -1526,13 +1623,9 @@ function haInventorySensor(PDO $db): void {
|
||||
'shopping_total' => $shoppingTotal,
|
||||
'price_tracking_enabled' => $priceEnabled,
|
||||
'price_currency' => $priceCurrency,
|
||||
'expiring_list' => array_map(fn($r) => [
|
||||
'name' => $r['name'],
|
||||
'quantity' => (float)$r['quantity'],
|
||||
'unit' => $r['unit'],
|
||||
'expiry_date' => $r['expiry_date'],
|
||||
'expires_today' => $r['expiry_date'] <= date('Y-m-d', strtotime('+1 days')),
|
||||
], $expiringItems),
|
||||
'expiring_list' => array_map('_haFormatProduct', $expiringItems),
|
||||
'expired_list' => array_map('_haFormatProduct', $expiredItemsList),
|
||||
'low_stock_list' => array_map('_haFormatProduct', $lowStockItemsList),
|
||||
'next_expiry_name' => !empty($expiringItems) ? $expiringItems[0]['name'] : null,
|
||||
'next_expiry_date' => !empty($expiringItems) ? $expiringItems[0]['expiry_date'] : null,
|
||||
'unit_of_measurement' => 'items',
|
||||
@@ -2132,7 +2225,7 @@ function stockForName(PDO $db): void {
|
||||
}
|
||||
|
||||
function _offFetchProduct(string $barcode): ?array {
|
||||
$fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores';
|
||||
$fields = 'product_name,product_name_it,generic_name,generic_name_it,brands,categories_tags,categories_hierarchy,categories,image_front_small_url,image_url,quantity,nutriscore_grade,ingredients_text_it,ingredients_text,allergens_tags,conservation_conditions_it,conservation_conditions,origins_it,origins,manufacturing_places,nova_group,ecoscore_grade,labels,stores,nutriments';
|
||||
|
||||
// Try candidate barcodes: given barcode + EAN-13 (UPC-A → prepend 0)
|
||||
$candidates = [$barcode];
|
||||
@@ -2185,12 +2278,29 @@ function _offFetchProduct(string $barcode): ?array {
|
||||
}
|
||||
|
||||
$ingredients = $p['ingredients_text_it'] ?? $p['ingredients_text'] ?? '';
|
||||
$category = $p['categories_tags'][0] ?? end($p['categories_hierarchy'] ?? []) ?? $p['categories'] ?? '';
|
||||
$catHierarchy = $p['categories_hierarchy'] ?? [];
|
||||
$category = $p['categories_tags'][0] ?? (empty($catHierarchy) ? null : end($catHierarchy)) ?? $p['categories'] ?? '';
|
||||
$allergens = '';
|
||||
if (!empty($p['allergens_tags'])) {
|
||||
$allergens = implode(', ', array_map(fn($a) => str_replace('en:', '', $a), $p['allergens_tags']));
|
||||
}
|
||||
|
||||
// Extract macronutrients per 100g (from OFF 'nutriments' field)
|
||||
$nutriments = null;
|
||||
if (!empty($p['nutriments']) && is_array($p['nutriments'])) {
|
||||
$nm = $p['nutriments'];
|
||||
$nutriments = [
|
||||
'energy_kcal_100g' => isset($nm['energy-kcal_100g']) ? round((float)$nm['energy-kcal_100g'], 1) : (isset($nm['energy_100g']) ? round((float)$nm['energy_100g'] / 4.184, 1) : null),
|
||||
'proteins_100g' => isset($nm['proteins_100g']) ? round((float)$nm['proteins_100g'], 1) : null,
|
||||
'carbohydrates_100g' => isset($nm['carbohydrates_100g']) ? round((float)$nm['carbohydrates_100g'], 1) : null,
|
||||
'fat_100g' => isset($nm['fat_100g']) ? round((float)$nm['fat_100g'], 1) : null,
|
||||
'fiber_100g' => isset($nm['fiber_100g']) ? round((float)$nm['fiber_100g'], 1) : null,
|
||||
'salt_100g' => isset($nm['salt_100g']) ? round((float)$nm['salt_100g'], 1) : null,
|
||||
];
|
||||
// Only keep if at least one macro is present
|
||||
if (!array_filter(array_values($nutriments))) $nutriments = null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'brand' => $p['brands'] ?? '',
|
||||
@@ -2206,6 +2316,7 @@ function _offFetchProduct(string $barcode): ?array {
|
||||
'ecoscore' => $p['ecoscore_grade'] ?? '',
|
||||
'labels' => $p['labels'] ?? '',
|
||||
'stores' => $p['stores'] ?? '',
|
||||
'nutriments' => $nutriments,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2371,28 +2482,31 @@ function saveProduct(PDO $db): void {
|
||||
$stmt = $db->prepare("
|
||||
UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?,
|
||||
default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?,
|
||||
nutriments_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=?
|
||||
");
|
||||
$nutriJson = isset($input['nutriments']) ? json_encode($input['nutriments']) : null;
|
||||
$stmt->execute([
|
||||
$input['name'], $input['brand'] ?? '', $input['category'] ?? '',
|
||||
$input['image_url'] ?? '', $input['unit'] ?? 'pz',
|
||||
$input['default_quantity'] ?? 1, $input['notes'] ?? '',
|
||||
$input['barcode'] ?? null, $input['package_unit'] ?? '',
|
||||
$shoppingName, $input['id']
|
||||
$shoppingName, $nutriJson, $input['id']
|
||||
]);
|
||||
echo json_encode(['success' => true, 'id' => $input['id']]);
|
||||
} else {
|
||||
// Insert new
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO products (barcode, name, brand, category, image_url, unit, default_quantity, notes, package_unit, shopping_name, nutriments_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
$barcode = !empty($input['barcode']) ? $input['barcode'] : null;
|
||||
$nutriJson = isset($input['nutriments']) ? json_encode($input['nutriments']) : null;
|
||||
$stmt->execute([
|
||||
$barcode, $input['name'], $input['brand'] ?? '',
|
||||
$input['category'] ?? '', $input['image_url'] ?? '',
|
||||
$input['unit'] ?? 'pz', $input['default_quantity'] ?? 1,
|
||||
$input['notes'] ?? '', $input['package_unit'] ?? '', $shoppingName
|
||||
$input['notes'] ?? '', $input['package_unit'] ?? '', $shoppingName, $nutriJson
|
||||
]);
|
||||
echo json_encode(['success' => true, 'id' => $db->lastInsertId()]);
|
||||
}
|
||||
@@ -2493,10 +2607,12 @@ function addToInventory(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a different unit was specified, update the product's unit
|
||||
// If a different unit was specified, update the product's unit.
|
||||
// NOTE: default_quantity is the PACKAGE SIZE, not the quantity being added —
|
||||
// do NOT overwrite it here. It is managed via product_save / the edit form.
|
||||
if ($unit) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$unit, $quantity, $productId]);
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$unit, $productId]);
|
||||
} else {
|
||||
// Auto-set default_quantity if product has none (first add sets package size)
|
||||
$stmt = $db->prepare("SELECT default_quantity, unit FROM products WHERE id = ?");
|
||||
@@ -2960,7 +3076,7 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total remaining across ALL locations
|
||||
// Calculate total remaining across ALL locations (this product only)
|
||||
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
|
||||
$stmt->execute([$productId]);
|
||||
$totalRemaining = round((float)($stmt->fetchColumn() ?: 0), 6);
|
||||
@@ -2970,8 +3086,26 @@ function useFromInventory(PDO $db): void {
|
||||
$stmt->execute([$productId]);
|
||||
$prodInfo = $stmt->fetch();
|
||||
|
||||
// Also sum related products in the same shopping_name family (same unit) so that
|
||||
// e.g. "Uova Sfoglia Gialla" + "Uova biologiche" are evaluated together for low stock.
|
||||
$totalFamilyRemaining = $totalRemaining;
|
||||
if ($prodInfo) {
|
||||
$sNameKey = strtolower(trim($prodInfo['shopping_name'] ?? ''));
|
||||
$prodUnit = $prodInfo['unit'] ?? '';
|
||||
if ($sNameKey !== '' && $prodUnit !== '') {
|
||||
$famStmt = $db->prepare("
|
||||
SELECT SUM(i.quantity)
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND p.unit = ? AND i.quantity > 0
|
||||
");
|
||||
$famStmt->execute([$sNameKey, $productId, $prodUnit]);
|
||||
$totalFamilyRemaining = round($totalRemaining + (float)($famStmt->fetchColumn() ?: 0), 6);
|
||||
}
|
||||
}
|
||||
|
||||
$response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring,
|
||||
'total_remaining' => $totalRemaining];
|
||||
'total_remaining' => $totalRemaining, 'total_family_remaining' => $totalFamilyRemaining];
|
||||
if ($prodInfo) {
|
||||
$response['product_name'] = $prodInfo['name'];
|
||||
$response['product_brand'] = $prodInfo['brand'] ?: '';
|
||||
@@ -3038,10 +3172,18 @@ function updateInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Update unit on the product if provided
|
||||
// Update unit on the product if provided.
|
||||
// When setting unit back to 'pz', also ensure default_quantity >= 1 so the
|
||||
// barcode-scan auto-detect (which only fires on default_quantity === 0) won't
|
||||
// silently revert the user's correction on the next scan.
|
||||
if (isset($input['unit']) && isset($input['product_id'])) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$input['unit'], $input['product_id']]);
|
||||
$newUnit = $input['unit'];
|
||||
if ($newUnit === 'pz') {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = CASE WHEN default_quantity < 1 THEN 1 ELSE default_quantity END, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
}
|
||||
$stmt->execute([$newUnit, $input['product_id']]);
|
||||
}
|
||||
|
||||
// Update package info if provided
|
||||
@@ -3587,6 +3729,288 @@ function getStats(PDO $db): void {
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== MONTHLY STATS =====
|
||||
/**
|
||||
* Normalize a raw category string (may contain OpenFoodFacts "en:slug" format)
|
||||
* to one of the app's known Italian category slugs.
|
||||
*/
|
||||
function _normalizeCat(string $raw): string {
|
||||
static $known = [
|
||||
'frutta','verdura','carne','pesce','latticini',
|
||||
'pasta','pane','cereali','bevande','condimenti',
|
||||
'surgelati','conserve','snack','altro',
|
||||
];
|
||||
$raw = trim($raw);
|
||||
if (in_array($raw, $known, true)) return $raw;
|
||||
|
||||
// Strip language prefix: "en:", "it:", "fr:", etc.
|
||||
$slug = (string)preg_replace('/^[a-z]{2}:/', '', $raw);
|
||||
if (in_array($slug, $known, true)) return $slug;
|
||||
|
||||
// Map common OpenFoodFacts slugs → app categories
|
||||
static $map = [
|
||||
// latticini
|
||||
'dairies'=>'latticini','dairy'=>'latticini','milk'=>'latticini',
|
||||
'fermented-milk-products'=>'latticini','cheeses'=>'latticini',
|
||||
'yogurts'=>'latticini','plant-based-milks'=>'latticini',
|
||||
'cream'=>'latticini','butter'=>'latticini','eggs'=>'latticini',
|
||||
// frutta
|
||||
'fruits'=>'frutta','fresh-fruits'=>'frutta','tropical-fruits'=>'frutta',
|
||||
'dried-fruits'=>'frutta','berries'=>'frutta',
|
||||
// verdura
|
||||
'vegetables'=>'verdura','fresh-vegetables'=>'verdura',
|
||||
'plant-based-foods'=>'verdura','legumes'=>'verdura',
|
||||
'mushrooms'=>'verdura','herbs'=>'verdura',
|
||||
// carne
|
||||
'meats'=>'carne','beef'=>'carne','pork'=>'carne',
|
||||
'poultry'=>'carne','chicken'=>'carne','processed-meat'=>'carne',
|
||||
'sausages'=>'carne','charcuterie'=>'carne',
|
||||
// pesce
|
||||
'fish'=>'pesce','seafood'=>'pesce','fish-products'=>'pesce',
|
||||
'canned-fish'=>'conserve',
|
||||
// pasta
|
||||
'pastas'=>'pasta','pasta'=>'pasta','pasta-based-dishes'=>'pasta',
|
||||
'noodles'=>'pasta',
|
||||
// pane
|
||||
'breads'=>'pane','bread'=>'pane','baked-goods'=>'pane',
|
||||
'pastries'=>'pane','cakes'=>'snack',
|
||||
// cereali
|
||||
'cereals'=>'cereali','breakfast-cereals'=>'cereali',
|
||||
'rice'=>'cereali','grains'=>'cereali','flours'=>'cereali',
|
||||
'seeds'=>'cereali',
|
||||
// bevande
|
||||
'beverages'=>'bevande','drinks'=>'bevande','waters'=>'bevande',
|
||||
'juices'=>'bevande','fruit-juices'=>'bevande','sodas'=>'bevande',
|
||||
'plant-based-foods-and-beverages'=>'bevande','coffee'=>'bevande',
|
||||
'tea'=>'bevande','alcoholic-beverages'=>'bevande','wine'=>'bevande',
|
||||
'beer'=>'bevande',
|
||||
// condimenti
|
||||
'sauces'=>'condimenti','condiments'=>'condimenti',
|
||||
'spreads'=>'condimenti','oils'=>'condimenti',
|
||||
'vinegars'=>'condimenti','dressings'=>'condimenti',
|
||||
'sugar'=>'condimenti','salt'=>'condimenti','spices'=>'condimenti',
|
||||
// surgelati
|
||||
'frozen-foods'=>'surgelati','frozen-vegetables'=>'surgelati',
|
||||
'frozen-fish'=>'surgelati','ice-cream'=>'surgelati',
|
||||
// conserve
|
||||
'preserved-foods'=>'conserve','canned-foods'=>'conserve',
|
||||
'jams'=>'conserve','pickles'=>'conserve','tomato-sauces'=>'conserve',
|
||||
// snack
|
||||
'snacks'=>'snack','cookies'=>'snack','chips'=>'snack',
|
||||
'chocolates'=>'snack','candies'=>'snack','sweets'=>'snack',
|
||||
'crackers'=>'snack','biscuits'=>'snack','nuts'=>'snack',
|
||||
];
|
||||
|
||||
return $map[$slug] ?? $map[strtolower($slug)] ?? 'altro';
|
||||
}
|
||||
|
||||
function getMonthlyStats(PDO $db): void {
|
||||
EverLog::debug('getMonthlyStats');
|
||||
|
||||
$thisMonthStart = date('Y-m-01');
|
||||
$lastMonthStart = date('Y-m-01', strtotime('first day of last month'));
|
||||
$lastMonthEnd = date('Y-m-01'); // exclusive upper bound for prev month
|
||||
|
||||
// Totals: consumed + added + wasted this month vs previous calendar month
|
||||
$totals = $db->query("
|
||||
SELECT
|
||||
SUM(CASE WHEN created_at >= '{$thisMonthStart}'
|
||||
AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS this_out,
|
||||
SUM(CASE WHEN created_at >= '{$lastMonthStart}' AND created_at < '{$lastMonthEnd}'
|
||||
AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS prev_out,
|
||||
SUM(CASE WHEN created_at >= '{$thisMonthStart}'
|
||||
AND type = 'in' AND undone=0 THEN 1 ELSE 0 END) AS this_in,
|
||||
SUM(CASE WHEN created_at >= '{$thisMonthStart}'
|
||||
AND type = 'waste' AND undone=0 THEN 1 ELSE 0 END) AS this_wasted
|
||||
FROM transactions
|
||||
WHERE created_at >= '{$lastMonthStart}'
|
||||
")->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$thisOut = (int)($totals['this_out'] ?? 0);
|
||||
$prevOut = (int)($totals['prev_out'] ?? 0);
|
||||
$thisIn = (int)($totals['this_in'] ?? 0);
|
||||
$thisWaste = (int)($totals['this_wasted'] ?? 0);
|
||||
|
||||
// Top categories consumed this month
|
||||
$catRows = $db->query("
|
||||
SELECT COALESCE(NULLIF(TRIM(p.category), ''), 'altro') AS cat, COUNT(*) AS cnt
|
||||
FROM transactions t
|
||||
JOIN products p ON t.product_id = p.id
|
||||
WHERE t.type IN ('out','waste') AND t.undone = 0
|
||||
AND t.created_at >= '{$thisMonthStart}'
|
||||
GROUP BY cat
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 5
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$totalCatEvents = array_sum(array_column($catRows, 'cnt')) ?: 1;
|
||||
|
||||
// Normalize OFF slugs (e.g. "en:dairies" → "latticini"), then re-aggregate
|
||||
$normAgg = [];
|
||||
foreach ($catRows as $r) {
|
||||
$norm = _normalizeCat((string)$r['cat']);
|
||||
$normAgg[$norm] = ($normAgg[$norm] ?? 0) + (int)$r['cnt'];
|
||||
}
|
||||
arsort($normAgg);
|
||||
$normAgg = array_slice($normAgg, 0, 4, true);
|
||||
$totalNorm = array_sum($normAgg) ?: 1;
|
||||
$topCats = array_map(fn($cat, $cnt) => [
|
||||
'cat' => $cat,
|
||||
'count' => $cnt,
|
||||
'pct' => (int)round($cnt / $totalNorm * 100),
|
||||
], array_keys($normAgg), array_values($normAgg));
|
||||
|
||||
// Top consumed products this month
|
||||
$topProds = $db->query("
|
||||
SELECT p.name, COUNT(*) AS cnt
|
||||
FROM transactions t
|
||||
JOIN products p ON t.product_id = p.id
|
||||
WHERE t.type IN ('out','waste') AND t.undone = 0
|
||||
AND t.created_at >= '{$thisMonthStart}'
|
||||
GROUP BY t.product_id
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 3
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Estimated € value of wasted items this month (#117)
|
||||
$wastedValueEur = 0.0;
|
||||
if ($thisWaste > 0 && file_exists(PRICE_CACHE_PATH)) {
|
||||
$priceCache = json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?: [];
|
||||
$country = env('PRICE_COUNTRY', 'Italia');
|
||||
$wastedProds = $db->query("
|
||||
SELECT p.name, SUM(t.quantity) AS total_qty, p.unit
|
||||
FROM transactions t
|
||||
JOIN products p ON t.product_id = p.id
|
||||
WHERE t.type = 'waste' AND t.undone = 0
|
||||
AND t.created_at >= '{$thisMonthStart}'
|
||||
GROUP BY t.product_id
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($wastedProds as $wp) {
|
||||
$key = _priceKey($wp['name'], $country);
|
||||
if (isset($priceCache[$key]['unit_price']) && $priceCache[$key]['unit_price'] > 0) {
|
||||
$unitPrice = (float)$priceCache[$key]['unit_price'];
|
||||
$qty = (float)$wp['total_qty'];
|
||||
// For weight/volume units treat qty as single-use events (transactions counted per action)
|
||||
$wastedValueEur += $unitPrice * $qty;
|
||||
}
|
||||
}
|
||||
$wastedValueEur = round($wastedValueEur, 2);
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'month' => date('Y-m'),
|
||||
'items_consumed' => $thisOut,
|
||||
'items_consumed_prev' => $prevOut,
|
||||
'items_added' => $thisIn,
|
||||
'items_wasted' => $thisWaste,
|
||||
'wasted_value_eur' => $wastedValueEur,
|
||||
'top_categories' => $topCats,
|
||||
'top_products' => array_map(fn($r) => [
|
||||
'name' => $r['name'],
|
||||
'count' => (int)$r['cnt'],
|
||||
], $topProds),
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== MACRO STATS (#118) =====
|
||||
/**
|
||||
* Aggregate macronutrients from current inventory.
|
||||
* For products with barcode-fetched nutriments_json, uses real data.
|
||||
* For products without, uses per-category static estimates (per 100g).
|
||||
*/
|
||||
function getMacroStats(PDO $db): void {
|
||||
EverLog::debug('getMacroStats');
|
||||
|
||||
// Static per-category estimates (per 100g, rough averages)
|
||||
$catDefaults = [
|
||||
'frutta' => ['energy_kcal_100g' => 52, 'proteins_100g' => 0.7, 'carbohydrates_100g' => 12.0, 'fat_100g' => 0.3, 'fiber_100g' => 2.0],
|
||||
'verdura' => ['energy_kcal_100g' => 30, 'proteins_100g' => 2.0, 'carbohydrates_100g' => 5.0, 'fat_100g' => 0.2, 'fiber_100g' => 2.5],
|
||||
'carne' => ['energy_kcal_100g' => 200, 'proteins_100g' => 20.0,'carbohydrates_100g' => 0.0, 'fat_100g' => 13.0,'fiber_100g' => 0.0],
|
||||
'pesce' => ['energy_kcal_100g' => 130, 'proteins_100g' => 20.0,'carbohydrates_100g' => 0.0, 'fat_100g' => 5.0, 'fiber_100g' => 0.0],
|
||||
'latticini' => ['energy_kcal_100g' => 150, 'proteins_100g' => 8.0, 'carbohydrates_100g' => 5.0, 'fat_100g' => 8.0, 'fiber_100g' => 0.0],
|
||||
'pasta' => ['energy_kcal_100g' => 350, 'proteins_100g' => 12.0,'carbohydrates_100g' => 70.0, 'fat_100g' => 2.0, 'fiber_100g' => 3.0],
|
||||
'pane' => ['energy_kcal_100g' => 265, 'proteins_100g' => 9.0, 'carbohydrates_100g' => 50.0, 'fat_100g' => 3.0, 'fiber_100g' => 2.5],
|
||||
'cereali' => ['energy_kcal_100g' => 370, 'proteins_100g' => 10.0,'carbohydrates_100g' => 70.0, 'fat_100g' => 4.0, 'fiber_100g' => 6.0],
|
||||
'bevande' => ['energy_kcal_100g' => 40, 'proteins_100g' => 0.2, 'carbohydrates_100g' => 10.0, 'fat_100g' => 0.0, 'fiber_100g' => 0.0],
|
||||
'condimenti' => ['energy_kcal_100g' => 150, 'proteins_100g' => 1.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 10.0,'fiber_100g' => 0.5],
|
||||
'conserve' => ['energy_kcal_100g' => 80, 'proteins_100g' => 4.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 2.0, 'fiber_100g' => 2.0],
|
||||
'surgelati' => ['energy_kcal_100g' => 100, 'proteins_100g' => 8.0, 'carbohydrates_100g' => 10.0, 'fat_100g' => 3.0, 'fiber_100g' => 2.0],
|
||||
'snack' => ['energy_kcal_100g' => 480, 'proteins_100g' => 6.0, 'carbohydrates_100g' => 55.0, 'fat_100g' => 28.0,'fiber_100g' => 2.0],
|
||||
'altro' => ['energy_kcal_100g' => 150, 'proteins_100g' => 4.0, 'carbohydrates_100g' => 20.0, 'fat_100g' => 5.0, 'fiber_100g' => 1.5],
|
||||
];
|
||||
|
||||
$rows = $db->query("
|
||||
SELECT p.name, p.category, p.unit, p.default_quantity, p.nutriments_json, i.quantity
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$totals = ['energy_kcal' => 0.0, 'proteins' => 0.0, 'carbohydrates' => 0.0, 'fat' => 0.0, 'fiber' => 0.0];
|
||||
$itemsWithData = 0;
|
||||
$totalItems = count($rows);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$nm = null;
|
||||
if (!empty($row['nutriments_json'])) {
|
||||
$nm = json_decode($row['nutriments_json'], true);
|
||||
}
|
||||
|
||||
// Estimate grams in inventory for this row
|
||||
$unit = $row['unit'] ?: 'pz';
|
||||
$qty = (float)$row['quantity'];
|
||||
$defQty = (float)($row['default_quantity'] ?: 0);
|
||||
$grams = 100; // default: assume 100g per item if no unit info
|
||||
|
||||
if ($unit === 'g') $grams = $qty;
|
||||
elseif ($unit === 'kg') $grams = $qty * 1000;
|
||||
elseif ($unit === 'ml') $grams = $qty; // approx 1g/ml
|
||||
elseif ($unit === 'l') $grams = $qty * 1000;
|
||||
elseif (in_array($unit, ['pz','conf']) && $defQty >= 20) $grams = $qty * $defQty;
|
||||
elseif (in_array($unit, ['pz','conf']) && $defQty > 0) $grams = $qty * $defQty;
|
||||
|
||||
if ($grams <= 0) $grams = 100;
|
||||
|
||||
// Use real nutriments if available, else fallback to category default
|
||||
if ($nm && isset($nm['proteins_100g'])) {
|
||||
$macro = $nm;
|
||||
} else {
|
||||
$cat = mb_strtolower(trim(_normalizeCat($row['category'] ?? 'altro')));
|
||||
$macro = $catDefaults[$cat] ?? $catDefaults['altro'];
|
||||
}
|
||||
|
||||
$factor = $grams / 100.0;
|
||||
$totals['energy_kcal'] += ($macro['energy_kcal_100g'] ?? 0) * $factor;
|
||||
$totals['proteins'] += ($macro['proteins_100g'] ?? 0) * $factor;
|
||||
$totals['carbohydrates'] += ($macro['carbohydrates_100g'] ?? 0) * $factor;
|
||||
$totals['fat'] += ($macro['fat_100g'] ?? 0) * $factor;
|
||||
$totals['fiber'] += ($macro['fiber_100g'] ?? 0) * $factor;
|
||||
if ($nm && isset($nm['proteins_100g'])) $itemsWithData++;
|
||||
}
|
||||
|
||||
// Round
|
||||
foreach ($totals as $k => $v) $totals[$k] = round($v);
|
||||
|
||||
// Macro ratio percentages (of kcal from P/C/F)
|
||||
$pKcal = $totals['proteins'] * 4;
|
||||
$cKcal = $totals['carbohydrates'] * 4;
|
||||
$fKcal = $totals['fat'] * 9;
|
||||
$sumKcal = max($pKcal + $cKcal + $fKcal, 1);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'total_items' => $totalItems,
|
||||
'items_with_data' => $itemsWithData,
|
||||
'totals' => $totals,
|
||||
'ratios' => [
|
||||
'proteins' => round($pKcal / $sumKcal * 100),
|
||||
'carbohydrates' => round($cKcal / $sumKcal * 100),
|
||||
'fat' => round($fKcal / $sumKcal * 100),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== RECENT & POPULAR PRODUCTS =====
|
||||
function recentPopularProducts(PDO $db): void {
|
||||
EverLog::debug('recentPopularProducts');
|
||||
@@ -5152,7 +5576,9 @@ REGOLE:
|
||||
5. "name": usa ESATTAMENTE il nome dalla lista (il sistema lo usa per scalare l'inventario).
|
||||
6. Includi nella lista ingredienti TUTTI quelli citati nei passi (tranne acqua/sale/pepe/olio).
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}.
|
||||
10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
@@ -6099,6 +6525,8 @@ REGOLE:
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
|
||||
10. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}.
|
||||
11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) ≠ 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' ≠ 'Latte UHT' ≠ 'Panna'; 'Farina 00' ≠ 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
@@ -8615,6 +9043,24 @@ function smartShopping(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Extended predictive horizon for staple items (high-frequency products).
|
||||
// The default predictive block triggers at daysLeft <= 14 for isFrequent (≥1.5/month).
|
||||
// Very frequent items (daily-ish: ≥4/month) or weekly items (≥2/month) should appear
|
||||
// in the shopping list earlier, so the user always has them on their radar when shopping.
|
||||
// ≥ 4/month → 28-day horizon (daily staples: latte, pane, uova…)
|
||||
// ≥ 2/month → 21-day horizon (weekly staples: yogurt, frutta, carne…)
|
||||
if ($urgency === 'none' && $dailyRate > 0 && $isRecent && !$justRestocked) {
|
||||
if ($usesPerMonth >= 4 && $daysLeft <= 28) {
|
||||
$urgency = 'low';
|
||||
$reasons[] = 'Finisce tra ~' . (int)round($daysLeft) . 'gg';
|
||||
$score += 20;
|
||||
} elseif ($usesPerMonth >= 2 && $daysLeft <= 21) {
|
||||
$urgency = 'low';
|
||||
$reasons[] = 'Finisce tra ~' . (int)round($daysLeft) . 'gg';
|
||||
$score += 15;
|
||||
}
|
||||
}
|
||||
|
||||
if ($urgency === 'none') continue;
|
||||
|
||||
// Family stock coverage: suppress items covered by other products in the same generic family.
|
||||
@@ -9092,21 +9538,32 @@ function appSettingsSave(PDO $db): void {
|
||||
|
||||
function recipesList(PDO $db): void {
|
||||
$limit = min(intval($_GET['limit'] ?? 60), 200);
|
||||
$rows = $db->query("SELECT id, date, meal, recipe_json, created_at FROM recipes ORDER BY date DESC, created_at DESC LIMIT {$limit}")->fetchAll();
|
||||
$rows = $db->query("SELECT id, date, meal, recipe_json, created_at, is_favorite FROM recipes ORDER BY is_favorite DESC, date DESC, created_at DESC LIMIT {$limit}")->fetchAll();
|
||||
EverLog::debug('recipesList');
|
||||
$recipes = [];
|
||||
foreach ($rows as $row) {
|
||||
$recipes[] = [
|
||||
'id' => $row['id'],
|
||||
'date' => $row['date'],
|
||||
'meal' => $row['meal'],
|
||||
'recipe' => json_decode($row['recipe_json'], true),
|
||||
'savedAt' => strtotime($row['created_at']) * 1000
|
||||
'id' => $row['id'],
|
||||
'date' => $row['date'],
|
||||
'meal' => $row['meal'],
|
||||
'recipe' => json_decode($row['recipe_json'], true),
|
||||
'savedAt' => strtotime($row['created_at']) * 1000,
|
||||
'is_favorite' => (bool)$row['is_favorite'],
|
||||
];
|
||||
}
|
||||
echo json_encode(['success' => true, 'recipes' => $recipes]);
|
||||
}
|
||||
|
||||
function recipeToggleFavorite(PDO $db): void {
|
||||
EverLog::info('recipeToggleFavorite');
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
$id = intval($input['id'] ?? 0);
|
||||
if ($id <= 0) { echo json_encode(['error' => 'Invalid id']); return; }
|
||||
$db->prepare("UPDATE recipes SET is_favorite = 1 - is_favorite WHERE id = ?")->execute([$id]);
|
||||
$fav = (int)$db->query("SELECT is_favorite FROM recipes WHERE id = {$id}")->fetchColumn();
|
||||
echo json_encode(['success' => true, 'is_favorite' => (bool)$fav]);
|
||||
}
|
||||
|
||||
function recipesSave(PDO $db): void {
|
||||
EverLog::info('recipesSave');
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
@@ -9474,9 +9931,41 @@ function checkUpdate(): void {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return path to the local fingerprint deduplication cache.
|
||||
* Falls back to /tmp when data/ is not writable (e.g. fresh install with wrong perms).
|
||||
*/
|
||||
function _getFpCachePath(): string {
|
||||
$primary = __DIR__ . '/../data/reported_issue_fps.json';
|
||||
return is_writable(dirname($primary)) ? $primary : (sys_get_temp_dir() . '/evershelf_fps.json');
|
||||
}
|
||||
|
||||
/** Load & prune (> 30 days) the local FP cache. */
|
||||
function _loadFpCache(): array {
|
||||
$path = _getFpCachePath();
|
||||
if (!file_exists($path)) return [];
|
||||
$data = @json_decode(@file_get_contents($path), true) ?: [];
|
||||
$cutoff = time() - 30 * 86400;
|
||||
return array_filter($data, fn($v) => ($v['ts'] ?? 0) > $cutoff);
|
||||
}
|
||||
|
||||
/** Persist the local FP cache. */
|
||||
function _saveFpCache(array $cache): void {
|
||||
@file_put_contents(_getFpCachePath(), json_encode($cache), LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a GitHub issue, or add a comment to an existing open issue with the
|
||||
* same fingerprint. Uses the REST API v3 directly (no library needed).
|
||||
*
|
||||
* Deduplication strategy (two-layer):
|
||||
* 1. Local file cache (data/reported_issue_fps.json or /tmp fallback) — checked
|
||||
* first to avoid the GitHub Search API indexing delay that caused duplicate
|
||||
* issues to be created in rapid succession.
|
||||
* 2. GitHub Search API — used only on first occurrence (cache miss) as backup.
|
||||
*
|
||||
* Comment throttle: at most one recurrence comment per 30 minutes per fingerprint,
|
||||
* to avoid flooding an issue when an error fires on every request.
|
||||
*/
|
||||
function _createOrCommentGithubIssue(
|
||||
string $token, string $repo,
|
||||
@@ -9487,13 +9976,27 @@ function _createOrCommentGithubIssue(
|
||||
$fp = _errorFingerprint($source, $type, $message);
|
||||
EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]);
|
||||
|
||||
// ── 1. Search for an existing open issue with this fingerprint ─────────
|
||||
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
||||
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
||||
|
||||
// ── 1. Check local cache (fast, avoids Search API indexing lag) ────────
|
||||
$fpCache = _loadFpCache();
|
||||
$existingIssueNumber = null;
|
||||
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) {
|
||||
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null;
|
||||
if (isset($fpCache[$fp])) {
|
||||
$existingIssueNumber = $fpCache[$fp]['issue'];
|
||||
// Comment throttle: skip if we already commented within the last 30 min
|
||||
$lastComment = $fpCache[$fp]['last_comment'] ?? 0;
|
||||
if (time() - $lastComment < 1800) {
|
||||
EverLog::debug('_createOrCommentGithubIssue: throttled', ['fp' => $fp]);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// ── 2. Fall back to GitHub Search (handles first run / cache cleared) ─
|
||||
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
|
||||
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
|
||||
if (!empty($searchResult['body']['items'][0]['number'])) {
|
||||
$existingIssueNumber = (int)$searchResult['body']['items'][0]['number'];
|
||||
// Populate local cache with what we found
|
||||
$fpCache[$fp] = ['issue' => $existingIssueNumber, 'ts' => time(), 'last_comment' => 0];
|
||||
_saveFpCache($fpCache);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build the common details block ─────────────────────────────────────
|
||||
@@ -9508,7 +10011,7 @@ function _createOrCommentGithubIssue(
|
||||
$verMd = $version ? "\n**Version:** `$version`" : '';
|
||||
|
||||
if ($existingIssueNumber) {
|
||||
// ── 2a. Post a comment to the existing issue ──────────────────────
|
||||
// ── 3a. Post a comment to the existing issue ──────────────────────
|
||||
$body = "### 🔁 Recurrence — $ts\n"
|
||||
. "**Source:** `$source` | **Type:** `$type`\n"
|
||||
. $urlMd . $uaMd . $verMd . "\n"
|
||||
@@ -9518,8 +10021,11 @@ function _createOrCommentGithubIssue(
|
||||
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
|
||||
['body' => $body]
|
||||
);
|
||||
// Update throttle timestamp
|
||||
$fpCache[$fp]['last_comment'] = time();
|
||||
_saveFpCache($fpCache);
|
||||
} else {
|
||||
// ── 2b. Create a new issue ────────────────────────────────────────
|
||||
// ── 3b. Create a new issue ────────────────────────────────────────
|
||||
// Determine labels from source
|
||||
$labelMap = [
|
||||
'pwa' => 'js-error',
|
||||
@@ -9547,7 +10053,7 @@ function _createOrCommentGithubIssue(
|
||||
. "<!-- auto-report fp:$fp -->\n"
|
||||
. "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
|
||||
|
||||
_githubRequest($token, 'POST',
|
||||
$newIssueRes = _githubRequest($token, 'POST',
|
||||
"https://api.github.com/repos/$repo/issues",
|
||||
[
|
||||
'title' => $title,
|
||||
@@ -9555,6 +10061,12 @@ function _createOrCommentGithubIssue(
|
||||
'labels' => ['auto-report', $typeLabel],
|
||||
]
|
||||
);
|
||||
// Save to local cache immediately to prevent duplicates on rapid recurrences
|
||||
$newNum = $newIssueRes['body']['number'] ?? null;
|
||||
if ($newNum) {
|
||||
$fpCache[$fp] = ['issue' => (int)$newNum, 'ts' => time(), 'last_comment' => time()];
|
||||
_saveFpCache($fpCache);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1969,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;
|
||||
@@ -4240,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;
|
||||
}
|
||||
@@ -4276,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 {
|
||||
@@ -6506,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;
|
||||
@@ -6895,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;
|
||||
@@ -7653,6 +7910,9 @@ body.cooking-mode-active .app-header {
|
||||
[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; }
|
||||
|
||||
|
||||
+737
-122
File diff suppressed because it is too large
Load Diff
@@ -35,10 +35,14 @@ Add EverShelf pantry data as native HA sensor entities that update automatically
|
||||
|
||||
| URL | Returns | Sensor |
|
||||
|-----|---------|--------|
|
||||
| `/api/?action=ha_sensor` | Items expiring soon (≤3 days) | `sensor.evershelf_overview` |
|
||||
| `/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
|
||||
|
||||
@@ -61,7 +65,12 @@ sensor:
|
||||
- expired_items
|
||||
- total_items
|
||||
- shopping_items
|
||||
- expiring_list
|
||||
- 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"
|
||||
|
||||
@@ -72,10 +81,62 @@ sensor:
|
||||
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
|
||||
@@ -109,9 +170,24 @@ EverShelf fires an HTTP POST to your HA webhook URL when pantry events occur.
|
||||
"type": "expiring_soon",
|
||||
"count": 3,
|
||||
"days": 3,
|
||||
"summary": "3 products expiring within 3 days",
|
||||
"summary": "Milk, Yogurt, Butter",
|
||||
"items": [
|
||||
{ "name": "Milk", "expiry_date": "2025-06-14", "quantity": 1, "unit": "l" }
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -128,12 +204,25 @@ action:
|
||||
- service: notify.telegram_bot
|
||||
data:
|
||||
message: >
|
||||
🥫 EverShelf: {{ trigger.json.data.summary }}
|
||||
🥫 EverShelf: {{ trigger.json.data.count }} product(s) expiring soon
|
||||
{% for item in trigger.json.data.items %}
|
||||
— {{ item.name }} (expires {{ item.expiry_date }})
|
||||
— {{ 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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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"
|
||||
|
||||
+17
-3
@@ -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>
|
||||
@@ -1314,11 +1321,12 @@
|
||||
</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.tts.ha_hint">🏠 Se usi Home Assistant, usa il tab <strong>Home Assistant</strong> per configurare TTS, webhook e sensori.</span>
|
||||
<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>
|
||||
@@ -1816,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>
|
||||
|
||||
+45
-6
@@ -214,7 +214,13 @@
|
||||
"scan_barcode": "🔖 Barcode scannen",
|
||||
"create_named": "{name} erstellen",
|
||||
"new_without_barcode": "Neues Produkt ohne Barcode",
|
||||
"stock_in_pantry": "Bereits im Vorrat:"
|
||||
"stock_in_pantry": "Bereits im Vorrat:",
|
||||
"status_ready": "Kamera auf Barcode richten",
|
||||
"status_scanning": "Scanne...",
|
||||
"status_partial": "Erkannt: {code} — prüfe...",
|
||||
"status_invalid": "Ungültig: {code} — versuche erneut",
|
||||
"status_confirmed": "Bestätigt!",
|
||||
"status_parallel": "Kombinierter Scan aktiv..."
|
||||
},
|
||||
"action": {
|
||||
"title": "Was möchtest du tun?",
|
||||
@@ -361,6 +367,9 @@
|
||||
"loading_msg": "Rezept wird vorbereitet...",
|
||||
"start_cooking": "👨🍳 Kochmodus",
|
||||
"regenerate": "🔄 Noch eins generieren",
|
||||
"regen_choice_title": "Was möchtest du mit diesem Rezept machen?",
|
||||
"regen_replace": "🔄 Neues generieren (dieses verwerfen)",
|
||||
"regen_save_new": "💾 Im Archiv speichern & neues generieren",
|
||||
"close_btn": "✅ Schließen",
|
||||
"ingredients_title": "🧾 Zutaten",
|
||||
"tools_title": "Benötigte Geräte",
|
||||
@@ -381,7 +390,10 @@
|
||||
"scale_wait_stable": "10s stabiles Gewicht für Auto-Ausfüllen abwarten…",
|
||||
"ingredient_scaled_toast": "📦 Zutat vom Vorrat abgezogen!",
|
||||
"finished_added_bring_toast": "🛒 Produkt aufgebraucht → zu Bring! hinzugefügt!",
|
||||
"load_error": "Fehler beim Laden"
|
||||
"load_error": "Fehler beim Laden",
|
||||
"favorite": "Zu Favoriten hinzufügen",
|
||||
"unfavorite": "Aus Favoriten entfernen",
|
||||
"adjust_persons": "Personen"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Einkaufsliste",
|
||||
@@ -690,6 +702,7 @@
|
||||
"extra_fields_label": "➕ Zusätzliche Felder (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Zusätzliche Felder im Payload, im JSON-Format. Leer lassen wenn nicht benötigt.",
|
||||
"test_sound_btn": "🔔 Klangtest ausführen",
|
||||
"test_btn": "🔊 Testansage senden",
|
||||
"voices_loading": "Stimmen werden geladen…",
|
||||
"voice_not_supported": "Stimme vom Browser nicht unterstützt",
|
||||
@@ -697,7 +710,12 @@
|
||||
"voices_hint": "Verfügbare Stimmen hängen vom Betriebssystem und Browser ab. Auf macOS/iOS ist die Stimme Paola (Italienisch) verfügbar. Drücken Sie ↺ wenn die Liste nicht lädt.",
|
||||
"url_missing": "⚠️ Endpunkt-URL fehlt.",
|
||||
"test_sending": "⏳ Wird gesendet…",
|
||||
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat."
|
||||
"test_ok": "✅ Antwort {code} — prüfe ob der Lautsprecher gesprochen hat.",
|
||||
"heard_question": "Hast du die Stimme gehört?",
|
||||
"heard_yes": "Ja, ich habe es gehört",
|
||||
"heard_no": "Nein, ich habe nichts gehört",
|
||||
"test_ok_kiosk": "TTS funktioniert.",
|
||||
"test_fail_steps": "Prüfe: 1) Medienvolume ist nicht 0; 2) Google Text-to-Speech installiert und aktualisiert; 3) Deutsches Sprachpaket in den Android TTS-Einstellungen heruntergeladen."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Sprache",
|
||||
@@ -1068,11 +1086,13 @@
|
||||
"offline_ai_disabled": "Offline nicht verfügbar",
|
||||
"offline_cache_ready": "Offline — {n} Produkte im Cache"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "Möchtest du dieses Produkt wirklich aus dem Bestand entfernen?",
|
||||
"kiosk_exit": "Kioskmodus verlassen?",
|
||||
"cancel": "Abbrechen",
|
||||
"proceed": "Bestätigen"
|
||||
"proceed": "Bestätigen",
|
||||
"discard_one": "1 Stück wegwerfen"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Vorratskammer",
|
||||
@@ -1084,7 +1104,8 @@
|
||||
"unknown_hint": "Produktname und Informationen eingeben",
|
||||
"label_name": "🏷️ Produktname",
|
||||
"choose_location_title": "Welchen Ort?",
|
||||
"choose_location_hint": "Wähle den zu bearbeitenden Ort:"
|
||||
"choose_location_hint": "Wähle den zu bearbeitenden Ort:",
|
||||
"confirm_large_qty": "Du setzt die Menge auf {qty} {unit}. Das scheint ungewöhnlich hoch zu sein. Bestätigen?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Rezepte",
|
||||
@@ -1238,7 +1259,13 @@
|
||||
"source": "Basierend auf {n} Produkten in deiner Vorratskammer · EverShelf",
|
||||
"products_count": "Produkte",
|
||||
"today_title": "🥗 Deine Vorratskammer heute",
|
||||
"products_n": "{n} Produkte"
|
||||
"products_n": "{n} Produkte",
|
||||
"macros_title": "Geschätzte Makronährstoffe",
|
||||
"macros_proteins": "Proteine",
|
||||
"macros_carbs": "Kohlenhydrate",
|
||||
"macros_fat": "Fett",
|
||||
"macros_fiber": "Ballaststoffe",
|
||||
"macros_source": "Schätzung basierend auf {n} Vorratsprodukten"
|
||||
},
|
||||
"facts": {
|
||||
"greeting_morning": "Guten Morgen",
|
||||
@@ -1415,5 +1442,17 @@
|
||||
"retry": "Erneut versuchen",
|
||||
"syncing_local": "Lokale Daten synchronisieren...",
|
||||
"sync_done": "Lokale Daten aktualisiert"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Monatsstatistik",
|
||||
"consumed": "Produkte verbraucht",
|
||||
"trend_up": "+{pct}% vs. {prev}",
|
||||
"trend_down": "-{pct}% vs. {prev}",
|
||||
"trend_same": "gleiches Tempo wie letzten Monat",
|
||||
"added": "hinzugefügt",
|
||||
"wasted": "verschwendet",
|
||||
"top_used": "meistbenutzt",
|
||||
"top_cats": "Hauptkategorien",
|
||||
"source": "Transaktionsverlauf · aktueller Monat"
|
||||
}
|
||||
}
|
||||
+45
-6
@@ -214,7 +214,13 @@
|
||||
"scan_barcode": "🔖 Scan Barcode",
|
||||
"create_named": "Create {name}",
|
||||
"new_without_barcode": "New product without barcode",
|
||||
"stock_in_pantry": "Already in pantry:"
|
||||
"stock_in_pantry": "Already in pantry:",
|
||||
"status_ready": "Point camera at barcode",
|
||||
"status_scanning": "Scanning...",
|
||||
"status_partial": "Detected: {code} — verifying...",
|
||||
"status_invalid": "Invalid: {code} — retrying",
|
||||
"status_confirmed": "Confirmed!",
|
||||
"status_parallel": "Using combined scan methods..."
|
||||
},
|
||||
"action": {
|
||||
"title": "What do you want to do?",
|
||||
@@ -361,6 +367,9 @@
|
||||
"loading_msg": "Preparing your recipe...",
|
||||
"start_cooking": "👨🍳 Cooking Mode",
|
||||
"regenerate": "🔄 Generate another one",
|
||||
"regen_choice_title": "What do you want to do with this recipe?",
|
||||
"regen_replace": "🔄 Generate another (discard this one)",
|
||||
"regen_save_new": "💾 Save to archive & generate a new one",
|
||||
"close_btn": "✅ Close",
|
||||
"ingredients_title": "🧾 Ingredients",
|
||||
"tools_title": "Equipment needed",
|
||||
@@ -381,7 +390,10 @@
|
||||
"scale_wait_stable": "Wait 10s of stable weight for auto-fill…",
|
||||
"ingredient_scaled_toast": "📦 Ingredient deducted from pantry!",
|
||||
"finished_added_bring_toast": "🛒 Finished product → added to Bring!",
|
||||
"load_error": "Loading error"
|
||||
"load_error": "Loading error",
|
||||
"favorite": "Add to favourites",
|
||||
"unfavorite": "Remove from favourites",
|
||||
"adjust_persons": "Persons"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Shopping List",
|
||||
@@ -690,6 +702,7 @@
|
||||
"extra_fields_label": "➕ Extra fields (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Additional fields to include in the payload, in JSON format. Leave empty if not needed.",
|
||||
"test_sound_btn": "🔔 Run Sound Test",
|
||||
"test_btn": "🔊 Send Test Voice",
|
||||
"voices_loading": "Loading voices…",
|
||||
"voice_not_supported": "Voice not supported by this browser",
|
||||
@@ -697,7 +710,12 @@
|
||||
"voices_hint": "Available voices depend on the OS and browser. On macOS/iOS the Paola (Italian) voice is available. Press ↺ if the list does not load.",
|
||||
"url_missing": "⚠️ Endpoint URL missing.",
|
||||
"test_sending": "⏳ Sending…",
|
||||
"test_ok": "✅ Response {code} — check that the speaker has spoken."
|
||||
"test_ok": "✅ Response {code} — check that the speaker has spoken.",
|
||||
"heard_question": "Did you hear the voice?",
|
||||
"heard_yes": "Yes, I heard it",
|
||||
"heard_no": "No, I didn't hear it",
|
||||
"test_ok_kiosk": "TTS is working.",
|
||||
"test_fail_steps": "Check: 1) media volume is not 0; 2) Google Text-to-Speech is installed and updated; 3) Italian voice package is downloaded in Android TTS settings."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Language",
|
||||
@@ -1068,11 +1086,13 @@
|
||||
"offline_ai_disabled": "Not available offline",
|
||||
"offline_cache_ready": "Offline — {n} items cached"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "Do you really want to remove this product from inventory?",
|
||||
"kiosk_exit": "Exit kiosk mode?",
|
||||
"cancel": "Cancel",
|
||||
"proceed": "Confirm"
|
||||
"proceed": "Confirm",
|
||||
"discard_one": "Discard 1 piece"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Pantry",
|
||||
@@ -1084,7 +1104,8 @@
|
||||
"unknown_hint": "Enter the product name and information",
|
||||
"label_name": "🏷️ Product name",
|
||||
"choose_location_title": "Which location?",
|
||||
"choose_location_hint": "Choose the location to edit:"
|
||||
"choose_location_hint": "Choose the location to edit:",
|
||||
"confirm_large_qty": "You are setting the quantity to {qty} {unit}. This seems unusually high. Confirm?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recipes",
|
||||
@@ -1238,7 +1259,13 @@
|
||||
"source": "Based on {n} products in your pantry · EverShelf",
|
||||
"products_count": "products",
|
||||
"today_title": "🥗 Your pantry today",
|
||||
"products_n": "{n} products"
|
||||
"products_n": "{n} products",
|
||||
"macros_title": "Estimated Macronutrients",
|
||||
"macros_proteins": "Proteins",
|
||||
"macros_carbs": "Carbohydrates",
|
||||
"macros_fat": "Fat",
|
||||
"macros_fiber": "Fibre",
|
||||
"macros_source": "Estimate based on {n} pantry products"
|
||||
},
|
||||
"facts": {
|
||||
"greeting_morning": "Good morning",
|
||||
@@ -1415,5 +1442,17 @@
|
||||
"retry": "Retry",
|
||||
"syncing_local": "Syncing local data...",
|
||||
"sync_done": "Local data synced"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Monthly Stats",
|
||||
"consumed": "products used",
|
||||
"trend_up": "+{pct}% vs {prev}",
|
||||
"trend_down": "-{pct}% vs {prev}",
|
||||
"trend_same": "same pace as last month",
|
||||
"added": "added",
|
||||
"wasted": "wasted",
|
||||
"top_used": "top used",
|
||||
"top_cats": "Top categories",
|
||||
"source": "Transaction history · current month"
|
||||
}
|
||||
}
|
||||
+44
-6
@@ -211,7 +211,13 @@
|
||||
"barcode_acquired": "🔖 Código de barras escaneado: {code}",
|
||||
"scan_barcode": "🔖 Escanear código de barras",
|
||||
"create_named": "Crear {name}",
|
||||
"new_without_barcode": "Nuevo producto sin código de barras"
|
||||
"new_without_barcode": "Nuevo producto sin código de barras",
|
||||
"status_ready": "Apunta la cámara al código de barras",
|
||||
"status_scanning": "Escaneando...",
|
||||
"status_partial": "Detectado: {code} — verificando...",
|
||||
"status_invalid": "Inválido: {code} — reintentando",
|
||||
"status_confirmed": "Confirmado!",
|
||||
"status_parallel": "Escaneo combinado activo..."
|
||||
},
|
||||
"action": {
|
||||
"title": "¿Qué quieres hacer?",
|
||||
@@ -357,6 +363,9 @@
|
||||
"loading_msg": "Preparando tu receta...",
|
||||
"start_cooking": "👨🍳 Modo cocina",
|
||||
"regenerate": "🔄 Generar otra",
|
||||
"regen_choice_title": "¿Qué quieres hacer con esta receta?",
|
||||
"regen_replace": "🔄 Generar otra (descartar esta)",
|
||||
"regen_save_new": "💾 Guardar en el archivo y generar una nueva",
|
||||
"close_btn": "✅ Cerrar",
|
||||
"ingredients_title": "🧾 Ingredientes",
|
||||
"tools_title": "Equipo necesario",
|
||||
@@ -376,7 +385,10 @@
|
||||
"scale_wait_stable": "Espera 10s de peso estable para el relleno automático…",
|
||||
"ingredient_scaled_toast": "📦 ¡Ingrediente deducido de la despensa!",
|
||||
"finished_added_bring_toast": "🛒 Producto terminado → ¡añadido a Bring!",
|
||||
"load_error": "Error de carga"
|
||||
"load_error": "Error de carga",
|
||||
"favorite": "Añadir a favoritos",
|
||||
"unfavorite": "Quitar de favoritos",
|
||||
"adjust_persons": "Personas"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista de la compra",
|
||||
@@ -691,7 +703,12 @@
|
||||
"voices_hint": "Las voces disponibles dependen del SO y el navegador. Pulsa ↺ si la lista no carga.",
|
||||
"url_missing": "⚠️ URL del endpoint faltante.",
|
||||
"test_sending": "⏳ Enviando…",
|
||||
"test_ok": "✅ Respuesta {code} — comprueba que el altavoz haya hablado."
|
||||
"test_ok": "✅ Respuesta {code} — comprueba que el altavoz haya hablado.",
|
||||
"heard_question": "¿Has escuchado la voz?",
|
||||
"heard_yes": "Sí, la escuché",
|
||||
"heard_no": "No, no escuché nada",
|
||||
"test_ok_kiosk": "TTS funcionando.",
|
||||
"test_fail_steps": "Comprueba: 1) el volumen del multimedia no es 0; 2) Google Text-to-Speech está instalado y actualizado; 3) el paquete de voz español está descargado en la configuración TTS de Android."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Idioma",
|
||||
@@ -1020,11 +1037,13 @@
|
||||
"offline_ai_disabled": "No disponible sin conexión",
|
||||
"offline_cache_ready": "Offline — {n} productos en caché"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "¿Realmente quieres eliminar este producto del inventario?",
|
||||
"kiosk_exit": "¿Salir del modo kiosco?",
|
||||
"cancel": "Cancelar",
|
||||
"proceed": "Confirmar"
|
||||
"proceed": "Confirmar",
|
||||
"discard_one": "Tirar 1 unidad"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Despensa",
|
||||
@@ -1036,7 +1055,8 @@
|
||||
"unknown_hint": "Introduce el nombre del producto y la información",
|
||||
"label_name": "🏷️ Nombre del producto",
|
||||
"choose_location_title": "¿Qué ubicación?",
|
||||
"choose_location_hint": "Elige la ubicación a editar:"
|
||||
"choose_location_hint": "Elige la ubicación a editar:",
|
||||
"confirm_large_qty": "Estás configurando la cantidad a {qty} {unit}. Esto parece inusualmente alto. ¿Confirmar?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recetas",
|
||||
@@ -1187,7 +1207,13 @@
|
||||
"source": "Basado en {n} productos en tu despensa · EverShelf",
|
||||
"products_count": "productos",
|
||||
"today_title": "🥗 Tu despensa hoy",
|
||||
"products_n": "{n} productos"
|
||||
"products_n": "{n} productos",
|
||||
"macros_title": "Macronutrientes estimados",
|
||||
"macros_proteins": "Proteínas",
|
||||
"macros_carbs": "Carbohidratos",
|
||||
"macros_fat": "Grasas",
|
||||
"macros_fiber": "Fibra",
|
||||
"macros_source": "Estimación basada en {n} productos en despensa"
|
||||
},
|
||||
"facts": {
|
||||
"greeting_morning": "Buenos días",
|
||||
@@ -1359,5 +1385,17 @@
|
||||
"retry": "Reintentar",
|
||||
"syncing_local": "Sincronizando datos locales...",
|
||||
"sync_done": "Datos locales sincronizados"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Estadísticas Mensuales",
|
||||
"consumed": "productos usados",
|
||||
"trend_up": "+{pct}% vs {prev}",
|
||||
"trend_down": "-{pct}% vs {prev}",
|
||||
"trend_same": "mismo ritmo que el mes pasado",
|
||||
"added": "añadidos",
|
||||
"wasted": "desperdiciados",
|
||||
"top_used": "más usado",
|
||||
"top_cats": "Categorías principales",
|
||||
"source": "Historial de transacciones · mes actual"
|
||||
}
|
||||
}
|
||||
+44
-6
@@ -211,7 +211,13 @@
|
||||
"barcode_acquired": "🔖 Code-barres scanné : {code}",
|
||||
"scan_barcode": "🔖 Scanner le code-barres",
|
||||
"create_named": "Créer {name}",
|
||||
"new_without_barcode": "Nouveau produit sans code-barres"
|
||||
"new_without_barcode": "Nouveau produit sans code-barres",
|
||||
"status_ready": "Pointez la caméra sur le code-barres",
|
||||
"status_scanning": "Scan en cours...",
|
||||
"status_partial": "Lu : {code} — vérification...",
|
||||
"status_invalid": "Invalide : {code} — nouvel essai",
|
||||
"status_confirmed": "Confirmé !",
|
||||
"status_parallel": "Scan combiné actif..."
|
||||
},
|
||||
"action": {
|
||||
"title": "Que voulez-vous faire ?",
|
||||
@@ -357,6 +363,9 @@
|
||||
"loading_msg": "Préparation de votre recette...",
|
||||
"start_cooking": "👨🍳 Mode cuisine",
|
||||
"regenerate": "🔄 En générer une autre",
|
||||
"regen_choice_title": "Que veux-tu faire de cette recette ?",
|
||||
"regen_replace": "🔄 En générer une autre (ignorer celle-ci)",
|
||||
"regen_save_new": "💾 Sauvegarder dans l'archive et en générer une nouvelle",
|
||||
"close_btn": "✅ Fermer",
|
||||
"ingredients_title": "🧾 Ingrédients",
|
||||
"tools_title": "Matériel nécessaire",
|
||||
@@ -376,7 +385,10 @@
|
||||
"scale_wait_stable": "Attendez 10s de poids stable pour le remplissage automatique…",
|
||||
"ingredient_scaled_toast": "📦 Ingrédient déduit du garde-manger !",
|
||||
"finished_added_bring_toast": "🛒 Produit terminé → ajouté à Bring !",
|
||||
"load_error": "Erreur de chargement"
|
||||
"load_error": "Erreur de chargement",
|
||||
"favorite": "Ajouter aux favoris",
|
||||
"unfavorite": "Retirer des favoris",
|
||||
"adjust_persons": "Personnes"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Liste de courses",
|
||||
@@ -691,7 +703,12 @@
|
||||
"voices_hint": "Les voix disponibles dépendent du système d'exploitation et du navigateur. Appuyez sur ↺ si la liste ne se charge pas.",
|
||||
"url_missing": "⚠️ URL de l'endpoint manquante.",
|
||||
"test_sending": "⏳ Envoi…",
|
||||
"test_ok": "✅ Réponse {code} — vérifiez que le haut-parleur a parlé."
|
||||
"test_ok": "✅ Réponse {code} — vérifiez que le haut-parleur a parlé.",
|
||||
"heard_question": "Avez-vous entendu la voix ?",
|
||||
"heard_yes": "Oui, je l'ai entendu",
|
||||
"heard_no": "Non, je n'ai rien entendu",
|
||||
"test_ok_kiosk": "TTS fonctionne.",
|
||||
"test_fail_steps": "Vérifiez : 1) le volume média n'est pas 0 ; 2) Google Text-to-Speech est installé et mis à jour ; 3) le pack vocal français est téléchargé dans les paramètres TTS Android."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Langue",
|
||||
@@ -1020,11 +1037,13 @@
|
||||
"offline_ai_disabled": "Indisponible hors ligne",
|
||||
"offline_cache_ready": "Offline — {n} produits en cache"
|
||||
},
|
||||
"confirm_placeholder_search": null,
|
||||
"confirm": {
|
||||
"remove_item": "Voulez-vous vraiment supprimer ce produit de l'inventaire ?",
|
||||
"kiosk_exit": "Quitter le mode kiosque ?",
|
||||
"cancel": "Annuler",
|
||||
"proceed": "Confirmer"
|
||||
"proceed": "Confirmer",
|
||||
"discard_one": "Jeter 1 pièce"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Garde-manger",
|
||||
@@ -1036,7 +1055,8 @@
|
||||
"unknown_hint": "Entrez le nom du produit et les informations",
|
||||
"label_name": "🏷️ Nom du produit",
|
||||
"choose_location_title": "Quel emplacement ?",
|
||||
"choose_location_hint": "Choisissez l'emplacement à modifier :"
|
||||
"choose_location_hint": "Choisissez l'emplacement à modifier :",
|
||||
"confirm_large_qty": "Vous définissez la quantité à {qty} {unit}. Cela semble inhabituellement élevé. Confirmer ?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Recettes",
|
||||
@@ -1187,7 +1207,13 @@
|
||||
"source": "Basé sur {n} produits dans votre garde-manger · EverShelf",
|
||||
"products_count": "produits",
|
||||
"today_title": "🥗 Votre garde-manger aujourd'hui",
|
||||
"products_n": "{n} produits"
|
||||
"products_n": "{n} produits",
|
||||
"macros_title": "Macronutriments estimés",
|
||||
"macros_proteins": "Protéines",
|
||||
"macros_carbs": "Glucides",
|
||||
"macros_fat": "Lipides",
|
||||
"macros_fiber": "Fibres",
|
||||
"macros_source": "Estimation basée sur {n} produits en stock"
|
||||
},
|
||||
"facts": {
|
||||
"greeting_morning": "Bonjour",
|
||||
@@ -1359,5 +1385,17 @@
|
||||
"retry": "Réessayer",
|
||||
"syncing_local": "Synchronisation des données locales...",
|
||||
"sync_done": "Données locales synchronisées"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Statistiques Mensuelles",
|
||||
"consumed": "produits utilisés",
|
||||
"trend_up": "+{pct}% vs {prev}",
|
||||
"trend_down": "-{pct}% vs {prev}",
|
||||
"trend_same": "même rythme que le mois dernier",
|
||||
"added": "ajoutés",
|
||||
"wasted": "gaspillés",
|
||||
"top_used": "le plus utilisé",
|
||||
"top_cats": "Catégories principales",
|
||||
"source": "Historique des transactions · mois en cours"
|
||||
}
|
||||
}
|
||||
+44
-6
@@ -214,7 +214,13 @@
|
||||
"scan_barcode": "🔖 Scansiona Barcode",
|
||||
"create_named": "Crea {name}",
|
||||
"new_without_barcode": "Nuovo prodotto senza barcode",
|
||||
"stock_in_pantry": "Hai gia in dispensa:"
|
||||
"stock_in_pantry": "Hai gia in dispensa:",
|
||||
"status_ready": "Inquadra il codice a barre",
|
||||
"status_scanning": "Scansione in corso...",
|
||||
"status_partial": "Letto: {code} — verifico...",
|
||||
"status_invalid": "Non valido: {code} — riprovo",
|
||||
"status_confirmed": "Confermato!",
|
||||
"status_parallel": "Doppia scansione attiva..."
|
||||
},
|
||||
"action": {
|
||||
"title": "Cosa vuoi fare?",
|
||||
@@ -361,6 +367,9 @@
|
||||
"loading_msg": "Sto preparando la ricetta...",
|
||||
"start_cooking": "👨🍳 Modalità Cucina",
|
||||
"regenerate": "🔄 Generane un'altra",
|
||||
"regen_choice_title": "Cosa vuoi fare con questa ricetta?",
|
||||
"regen_replace": "🔄 Genera un'altra (scarta questa)",
|
||||
"regen_save_new": "💾 Salva nell'archivio e genera una nuova",
|
||||
"close_btn": "✅ Chiudi",
|
||||
"ingredients_title": "🧾 Ingredienti",
|
||||
"tools_title": "Strumenti necessari",
|
||||
@@ -381,7 +390,10 @@
|
||||
"scale_wait_stable": "Attendi 10s di stabilità per la compilazione automatica…",
|
||||
"ingredient_scaled_toast": "📦 Ingrediente scalato dalla dispensa!",
|
||||
"finished_added_bring_toast": "🛒 Prodotto finito → aggiunto a Bring!",
|
||||
"load_error": "Errore nel caricamento"
|
||||
"load_error": "Errore nel caricamento",
|
||||
"favorite": "Aggiungi ai preferiti",
|
||||
"unfavorite": "Rimuovi dai preferiti",
|
||||
"adjust_persons": "Persone"
|
||||
},
|
||||
"shopping": {
|
||||
"title": "🛒 Lista della Spesa",
|
||||
@@ -690,6 +702,7 @@
|
||||
"extra_fields_label": "➕ Campi extra (JSON)",
|
||||
"extra_fields_placeholder": "{\"entity_id\": \"media_player.living_room\"}",
|
||||
"extra_fields_hint": "Campi aggiuntivi da includere nel payload, in formato JSON. Lascia vuoto se non necessario.",
|
||||
"test_sound_btn": "🔔 Esegui Test Suono",
|
||||
"test_btn": "🔊 Invia Test Vocale",
|
||||
"voices_loading": "Caricamento voci…",
|
||||
"voice_not_supported": "Voce non supportata dal browser",
|
||||
@@ -697,7 +710,12 @@
|
||||
"voices_hint": "Le voci disponibili dipendono dal sistema operativo e dal browser. Su macOS/iOS è disponibile la voce Paola (italiano). Premi ↺ se la lista non si carica.",
|
||||
"url_missing": "⚠️ URL endpoint mancante.",
|
||||
"test_sending": "⏳ Invio in corso…",
|
||||
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato."
|
||||
"test_ok": "✅ Risposta {code} — controlla che l'altoparlante abbia parlato.",
|
||||
"heard_question": "Hai sentito la voce?",
|
||||
"heard_yes": "Sì, ho sentito",
|
||||
"heard_no": "No, non ho sentito",
|
||||
"test_ok_kiosk": "TTS funzionante.",
|
||||
"test_fail_steps": "Controlla: 1) volume media del dispositivo non sia 0; 2) Google Text-to-Speech installato e aggiornato; 3) pacchetto vocale italiano scaricato nelle impostazioni TTS Android."
|
||||
},
|
||||
"language": {
|
||||
"title": "🌐 Lingua / Language",
|
||||
@@ -1072,7 +1090,8 @@
|
||||
"remove_item": "Vuoi davvero rimuovere questo prodotto dall'inventario?",
|
||||
"kiosk_exit": "Uscire dalla modalità kiosk?",
|
||||
"cancel": "Annulla",
|
||||
"proceed": "Conferma"
|
||||
"proceed": "Conferma",
|
||||
"discard_one": "Butta 1 pezzo"
|
||||
},
|
||||
"location": {
|
||||
"dispensa": "Dispensa",
|
||||
@@ -1084,7 +1103,8 @@
|
||||
"unknown_hint": "Inserisci il nome e le informazioni del prodotto",
|
||||
"label_name": "🏷️ Nome prodotto",
|
||||
"choose_location_title": "Quale modifica?",
|
||||
"choose_location_hint": "Scegli la posizione da modificare:"
|
||||
"choose_location_hint": "Scegli la posizione da modificare:",
|
||||
"confirm_large_qty": "Stai impostando la quantità a {qty} {unit}. Questo sembra un valore insolitamente alto. Confermare?"
|
||||
},
|
||||
"screensaver": {
|
||||
"recipe_btn": "Ricette",
|
||||
@@ -1249,7 +1269,13 @@
|
||||
"source": "Basato su {n} prodotti in dispensa · EverShelf",
|
||||
"products_count": "prodotti",
|
||||
"today_title": "🥗 La tua dispensa oggi",
|
||||
"products_n": "{n} prodotti"
|
||||
"products_n": "{n} prodotti",
|
||||
"macros_title": "Macronutrienti stimati",
|
||||
"macros_proteins": "Proteine",
|
||||
"macros_carbs": "Carboidrati",
|
||||
"macros_fat": "Grassi",
|
||||
"macros_fiber": "Fibre",
|
||||
"macros_source": "Stima basata su {n} prodotti in dispensa"
|
||||
},
|
||||
"facts": {
|
||||
"greeting_morning": "Buongiorno",
|
||||
@@ -1415,5 +1441,17 @@
|
||||
"retry": "Riprova",
|
||||
"syncing_local": "Sincronizzazione dati locali...",
|
||||
"sync_done": "Dati locali aggiornati"
|
||||
},
|
||||
"stats_monthly": {
|
||||
"title": "Statistiche Mensili",
|
||||
"consumed": "prodotti usati",
|
||||
"trend_up": "+{pct}% rispetto a {prev}",
|
||||
"trend_down": "-{pct}% rispetto a {prev}",
|
||||
"trend_same": "stesso ritmo del mese scorso",
|
||||
"added": "aggiunti",
|
||||
"wasted": "sprecati",
|
||||
"top_used": "più usato",
|
||||
"top_cats": "Categorie principali",
|
||||
"source": "Storico transazioni · mese corrente"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user