feat: recipe favorites (#124), portion rescaler (#123), financial waste report (#117), macronutrient panel (#118)
- #124: star toggle on recipe view + favorites shown first in archive with gold border - #123: +/- persons buttons on recipe to scale ingredient quantities - #117: wasted value in EUR displayed in monthly stats section - #118: macronutrient breakdown panel (P/C/F/fiber bars) with 4th insight rotation phase - DB: is_favorite column on recipes, nutriments_json on products (auto-migrated) - OFF API: nutriments fields fetched and stored per product - Translations: it/en/de/fr/es updated with new keys
This commit is contained in:
@@ -277,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; }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+168
-7
@@ -922,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;
|
||||
@@ -2140,7 +2146,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];
|
||||
@@ -2200,6 +2206,22 @@ function _offFetchProduct(string $barcode): ?array {
|
||||
$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'] ?? '',
|
||||
@@ -2215,6 +2237,7 @@ function _offFetchProduct(string $barcode): ?array {
|
||||
'ecoscore' => $p['ecoscore_grade'] ?? '',
|
||||
'labels' => $p['labels'] ?? '',
|
||||
'stores' => $p['stores'] ?? '',
|
||||
'nutriments' => $nutriments,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2380,28 +2403,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()]);
|
||||
}
|
||||
@@ -3739,6 +3765,31 @@ function getMonthlyStats(PDO $db): void {
|
||||
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'),
|
||||
@@ -3746,6 +3797,7 @@ function getMonthlyStats(PDO $db): void {
|
||||
'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'],
|
||||
@@ -3754,6 +3806,104 @@ function getMonthlyStats(PDO $db): void {
|
||||
]);
|
||||
}
|
||||
|
||||
// ===== 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');
|
||||
@@ -9279,7 +9429,7 @@ 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) {
|
||||
@@ -9288,12 +9438,23 @@ function recipesList(PDO $db): void {
|
||||
'date' => $row['date'],
|
||||
'meal' => $row['meal'],
|
||||
'recipe' => json_decode($row['recipe_json'], true),
|
||||
'savedAt' => strtotime($row['created_at']) * 1000
|
||||
'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);
|
||||
|
||||
@@ -6535,6 +6535,108 @@ 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.4rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
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: 4px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
|
||||
.btn-persons-adj {
|
||||
background: var(--bg-secondary, #1e2a3a);
|
||||
border: 1px solid var(--border-color, #2a3a50);
|
||||
color: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-persons-adj:hover { background: var(--accent, #6366f1); color: #fff; }
|
||||
|
||||
/* ===== 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;
|
||||
|
||||
+147
-14
@@ -4411,8 +4411,14 @@ function _renderMonthlyStatsSection(data) {
|
||||
const badges = [];
|
||||
if (data.items_added > 0)
|
||||
badges.push(`<span class="aw-badge"><span class="aw-badge-icon">📦</span><span class="aw-badge-body"><b>${data.items_added}</b><small>${t('stats_monthly.added')}</small></span></span>`);
|
||||
if (data.items_wasted > 0)
|
||||
badges.push(`<span class="aw-badge aw-badge-wasted"><span class="aw-badge-icon">🗑️</span><span class="aw-badge-body"><b>${data.items_wasted}</b><small>${t('stats_monthly.wasted')}</small></span></span>`);
|
||||
if (data.items_wasted > 0) {
|
||||
let wastedBadgeText = `<b>${data.items_wasted}</b><small>${t('stats_monthly.wasted')}</small>`;
|
||||
if (data.wasted_value_eur > 0) {
|
||||
const sym = getSettings().price_currency === 'USD' ? '$' : (getSettings().price_currency === 'GBP' ? '£' : '€');
|
||||
wastedBadgeText = `<b>${data.items_wasted}</b><small>${t('stats_monthly.wasted')} · ${sym}${data.wasted_value_eur.toFixed(2)}</small>`;
|
||||
}
|
||||
badges.push(`<span class="aw-badge aw-badge-wasted"><span class="aw-badge-icon">🗑️</span><span class="aw-badge-body">${wastedBadgeText}</span></span>`);
|
||||
}
|
||||
if (data.top_products?.length > 0)
|
||||
badges.push(`<span class="aw-badge aw-badge-better"><span class="aw-badge-icon">⭐</span><span class="aw-badge-body"><b>${escapeHtml(data.top_products[0].name)}</b><small>${t('stats_monthly.top_used')}</small></span></span>`);
|
||||
|
||||
@@ -4449,11 +4455,59 @@ function _renderMonthlyStatsSection(data) {
|
||||
section.style.display = (_insightPhase === 'monthly') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ===== MACROS SECTION (#118) =====
|
||||
/**
|
||||
* Render the macronutrient breakdown panel into #macros-section.
|
||||
*/
|
||||
function _renderMacrosSection(data) {
|
||||
const section = document.getElementById('macros-section');
|
||||
if (!section) return;
|
||||
if (!data || !data.success || data.total_items === 0) {
|
||||
section.innerHTML = '';
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const { totals, ratios, total_items } = data;
|
||||
const macros = [
|
||||
{ key: 'carbohydrates', label: t('nutrition.macros_carbs'), color: '#a78bfa', value: totals.carbohydrates, unit: 'g', pct: ratios.carbohydrates },
|
||||
{ key: 'fat', label: t('nutrition.macros_fat'), color: '#fbbf24', value: totals.fat, unit: 'g', pct: ratios.fat },
|
||||
{ key: 'proteins', label: t('nutrition.macros_proteins'), color: '#4ade80', value: totals.proteins, unit: 'g', pct: ratios.proteins },
|
||||
{ key: 'fiber', label: t('nutrition.macros_fiber'), color: '#34d399', value: totals.fiber, unit: 'g', pct: null },
|
||||
];
|
||||
|
||||
const bars = macros.map(m => {
|
||||
const barPct = m.pct !== null ? m.pct : Math.min(100, Math.round((m.value / Math.max(totals.carbohydrates + totals.fat + totals.proteins, 1)) * 100));
|
||||
return `<div class="macro-row">
|
||||
<span class="macro-label">${m.label}</span>
|
||||
<div class="macro-bar-wrap">
|
||||
<div class="macro-bar-fill" style="background:${m.color}" data-target="${barPct}"></div>
|
||||
</div>
|
||||
<span class="macro-val">${m.value.toLocaleString(_currentLang === 'de' ? 'de-DE' : 'it-IT')}${m.unit}${m.pct !== null ? ` <small>(${m.pct}%)</small>` : ''}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
section.innerHTML = `
|
||||
<div class="nutr-card">
|
||||
<div class="aw-header">
|
||||
<div class="aw-title-row">
|
||||
<span class="aw-live-dot aw-live-on"></span>
|
||||
<h3 class="aw-title">${t('nutrition.macros_title')}</h3>
|
||||
</div>
|
||||
<span class="aw-grade" style="background:#0ea5e9;font-size:.75rem;padding:4px 10px">${totals.energy_kcal.toLocaleString()} kcal</span>
|
||||
</div>
|
||||
<div class="macro-bars">${bars}</div>
|
||||
<div class="aw-source">${t('nutrition.macros_source').replace('{n}', total_items)}</div>
|
||||
</div>`;
|
||||
|
||||
section.style.display = (_insightPhase === 'macros') ? 'block' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the waste ↔ nutrition ↔ monthly stats alternation on the dashboard.
|
||||
*/
|
||||
let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly'
|
||||
const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly'];
|
||||
let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly' | 'macros'
|
||||
const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly', 'macros'];
|
||||
|
||||
function _startInsightAlternation() {
|
||||
clearInterval(_insightFlipTimer);
|
||||
@@ -4472,6 +4526,7 @@ function _applyInsightPhase() {
|
||||
const wasteEl = document.getElementById('waste-chart-section');
|
||||
const nutrEl = document.getElementById('nutrition-section');
|
||||
const monthlyEl = document.getElementById('monthly-stats-section');
|
||||
const macrosEl = document.getElementById('macros-section');
|
||||
if (!wasteEl || !nutrEl) return;
|
||||
|
||||
// Map of which panels actually have rendered content
|
||||
@@ -4479,6 +4534,7 @@ function _applyInsightPhase() {
|
||||
'waste': wasteEl.innerHTML.trim() !== '',
|
||||
'nutrition': nutrEl.innerHTML.trim() !== '',
|
||||
'monthly': !!monthlyEl && monthlyEl.innerHTML.trim() !== '',
|
||||
'macros': !!macrosEl && macrosEl.innerHTML.trim() !== '',
|
||||
};
|
||||
|
||||
// If the intended phase has no content, advance to the next one that does
|
||||
@@ -4491,14 +4547,16 @@ function _applyInsightPhase() {
|
||||
const showWaste = phase === 'waste';
|
||||
const showNutr = phase === 'nutrition';
|
||||
const showMonthly = phase === 'monthly';
|
||||
const showMacros = phase === 'macros';
|
||||
|
||||
// Fade-swap all three panels
|
||||
const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : [])];
|
||||
// Fade-swap all four panels
|
||||
const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : []), ...(macrosEl ? [macrosEl] : [])];
|
||||
els.forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; });
|
||||
setTimeout(() => {
|
||||
wasteEl.style.display = showWaste ? 'block' : 'none';
|
||||
nutrEl.style.display = showNutr ? 'block' : 'none';
|
||||
if (monthlyEl) monthlyEl.style.display = showMonthly ? 'block' : 'none';
|
||||
if (macrosEl) macrosEl.style.display = showMacros ? 'block' : 'none';
|
||||
requestAnimationFrame(() => {
|
||||
els.forEach(el => { el.style.opacity = '1'; });
|
||||
if (showNutr) {
|
||||
@@ -4512,6 +4570,12 @@ function _applyInsightPhase() {
|
||||
bar.style.width = (bar.dataset.target || 0) + '%';
|
||||
});
|
||||
}
|
||||
if (showMacros && macrosEl) {
|
||||
macrosEl.querySelectorAll('.macro-bar-fill').forEach(bar => {
|
||||
bar.style.transition = 'width 0.6s ease';
|
||||
bar.style.width = (bar.dataset.target || 0) + '%';
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 620);
|
||||
}
|
||||
@@ -4629,10 +4693,11 @@ async function loadDashboard() {
|
||||
loadBannerAlerts();
|
||||
|
||||
// Anti-waste section + Nutrition section + Monthly stats: load in parallel
|
||||
const [, invForNutr, monthlyData] = await Promise.all([
|
||||
const [, invForNutr, monthlyData, macroData] = await Promise.all([
|
||||
_awLoadFacts(),
|
||||
api('inventory_list').then(d => d.inventory || []).catch(() => []),
|
||||
api('monthly_stats').catch(() => null),
|
||||
api('macro_stats').catch(() => null),
|
||||
]);
|
||||
_renderAntiWasteSection(
|
||||
statsData.used_30d || 0, statsData.wasted_30d || 0,
|
||||
@@ -4648,6 +4713,9 @@ async function loadDashboard() {
|
||||
// Monthly stats panel
|
||||
_renderMonthlyStatsSection(monthlyData);
|
||||
|
||||
// Macronutrient panel (#118)
|
||||
_renderMacrosSection(macroData);
|
||||
|
||||
_startInsightAlternation();
|
||||
|
||||
// Opened (partially used products with known package capacity)
|
||||
@@ -12829,10 +12897,12 @@ async function loadRecipeArchive() {
|
||||
const tags = (r.tags || []).slice(0, 3).join(', ');
|
||||
// Find this entry's index in the flat archive array
|
||||
const archiveIdx = archive.indexOf(entry);
|
||||
html += `<div class="recipe-archive-card" onclick="viewArchivedRecipe(${archiveIdx})">`;
|
||||
const favBadge = entry.is_favorite ? `<span class="recipe-fav-badge" title="${t('recipes.favorite')}">★</span>` : '';
|
||||
html += `<div class="recipe-archive-card${entry.is_favorite ? ' recipe-archive-card-fav' : ''}" onclick="viewArchivedRecipe(${archiveIdx})">`;
|
||||
html += `<div class="recipe-archive-card-header">`;
|
||||
html += `<span class="recipe-archive-meal">${mealIcon}</span>`;
|
||||
html += `<span class="recipe-archive-title">${escapeHtml(r.title)}</span>`;
|
||||
html += favBadge;
|
||||
html += `</div>`;
|
||||
html += `<div class="recipe-archive-card-meta">`;
|
||||
if (r.prep_time) html += `<span>🔪 ${r.prep_time}</span>`;
|
||||
@@ -12851,7 +12921,7 @@ async function loadRecipeArchive() {
|
||||
function viewArchivedRecipe(idx) {
|
||||
const entry = _recipeArchiveEntries[idx];
|
||||
if (!entry) return;
|
||||
_cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe };
|
||||
_cachedRecipe = { meal: _normalizeMealId(entry.meal), recipe: entry.recipe, id: entry.id, is_favorite: !!entry.is_favorite };
|
||||
renderRecipe(entry.recipe);
|
||||
document.getElementById('recipe-overlay').style.display = 'flex';
|
||||
document.getElementById('recipe-ask').style.display = 'none';
|
||||
@@ -13333,6 +13403,55 @@ function _extractToolsFromSteps(steps) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// ===== RECIPE FAVORITES & PORTION RESCALER =====
|
||||
let _recipeBasePersons = 1;
|
||||
let _recipeCurrentPersons = 1;
|
||||
|
||||
/**
|
||||
* Toggle favorite status for the currently displayed archived recipe (#124).
|
||||
*/
|
||||
async function toggleRecipeFavorite(btn) {
|
||||
if (!_cachedRecipe || !_cachedRecipe.id) return;
|
||||
const res = await api('recipes_toggle_favorite', {}, 'POST', { id: _cachedRecipe.id });
|
||||
if (!res.success) return;
|
||||
_cachedRecipe.is_favorite = res.is_favorite;
|
||||
btn.classList.toggle('active', res.is_favorite);
|
||||
btn.textContent = res.is_favorite ? '★' : '☆';
|
||||
btn.title = res.is_favorite ? t('recipes.unfavorite') : t('recipes.favorite');
|
||||
// Invalidate archive cache so the star shows on next open
|
||||
_recipeArchiveCache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale recipe ingredient quantities (#123).
|
||||
* Delta: +1 or -1. Min 1, max 20 persons.
|
||||
*/
|
||||
function adjustRecipePersons(delta) {
|
||||
const newPersons = Math.max(1, Math.min(20, _recipeCurrentPersons + delta));
|
||||
if (newPersons === _recipeCurrentPersons) return;
|
||||
_recipeCurrentPersons = newPersons;
|
||||
const display = document.getElementById('recipe-persons-display');
|
||||
if (display) display.textContent = `👥 ${newPersons} ${t('recipes.persons_short')}`;
|
||||
|
||||
const ratio = _recipeBasePersons > 0 ? (newPersons / _recipeBasePersons) : 1;
|
||||
document.querySelectorAll('#recipe-content .recipe-ingredient').forEach(li => {
|
||||
const baseQty = parseFloat(li.dataset.baseQty || '0');
|
||||
const baseStr = li.dataset.baseQtyStr || '';
|
||||
const qtySpan = li.querySelector('.recipe-ing-qty');
|
||||
if (!qtySpan) return;
|
||||
|
||||
if (baseQty > 0) {
|
||||
// Extract unit suffix from baseStr: e.g. "200 g" → "g", "2 uova" → "uova"
|
||||
const m = baseStr.match(/^(\d+(?:[.,]\d+)?)\s*(.*)/);
|
||||
const unitSuffix = m ? m[2].trim() : '';
|
||||
const scaled = baseQty * ratio;
|
||||
// Round sensibly: integers for whole counts, 1 decimal for fractional
|
||||
const rounded = scaled < 10 ? (Math.round(scaled * 10) / 10) : Math.round(scaled);
|
||||
qtySpan.textContent = unitSuffix ? `${rounded} ${unitSuffix}` : String(rounded);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderRecipe(r) {
|
||||
// Reset regen choice panel (hide choice, show button)
|
||||
const regenChoice = document.getElementById('recipe-regen-choice');
|
||||
@@ -13340,15 +13459,29 @@ function renderRecipe(r) {
|
||||
if (regenChoice) regenChoice.style.display = 'none';
|
||||
if (regenBtn) regenBtn.style.display = '';
|
||||
|
||||
// Store base persons for the rescaler (#123)
|
||||
_recipeBasePersons = r.persons || 1;
|
||||
_recipeCurrentPersons = _recipeBasePersons;
|
||||
|
||||
const isFav = !!(_cachedRecipe && _cachedRecipe.is_favorite);
|
||||
|
||||
let html = `<h2>${r.title}</h2>`;
|
||||
|
||||
// Meta tags
|
||||
// Meta tags + star (#124) + persons rescaler (#123)
|
||||
html += '<div class="recipe-meta">';
|
||||
if (r.meal) html += `<span class="recipe-tag">${_mealLabel(r.meal)}</span>`;
|
||||
html += `<span class="recipe-tag">👥 ${r.persons} ${t('recipes.persons_short')}</span>`;
|
||||
html += `<span class="recipe-tag recipe-persons-ctrl">
|
||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(-1)">−</button>
|
||||
<span id="recipe-persons-display">👥 ${r.persons} ${t('recipes.persons_short')}</span>
|
||||
<button class="btn-persons-adj" onclick="adjustRecipePersons(+1)">+</button>
|
||||
</span>`;
|
||||
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
|
||||
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
|
||||
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
|
||||
// Favorite star button (#124) — visible only for archived recipes (have an id)
|
||||
if (_cachedRecipe && _cachedRecipe.id) {
|
||||
html += `<button class="btn-recipe-fav${isFav ? ' active' : ''}" onclick="toggleRecipeFavorite(this)" title="${isFav ? t('recipes.unfavorite') : t('recipes.favorite')}">${isFav ? '★' : '☆'}</button>`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Expiry note
|
||||
@@ -13371,8 +13504,8 @@ function renderRecipe(r) {
|
||||
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
|
||||
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
|
||||
const alreadyUsed = ing.used === true;
|
||||
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}">`;
|
||||
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: ${ing.qty} ✅`;
|
||||
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${(ing.qty || '').replace(/"/g, '"')}">`;
|
||||
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${t('action.edit') || 'Modifica'}">${ing.name}</strong>${ing.brand ? ' <em>(' + ing.brand + ')</em>' : ''}: <span class="recipe-ing-qty">${ing.qty}</span> ✅`;
|
||||
// Detail line: location + expiry
|
||||
let details = [];
|
||||
const ingredientLocLabels = Object.fromEntries(Object.entries(LOCATIONS).map(([k,v]) => [k, `${v.icon} ${v.label}`]));
|
||||
@@ -13396,7 +13529,7 @@ function renderRecipe(r) {
|
||||
html += `</li>`;
|
||||
} else {
|
||||
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
|
||||
html += `<li class="recipe-ingredient"><span class="recipe-ing-text"><strong>${ing.name}</strong>: ${ing.qty}${pantryIcon}</span></li>`;
|
||||
html += `<li class="recipe-ingredient" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${(ing.qty || '').replace(/"/g, '"')}"><span class="recipe-ing-text"><strong>${ing.name}</strong>: <span class="recipe-ing-qty">${ing.qty}</span>${pantryIcon}</span></li>`;
|
||||
}
|
||||
});
|
||||
html += '</ul>';
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
<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 -->
|
||||
|
||||
+11
-2
@@ -384,7 +384,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",
|
||||
@@ -1247,7 +1250,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",
|
||||
|
||||
+11
-2
@@ -384,7 +384,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",
|
||||
@@ -1247,7 +1250,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",
|
||||
|
||||
+11
-2
@@ -379,7 +379,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",
|
||||
@@ -1195,7 +1198,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",
|
||||
|
||||
+11
-2
@@ -379,7 +379,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",
|
||||
@@ -1195,7 +1198,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",
|
||||
|
||||
+11
-2
@@ -384,7 +384,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",
|
||||
@@ -1258,7 +1261,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",
|
||||
|
||||
Reference in New Issue
Block a user