From a1b80862f5cce0fdc475fefb64100441f698fb7f Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 9 Apr 2026 20:47:31 +0200 Subject: [PATCH] feat: add "round off this day" nutrition suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suggest optimal 1-3 food combinations to fill remaining macro budget using weighted least-squares solver over a curated pantry (~55 foods) plus user favorites/recents. Recipes scored individually (no combining). Features: - Combinatorial solver (singles, pairs, triples) with macro-weighted scoring - MealTypePicker component (extracted from quick-log, shared) - Hero card with fit%, macro delta icons (Beef/Droplet/Wheat), ingredient cards, animated +/X toggle for logging - Responsive layout: sidebar on mobile, center column on desktop - MongoDB cache with ±5% tolerance, SSR on cache hit, TTL auto-expiry - Cache invalidation on food-log/favorites/custom-meals CRUD - Recipe per100g backfill admin endpoint --- package.json | 2 +- .../components/fitness/MealTypePicker.svelte | 63 ++ .../components/fitness/RoundOffCard.svelte | 686 ++++++++++++++++++ src/lib/server/pantryFoods.ts | 88 +++ src/lib/server/roundOffScoring.ts | 296 ++++++++ src/models/Recipe.ts | 4 + src/models/RoundOffCache.ts | 21 + .../admin/nutrition/+page.svelte | 57 ++ .../nutrition/cache-per100g/+server.ts | 97 +++ .../api/fitness/custom-meals/+server.ts | 2 + .../api/fitness/custom-meals/[id]/+server.ts | 3 + .../fitness/favorite-ingredients/+server.ts | 3 + src/routes/api/fitness/food-log/+server.ts | 4 + .../api/fitness/food-log/[id]/+server.ts | 5 + .../api/fitness/round-off-day/+server.ts | 246 +++++++ .../+page.server.ts | 20 + .../[nutrition=fitnessNutrition]/+page.svelte | 85 ++- 17 files changed, 1645 insertions(+), 37 deletions(-) create mode 100644 src/lib/components/fitness/MealTypePicker.svelte create mode 100644 src/lib/components/fitness/RoundOffCard.svelte create mode 100644 src/lib/server/pantryFoods.ts create mode 100644 src/lib/server/roundOffScoring.ts create mode 100644 src/models/RoundOffCache.ts create mode 100644 src/routes/api/[recipeLang=recipeLang]/nutrition/cache-per100g/+server.ts create mode 100644 src/routes/api/fitness/round-off-day/+server.ts diff --git a/package.json b/package.json index 9b44e8c..caa8063 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.20.0", + "version": "1.21.0", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/fitness/MealTypePicker.svelte b/src/lib/components/fitness/MealTypePicker.svelte new file mode 100644 index 0000000..3f6e7e6 --- /dev/null +++ b/src/lib/components/fitness/MealTypePicker.svelte @@ -0,0 +1,63 @@ + + +
+ {#each mealTypes as meal (meal)} + {@const meta = mealMeta[meal]} + {@const MealIcon = meta.icon} + + {/each} +
+ + diff --git a/src/lib/components/fitness/RoundOffCard.svelte b/src/lib/components/fitness/RoundOffCard.svelte new file mode 100644 index 0000000..dd12cef --- /dev/null +++ b/src/lib/components/fitness/RoundOffCard.svelte @@ -0,0 +1,686 @@ + + +{#if suggestions?.length || loading} +
+
+ +

{isEn ? 'Round off this day' : 'Tag abrunden'}

+ {Math.round(remainingKcal)} kcal {t('remaining', lang)} +
+ + {#if loading} +
+
+
+
+
+ {:else if suggestions?.length} + + {@const hero = suggestions[0]} + {@const heroEditing = editingComboIdx === 0} + {@const hd = hero.delta} +
+
+
+
+
+
+ {hero.fitPercent}% +
+
+
+ + {fmtSigned(hd.protein)} + + +
+
+ + {fmtSigned(hd.fat)} + + + + {fmtSigned(hd.carbs)} + + +
+
+
+
+ {#each hero.items as item, i (item.id)} + {#if i > 0}+{/if} + + + {#if hero.tierLabel === 'recipe'}🍳{/if} + {isEn && item.nameEn ? item.nameEn : item.name} + + {item.grams}g + + {/each} +
+
+ +
+ {#if heroEditing} +
+ editMealType = m} /> +
+ +
+
+ {/if} +
+ + + {#if displayItems && displayItems.length > 1} +
+ {#each displayItems.slice(1) as combo, idx (idx + 1)} + {@const isEditing = editingComboIdx === idx + 1} + {@const d = combo.delta} +
+
+
+ {combo.fitPercent}% + + {#each combo.items as item, i (item.id)} + {#if i > 0} + {/if} + + {item.grams}g + {#if combo.tierLabel === 'recipe' && i === 0}🍳{/if} + {isEn && item.nameEn ? item.nameEn : item.name} + + {/each} + + + {fmtSigned(d.protein)} + {fmtSigned(d.fat)} + {fmtSigned(d.carbs)} + +
+ +
+ {#if isEditing} +
+ editMealType = m} /> +
+ +
+
+ {/if} +
+ {/each} +
+ {/if} + + {#if suggestions.length > 5 && !expanded} + + {/if} + {:else} +

{isEn ? 'No matching suggestions found.' : 'Keine passenden Vorschläge gefunden.'}

+ {/if} +
+{/if} + + diff --git a/src/lib/server/pantryFoods.ts b/src/lib/server/pantryFoods.ts new file mode 100644 index 0000000..06c0680 --- /dev/null +++ b/src/lib/server/pantryFoods.ts @@ -0,0 +1,88 @@ +/** + * Curated list of common pantry/kitchen foods mapped to BLS codes. + * Each entry is resolved at runtime against BLS_DB for per100g data. + */ + +export interface PantryItem { + blsCode: string; + name: string; // short display name + nameEn: string; + group: string; +} + +export const PANTRY_FOODS: PantryItem[] = [ + // Grains & Pasta + { blsCode: 'C352000', name: 'Reis', nameEn: 'Rice', group: 'grains' }, + { blsCode: 'E401000', name: 'Pasta', nameEn: 'Pasta', group: 'grains' }, + { blsCode: 'E510000', name: 'Vollkornpasta', nameEn: 'Whole grain pasta', group: 'grains' }, + { blsCode: 'C133000', name: 'Haferflocken', nameEn: 'Oat flakes', group: 'grains' }, + { blsCode: 'C118000', name: 'Quinoa', nameEn: 'Quinoa', group: 'grains' }, + { blsCode: 'C119200', name: 'Couscous', nameEn: 'Couscous', group: 'grains' }, + { blsCode: 'C119100', name: 'Bulgur', nameEn: 'Bulgur', group: 'grains' }, + { blsCode: 'B311000', name: 'Weißbrot', nameEn: 'White bread', group: 'grains' }, + { blsCode: 'B121000', name: 'Vollkornbrot', nameEn: 'Whole grain bread', group: 'grains' }, + + // Meat + { blsCode: 'V416100', name: 'Hähnchenbrust', nameEn: 'Chicken breast', group: 'meat' }, + { blsCode: 'V413000', name: 'Hähnchenfleisch', nameEn: 'Chicken meat', group: 'meat' }, + { blsCode: 'V486100', name: 'Putenbrust', nameEn: 'Turkey breast', group: 'meat' }, + { blsCode: 'U201000', name: 'Rindfleisch', nameEn: 'Beef', group: 'meat' }, + { blsCode: 'U287100', name: 'Rind Oberschale', nameEn: 'Beef top round', group: 'meat' }, + { blsCode: 'U020100', name: 'Schweinehack', nameEn: 'Pork mince', group: 'meat' }, + { blsCode: 'U611100', name: 'Schweinefilet', nameEn: 'Pork tenderloin', group: 'meat' }, + + // Fish + { blsCode: 'T410100', name: 'Lachs', nameEn: 'Salmon', group: 'fish' }, + { blsCode: 'T121100', name: 'Thunfisch', nameEn: 'Tuna', group: 'fish' }, + { blsCode: 'T204100', name: 'Kabeljau', nameEn: 'Cod', group: 'fish' }, + { blsCode: 'T422100', name: 'Forelle', nameEn: 'Trout', group: 'fish' }, + { blsCode: 'T753100', name: 'Garnelen', nameEn: 'Shrimp', group: 'fish' }, + { blsCode: 'T107100', name: 'Makrele', nameEn: 'Mackerel', group: 'fish' }, + + // Dairy + { blsCode: 'M111300', name: 'Vollmilch', nameEn: 'Whole milk', group: 'dairy' }, + { blsCode: 'M141300', name: 'Joghurt', nameEn: 'Yogurt', group: 'dairy' }, + { blsCode: 'M713100', name: 'Magerquark', nameEn: 'Low-fat quark', group: 'dairy' }, + { blsCode: 'M304600', name: 'Emmentaler', nameEn: 'Emmental', group: 'dairy' }, + { blsCode: 'M402600', name: 'Gouda', nameEn: 'Gouda', group: 'dairy' }, + { blsCode: 'M012200', name: 'Feta', nameEn: 'Feta', group: 'dairy' }, + { blsCode: 'Q611000', name: 'Butter', nameEn: 'Butter', group: 'dairy' }, + { blsCode: 'M173900', name: 'Sahne', nameEn: 'Heavy cream', group: 'dairy' }, + + // Eggs + { blsCode: 'E111100', name: 'Ei', nameEn: 'Egg', group: 'eggs' }, + + // Legumes + { blsCode: 'H725100', name: 'Linsen', nameEn: 'Lentils', group: 'legumes' }, + { blsCode: 'H730000', name: 'Rote Linsen', nameEn: 'Red lentils', group: 'legumes' }, + { blsCode: 'G770400', name: 'Kichererbsen', nameEn: 'Chickpeas', group: 'legumes' }, + { blsCode: 'H742100', name: 'Kidneybohnen', nameEn: 'Kidney beans', group: 'legumes' }, + { blsCode: 'H861000', name: 'Tofu', nameEn: 'Tofu', group: 'legumes' }, + + // Vegetables + { blsCode: 'G312100', name: 'Brokkoli', nameEn: 'Broccoli', group: 'vegetables' }, + { blsCode: 'G561100', name: 'Tomate', nameEn: 'Tomato', group: 'vegetables' }, + { blsCode: 'G543100', name: 'Paprika rot', nameEn: 'Red bell pepper', group: 'vegetables' }, + { blsCode: 'G211100', name: 'Spinat', nameEn: 'Spinach', group: 'vegetables' }, + { blsCode: 'G582100', name: 'Zucchini', nameEn: 'Zucchini', group: 'vegetables' }, + { blsCode: 'G620100', name: 'Karotte', nameEn: 'Carrot', group: 'vegetables' }, + { blsCode: 'K110100', name: 'Kartoffel', nameEn: 'Potato', group: 'vegetables' }, + { blsCode: 'K420100', name: 'Süßkartoffel', nameEn: 'Sweet potato', group: 'vegetables' }, + { blsCode: 'F502100', name: 'Avocado', nameEn: 'Avocado', group: 'vegetables' }, + + // Fruits + { blsCode: 'F110100', name: 'Apfel', nameEn: 'Apple', group: 'fruits' }, + { blsCode: 'F503100', name: 'Banane', nameEn: 'Banana', group: 'fruits' }, + { blsCode: 'F301100', name: 'Erdbeere', nameEn: 'Strawberry', group: 'fruits' }, + { blsCode: 'F304100', name: 'Heidelbeere', nameEn: 'Blueberry', group: 'fruits' }, + { blsCode: 'F603100', name: 'Orange', nameEn: 'Orange', group: 'fruits' }, + + // Nuts & Seeds + { blsCode: 'H210100', name: 'Mandeln', nameEn: 'Almonds', group: 'nuts' }, + { blsCode: 'H120100', name: 'Walnüsse', nameEn: 'Walnuts', group: 'nuts' }, + { blsCode: 'H110600', name: 'Erdnüsse', nameEn: 'Peanuts', group: 'nuts' }, + { blsCode: 'H170100', name: 'Cashews', nameEn: 'Cashews', group: 'nuts' }, + + // Oils + { blsCode: 'Q120000', name: 'Olivenöl', nameEn: 'Olive oil', group: 'oils' }, +]; diff --git a/src/lib/server/roundOffScoring.ts b/src/lib/server/roundOffScoring.ts new file mode 100644 index 0000000..c8dfe70 --- /dev/null +++ b/src/lib/server/roundOffScoring.ts @@ -0,0 +1,296 @@ +/** + * Combinatorial solver for "Round Off This Day" suggestions. + * Finds optimal 1-3 food combinations from a curated pantry list + * that best fill the remaining macro budget. + */ + +export interface RemainingBudget { + kcal: number; + protein: number; + fat: number; + carbs: number; +} + +export interface ResolvedFood { + source: string; + id: string; + name: string; + nameEn: string; + per100g: { calories: number; protein: number; fat: number; carbs: number }; + group?: string; +} + +export interface ComboItem { + source: string; + id: string; + name: string; + nameEn: string; + grams: number; + atServing: { calories: number; protein: number; fat: number; carbs: number }; +} + +export interface ComboSuggestion { + items: ComboItem[]; + total: { calories: number; protein: number; fat: number; carbs: number }; + delta: { calories: number; protein: number; fat: number; carbs: number }; + score: number; // weighted L1 delta — lower is better + fitPercent: number; // 0-100, how well this fills the remaining budget + tierLabel: string; +} + +const MIN_GRAMS = 20; +const MAX_GRAMS = 500; + +// Macro weights for scoring: fat weighted 2x (9 kcal/g vs 4) +const W_KCAL = 1; +const W_P = 1; +const W_F = 2; +const W_C = 1; + +/** + * For a single food, find optimal grams to match remaining budget. + * Uses weighted least squares (scalar case). + */ +function solveOne(food: ResolvedFood, rem: RemainingBudget): number | null { + const p = food.per100g; + if (p.calories <= 5) return null; + + // Weighted least squares: min sum(w_i * (g/100 * p_i - rem_i)^2) + // Derivative = 0: g/100 = sum(w_i * p_i * rem_i) / sum(w_i * p_i^2) + const num = W_KCAL * p.calories * rem.kcal + + W_P * p.protein * rem.protein + + W_F * p.fat * rem.fat * W_F + + W_C * p.carbs * rem.carbs; + const den = W_KCAL * p.calories ** 2 + + W_P * p.protein ** 2 + + W_F ** 2 * p.fat ** 2 + + W_C * p.carbs ** 2; + + if (den <= 0) return null; + const hectograms = num / den; + const grams = hectograms * 100; + + if (grams < MIN_GRAMS || grams > MAX_GRAMS) return null; + return Math.round(grams); +} + +/** + * For 2 foods, solve 2x2 weighted least squares. + */ +function solveTwo(foods: [ResolvedFood, ResolvedFood], rem: RemainingBudget): [number, number] | null { + const [f1, f2] = foods; + const p1 = f1.per100g, p2 = f2.per100g; + + // Build normal equations: (A^T W^2 A) x = A^T W^2 b + // where A is 4x2, W is diagonal weights, b is remaining budget + const w = [W_KCAL, W_P, W_F * W_F, W_C]; // W^2 for fat + const a = [ + [p1.calories, p2.calories], + [p1.protein, p2.protein], + [p1.fat, p2.fat], + [p1.carbs, p2.carbs], + ]; + const b = [rem.kcal, rem.protein, rem.fat, rem.carbs]; + + // A^T W^2 A (2x2) + let m00 = 0, m01 = 0, m11 = 0; + let r0 = 0, r1 = 0; + for (let i = 0; i < 4; i++) { + const wi = w[i]; + m00 += wi * a[i][0] * a[i][0]; + m01 += wi * a[i][0] * a[i][1]; + m11 += wi * a[i][1] * a[i][1]; + r0 += wi * a[i][0] * b[i]; + r1 += wi * a[i][1] * b[i]; + } + + const det = m00 * m11 - m01 * m01; + if (Math.abs(det) < 1e-10) return null; + + const x0 = (m11 * r0 - m01 * r1) / det * 100; + const x1 = (m00 * r1 - m01 * r0) / det * 100; + + if (x0 < MIN_GRAMS || x0 > MAX_GRAMS || x1 < MIN_GRAMS || x1 > MAX_GRAMS) return null; + return [Math.round(x0), Math.round(x1)]; +} + +/** + * For 3 foods, solve 3x3 weighted least squares. + */ +function solveThree(foods: [ResolvedFood, ResolvedFood, ResolvedFood], rem: RemainingBudget): [number, number, number] | null { + const [f1, f2, f3] = foods; + const p = [f1.per100g, f2.per100g, f3.per100g]; + + const w = [W_KCAL, W_P, W_F * W_F, W_C]; + const a = [ + [p[0].calories, p[1].calories, p[2].calories], + [p[0].protein, p[1].protein, p[2].protein], + [p[0].fat, p[1].fat, p[2].fat], + [p[0].carbs, p[1].carbs, p[2].carbs], + ]; + const b = [rem.kcal, rem.protein, rem.fat, rem.carbs]; + + // Build 3x3 normal equations: M x = r + const M = Array.from({ length: 3 }, () => new Float64Array(3)); + const r = new Float64Array(3); + for (let i = 0; i < 4; i++) { + const wi = w[i]; + for (let j = 0; j < 3; j++) { + for (let k = 0; k < 3; k++) { + M[j][k] += wi * a[i][j] * a[i][k]; + } + r[j] += wi * a[i][j] * b[i]; + } + } + + // Solve by Cramer's rule (3x3) + const det3 = (m: Float64Array[] | number[][]) => + m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) + - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) + + m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]); + + const D = det3(M); + if (Math.abs(D) < 1e-10) return null; + + const results: number[] = []; + for (let col = 0; col < 3; col++) { + const Mc = M.map(row => Float64Array.from(row)); + for (let row = 0; row < 3; row++) Mc[row][col] = r[row]; + results.push(det3(Mc) / D * 100); + } + + for (const g of results) { + if (g < MIN_GRAMS || g > MAX_GRAMS) return null; + } + return [Math.round(results[0]), Math.round(results[1]), Math.round(results[2])]; +} + +function macrosAtGrams(food: ResolvedFood, grams: number) { + const f = grams / 100; + return { + calories: food.per100g.calories * f, + protein: food.per100g.protein * f, + fat: food.per100g.fat * f, + carbs: food.per100g.carbs * f, + }; +} + +function sumMacros(...servings: { calories: number; protein: number; fat: number; carbs: number }[]) { + const r = { calories: 0, protein: 0, fat: 0, carbs: 0 }; + for (const s of servings) { + r.calories += s.calories; + r.protein += s.protein; + r.fat += s.fat; + r.carbs += s.carbs; + } + return r; +} + +function scoreDelta(total: { calories: number; protein: number; fat: number; carbs: number }, rem: RemainingBudget): number { + return Math.abs(total.calories - rem.kcal) * W_KCAL + + Math.abs(total.protein - rem.protein) * W_P + + Math.abs(total.fat - rem.fat) * W_F + + Math.abs(total.carbs - rem.carbs) * W_C; +} + +function makeComboItem(food: ResolvedFood, grams: number): ComboItem { + return { + source: food.source, + id: food.id, + name: food.name, + nameEn: food.nameEn, + grams, + atServing: macrosAtGrams(food, grams), + }; +} + +// fitPercent is computed relative to the worst result in each batch — see findBestCombos + +function makeCombo(items: ComboItem[], rem: RemainingBudget, tierLabel: string): ComboSuggestion { + const total = sumMacros(...items.map(i => i.atServing)); + const delta = { + calories: rem.kcal - total.calories, + protein: rem.protein - total.protein, + fat: rem.fat - total.fat, + carbs: rem.carbs - total.carbs, + }; + return { + items, + total, + delta, + score: scoreDelta(total, rem), + fitPercent: 0, // filled in by findBestCombos + tierLabel, + }; +} + +/** + * Search all combinations of foods (up to maxComboSize) for best macro fit. + * @param maxComboSize - max foods per combo (1 = singles only, 3 = up to triples) + */ +export function findBestCombos( + foods: ResolvedFood[], + remaining: RemainingBudget, + tierLabel: string, + limit: number = 20, + maxComboSize: number = 3, +): ComboSuggestion[] { + const results: ComboSuggestion[] = []; + const n = foods.length; + + // Singles + for (let i = 0; i < n; i++) { + const g = solveOne(foods[i], remaining); + if (g === null) continue; + results.push(makeCombo([makeComboItem(foods[i], g)], remaining, tierLabel)); + } + + // Pairs + if (maxComboSize >= 2) { + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const sol = solveTwo([foods[i], foods[j]], remaining); + if (!sol) continue; + results.push(makeCombo([ + makeComboItem(foods[i], sol[0]), + makeComboItem(foods[j], sol[1]), + ], remaining, tierLabel)); + } + } + } + + // Triples + if (maxComboSize >= 3) { + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + for (let k = j + 1; k < n; k++) { + const sol = solveThree([foods[i], foods[j], foods[k]], remaining); + if (!sol) continue; + results.push(makeCombo([ + makeComboItem(foods[i], sol[0]), + makeComboItem(foods[j], sol[1]), + makeComboItem(foods[k], sol[2]), + ], remaining, tierLabel)); + } + } + } + } + + // Sort by score (lower = better fit) + results.sort((a, b) => a.score - b.score); + const top = results.slice(0, limit); + + // Compute fitPercent: best = 100%, worst in returned set = 0% + if (top.length > 0) { + const bestScore = top[0].score; + const worstScore = top[top.length - 1].score; + const range = worstScore - bestScore; + for (const r of top) { + r.fitPercent = range > 0 + ? Math.round((1 - (r.score - bestScore) / range) * 100) + : 100; + } + } + + return top; +} diff --git a/src/models/Recipe.ts b/src/models/Recipe.ts index 8d1a0ba..74a42bf 100644 --- a/src/models/Recipe.ts +++ b/src/models/Recipe.ts @@ -184,6 +184,10 @@ const RecipeSchema = new mongoose.Schema( recipeRefMultiplier: { type: Number, default: 1 }, }], + // Cached nutrition per 100g (for round-off suggestions & listing) + cachedPer100g: { type: mongoose.Schema.Types.Mixed }, + cachedTotalGrams: { type: Number }, + // Translation metadata for tracking changes translationMetadata: { lastModifiedGerman: {type: Date}, diff --git a/src/models/RoundOffCache.ts b/src/models/RoundOffCache.ts new file mode 100644 index 0000000..3bd957f --- /dev/null +++ b/src/models/RoundOffCache.ts @@ -0,0 +1,21 @@ +import mongoose from 'mongoose'; + +const RoundOffCacheSchema = new mongoose.Schema({ + createdBy: { type: String, required: true }, + date: { type: String, required: true }, + remainingKcal: { type: Number, required: true }, + remainingProtein: { type: Number, required: true }, + remainingFat: { type: Number, required: true }, + remainingCarbs: { type: Number, required: true }, + suggestions: { type: mongoose.Schema.Types.Mixed, default: [] }, + foodPoolCount: { type: Number, default: 0 }, + recipeCount: { type: Number, default: 0 }, + computedAt: { type: Date, default: Date.now }, +}); + +RoundOffCacheSchema.index({ createdBy: 1, date: 1 }, { unique: true }); +RoundOffCacheSchema.index({ computedAt: 1 }, { expireAfterSeconds: 86400 }); + +let _model: mongoose.Model; +try { _model = mongoose.model('RoundOffCache'); } catch { _model = mongoose.model('RoundOffCache', RoundOffCacheSchema); } +export const RoundOffCache = _model; diff --git a/src/routes/[recipeLang=recipeLang]/admin/nutrition/+page.svelte b/src/routes/[recipeLang=recipeLang]/admin/nutrition/+page.svelte index 71c3824..56e02d2 100644 --- a/src/routes/[recipeLang=recipeLang]/admin/nutrition/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/admin/nutrition/+page.svelte @@ -14,6 +14,11 @@ let errorMsg = $state(''); let recipeName = $state(''); + // Cache per100g + let cacheProcessing = $state(false); + /** @type {any} */ + let cacheResult = $state(null); + async function generateAll() { processing = true; errorMsg = ''; @@ -304,6 +309,58 @@ {/if} + +
+

{isEnglish ? 'Cache Per-100g Nutrition' : 'Per-100g-Nährwerte cachen'}

+

{isEnglish + ? 'Recompute and cache per-100g nutrition on all recipes with nutrition mappings. Required for "Round off this day" suggestions.' + : 'Per-100g-Nährwerte für alle Rezepte mit Nährwertzuordnungen neu berechnen und cachen. Notwendig für "Tag abrunden"-Vorschläge.'} +

+ + + {#if cacheResult} +
+
+
{cacheResult.updated}
+
{isEnglish ? 'Cached' : 'Gecacht'}
+
+
+
{cacheResult.skipped}
+
{isEnglish ? 'Skipped' : 'Ăśbersprungen'}
+
+
+ +
+ + + + {#each cacheResult.details as d} + + + + + + {/each} + +
{isEnglish ? 'Recipe' : 'Rezept'}kcal/100g{isEnglish ? 'Total (g)' : 'Gesamt (g)'}
{d.name}{d.calories}{d.totalGrams}
+
+ {/if} +
+ {#if errorMsg}

{errorMsg}

{/if} diff --git a/src/routes/api/[recipeLang=recipeLang]/nutrition/cache-per100g/+server.ts b/src/routes/api/[recipeLang=recipeLang]/nutrition/cache-per100g/+server.ts new file mode 100644 index 0000000..ca6d580 --- /dev/null +++ b/src/routes/api/[recipeLang=recipeLang]/nutrition/cache-per100g/+server.ts @@ -0,0 +1,97 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { Recipe } from '$models/Recipe'; +import { dbConnect } from '$utils/db'; +import { requireGroup } from '$lib/server/middleware/auth'; +import { getBlsEntryByCode, getNutritionEntryByFdcId } from '$lib/server/nutritionMatcher'; + +const KEYS = [ + 'calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars', + 'calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc', + 'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', + 'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate', 'cholesterol', +]; + +function parseAmount(amount: string | undefined): number { + if (!amount?.trim()) return 0; + const s = amount.trim().replace(',', '.'); + const rangeMatch = s.match(/^(\d+(?:\.\d+)?)\s*[-–]\s*(\d+(?:\.\d+)?)$/); + if (rangeMatch) return (parseFloat(rangeMatch[1]) + parseFloat(rangeMatch[2])) / 2; + const fractionMatch = s.match(/^(\d+)\s*\/\s*(\d+)$/); + if (fractionMatch) return parseInt(fractionMatch[1]) / parseInt(fractionMatch[2]); + const parsed = parseFloat(s); + return isNaN(parsed) ? 0 : parsed; +} + +function computePer100g(recipe: any): { per100g: Record; totalGrams: number } | null { + const mappings = recipe.nutritionMappings; + if (!mappings?.length) return null; + + const totals: Record = {}; + for (const k of KEYS) totals[k] = 0; + let totalGrams = 0; + + for (const m of mappings) { + if (m.matchMethod === 'none' || m.excluded || !m.gramsPerUnit) continue; + + let per100g = m.per100g; + if (!per100g) { + if (m.source === 'bls' && m.blsCode) { + per100g = getBlsEntryByCode(m.blsCode)?.per100g; + } else if (m.fdcId) { + per100g = getNutritionEntryByFdcId(m.fdcId)?.per100g; + } + } + if (!per100g) continue; + + const section = recipe.ingredients?.[m.sectionIndex]; + const items = section?.list ?? section?.ingredients ?? section?.items ?? []; + const ing = items[m.ingredientIndex]; + const parsedAmount = (ing ? parseAmount(ing.amount) : 0) || (m.defaultAmountUsed ? 1 : 0); + + const grams = parsedAmount * m.gramsPerUnit; + totalGrams += grams; + const factor = grams / 100; + for (const k of KEYS) totals[k] += factor * ((per100g as any)[k] ?? 0); + } + + if (totalGrams <= 0) return null; + + const per100g: Record = {}; + for (const k of KEYS) per100g[k] = totals[k] / totalGrams * 100; + return { per100g, totalGrams }; +} + +export const POST: RequestHandler = async ({ locals }) => { + await requireGroup(locals, 'rezepte_users'); + await dbConnect(); + + const recipes = await Recipe.find({}).select('name short_name ingredients nutritionMappings').lean(); + const results: { name: string; shortName: string; calories: number; totalGrams: number }[] = []; + let skipped = 0; + + for (const recipe of recipes) { + const result = computePer100g(recipe); + if (!result) { + skipped++; + continue; + } + + await Recipe.updateOne( + { _id: recipe._id }, + { $set: { cachedPer100g: result.per100g, cachedTotalGrams: result.totalGrams } } + ); + results.push({ + name: (recipe as any).name, + shortName: (recipe as any).short_name, + calories: Math.round(result.per100g.calories), + totalGrams: Math.round(result.totalGrams), + }); + } + + return json({ + updated: results.length, + skipped, + total: recipes.length, + details: results, + }); +}; diff --git a/src/routes/api/fitness/custom-meals/+server.ts b/src/routes/api/fitness/custom-meals/+server.ts index ae428e4..113f1f4 100644 --- a/src/routes/api/fitness/custom-meals/+server.ts +++ b/src/routes/api/fitness/custom-meals/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { CustomMeal } from '$models/CustomMeal'; +import { RoundOffCache } from '$models/RoundOffCache'; export const GET: RequestHandler = async ({ locals }) => { const user = await requireAuth(locals); @@ -28,5 +29,6 @@ export const POST: RequestHandler = async ({ request, locals }) => { createdBy: user.nickname, }); + RoundOffCache.deleteMany({ createdBy: user.nickname }).catch(() => {}); return json(meal.toObject(), { status: 201 }); }; diff --git a/src/routes/api/fitness/custom-meals/[id]/+server.ts b/src/routes/api/fitness/custom-meals/[id]/+server.ts index f1b1c09..30bc8e4 100644 --- a/src/routes/api/fitness/custom-meals/[id]/+server.ts +++ b/src/routes/api/fitness/custom-meals/[id]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { CustomMeal } from '$models/CustomMeal'; +import { RoundOffCache } from '$models/RoundOffCache'; export const PUT: RequestHandler = async ({ params, request, locals }) => { const user = await requireAuth(locals); @@ -23,6 +24,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { } await meal.save(); + RoundOffCache.deleteMany({ createdBy: user.nickname }).catch(() => {}); return json(meal.toObject()); }; @@ -35,5 +37,6 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { createdBy: user.nickname, }); if (!deleted) throw error(404, 'Meal not found'); + RoundOffCache.deleteMany({ createdBy: user.nickname }).catch(() => {}); return json({ ok: true }); }; diff --git a/src/routes/api/fitness/favorite-ingredients/+server.ts b/src/routes/api/fitness/favorite-ingredients/+server.ts index c6b3aa3..dbb6a82 100644 --- a/src/routes/api/fitness/favorite-ingredients/+server.ts +++ b/src/routes/api/fitness/favorite-ingredients/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { FavoriteIngredient } from '$models/FavoriteIngredient'; +import { RoundOffCache } from '$models/RoundOffCache'; export const GET: RequestHandler = async ({ locals }) => { const user = await requireAuth(locals); @@ -31,6 +32,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { { upsert: true, returnDocument: 'after' } ); + RoundOffCache.deleteMany({ createdBy: user.nickname }).catch(() => {}); return json({ ok: true }, { status: 201 }); }; @@ -50,5 +52,6 @@ export const DELETE: RequestHandler = async ({ request, locals }) => { sourceId: String(sourceId), }); + RoundOffCache.deleteMany({ createdBy: user.nickname }).catch(() => {}); return json({ ok: true }); }; diff --git a/src/routes/api/fitness/food-log/+server.ts b/src/routes/api/fitness/food-log/+server.ts index 21ec8e7..c802e4f 100644 --- a/src/routes/api/fitness/food-log/+server.ts +++ b/src/routes/api/fitness/food-log/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { FoodLogEntry } from '$models/FoodLogEntry'; +import { RoundOffCache } from '$models/RoundOffCache'; const VALID_MEALS = ['breakfast', 'lunch', 'dinner', 'snack', 'water']; @@ -59,5 +60,8 @@ export const POST: RequestHandler = async ({ request, locals }) => { createdBy: user.nickname, }); + // Invalidate round-off cache for this date + RoundOffCache.deleteOne({ createdBy: user.nickname, date: date.slice(0, 10) }).catch(() => {}); + return json(entry.toObject(), { status: 201 }); }; diff --git a/src/routes/api/fitness/food-log/[id]/+server.ts b/src/routes/api/fitness/food-log/[id]/+server.ts index d5704c6..5570228 100644 --- a/src/routes/api/fitness/food-log/[id]/+server.ts +++ b/src/routes/api/fitness/food-log/[id]/+server.ts @@ -3,6 +3,7 @@ import type { RequestHandler } from './$types'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { FoodLogEntry } from '$models/FoodLogEntry'; +import { RoundOffCache } from '$models/RoundOffCache'; export const PUT: RequestHandler = async ({ params, request, locals }) => { const user = await requireAuth(locals); @@ -19,6 +20,8 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => { } await entry.save(); + const dateStr = entry.date instanceof Date ? entry.date.toISOString().slice(0, 10) : ''; + if (dateStr) RoundOffCache.deleteOne({ createdBy: user.nickname, date: dateStr }).catch(() => {}); return json(entry.toObject()); }; @@ -31,5 +34,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => { createdBy: user.nickname, }); if (!deleted) throw error(404, 'Entry not found'); + const dateStr = deleted.date instanceof Date ? deleted.date.toISOString().slice(0, 10) : ''; + if (dateStr) RoundOffCache.deleteOne({ createdBy: user.nickname, date: dateStr }).catch(() => {}); return json({ ok: true }); }; diff --git a/src/routes/api/fitness/round-off-day/+server.ts b/src/routes/api/fitness/round-off-day/+server.ts new file mode 100644 index 0000000..e8e5126 --- /dev/null +++ b/src/routes/api/fitness/round-off-day/+server.ts @@ -0,0 +1,246 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { dbConnect } from '$utils/db'; +import { BLS_DB } from '$lib/data/blsDb'; +import { getBlsEntryByCode, getNutritionEntryByFdcId } from '$lib/server/nutritionMatcher'; +import { Recipe } from '$models/Recipe'; +import { FavoriteIngredient } from '$models/FavoriteIngredient'; +import { FoodLogEntry } from '$models/FoodLogEntry'; +import { OpenFoodFact } from '$models/OpenFoodFact'; +import { RoundOffCache } from '$models/RoundOffCache'; +import { PANTRY_FOODS } from '$lib/server/pantryFoods'; +import { + findBestCombos, + type RemainingBudget, + type ResolvedFood, + type ComboSuggestion, +} from '$lib/server/roundOffScoring'; + +// Build a lookup map once on module load +const blsByCode = new Map(); +for (const entry of BLS_DB) { + blsByCode.set(entry.blsCode, entry); +} + +// Resolve pantry foods to per100g data (cached on module load) +const resolvedPantry: ResolvedFood[] = []; +for (const item of PANTRY_FOODS) { + const entry = blsByCode.get(item.blsCode); + if (!entry) continue; + const p = entry.per100g as any; + resolvedPantry.push({ + source: 'bls', + id: item.blsCode, + name: item.name, + nameEn: item.nameEn, + per100g: { + calories: p.calories ?? 0, + protein: p.protein ?? 0, + fat: p.fat ?? 0, + carbs: p.carbs ?? 0, + }, + group: item.group, + }); +} + +/** + * Resolve favorites + recents to ResolvedFood entries. + */ +async function resolveFavoritesAndRecents(nickname: string): Promise { + const foods: ResolvedFood[] = []; + const seen = new Set(); + + // Favorites + const favDocs = await FavoriteIngredient.find({ createdBy: nickname }).lean(); + for (const fav of favDocs) { + const key = `${fav.source}:${fav.sourceId}`; + if (seen.has(key)) continue; + seen.add(key); + + let per100g: Record | null = null; + if (fav.source === 'bls') { + const entry = getBlsEntryByCode(fav.sourceId); + if (entry) per100g = entry.per100g as unknown as Record; + } else if (fav.source === 'usda') { + const entry = getNutritionEntryByFdcId(Number(fav.sourceId)); + if (entry) per100g = entry.per100g as unknown as Record; + } else if (fav.source === 'off') { + const entry = await OpenFoodFact.findOne({ barcode: fav.sourceId }).lean(); + if (entry) per100g = entry.per100g as unknown as Record; + } + if (per100g && (per100g.calories ?? 0) > 5) { + foods.push({ + source: fav.source, + id: fav.sourceId, + name: fav.name, + nameEn: fav.name, + per100g: { + calories: per100g.calories ?? 0, + protein: per100g.protein ?? 0, + fat: per100g.fat ?? 0, + carbs: per100g.carbs ?? 0, + }, + }); + } + } + + // Recents (last 3 days) + const recentFrom = new Date(); + recentFrom.setDate(recentFrom.getDate() - 3); + const recentEntries = await FoodLogEntry.find({ + createdBy: nickname, + date: { $gte: recentFrom }, + mealType: { $ne: 'water' }, + source: { $exists: true }, + sourceId: { $exists: true, $ne: '' }, + per100g: { $exists: true }, + }).sort({ date: -1 }).lean(); + + for (const entry of recentEntries as any[]) { + const key = `${entry.source}:${entry.sourceId}`; + if (seen.has(key)) continue; + seen.add(key); + if (!entry.per100g || (entry.per100g.calories ?? 0) <= 5) continue; + foods.push({ + source: entry.source, + id: entry.sourceId, + name: entry.name, + nameEn: entry.name, + per100g: { + calories: entry.per100g.calories ?? 0, + protein: entry.per100g.protein ?? 0, + fat: entry.per100g.fat ?? 0, + carbs: entry.per100g.carbs ?? 0, + }, + }); + } + + return foods; +} + +/** Check if cached remaining values are within ±5% of requested */ +function cacheMatchesParams( + cached: { remainingKcal: number; remainingProtein: number; remainingFat: number; remainingCarbs: number }, + req: RemainingBudget, +): boolean { + const close = (a: number, b: number) => { + if (b === 0) return Math.abs(a) < 5; + return Math.abs(a - b) / Math.max(Math.abs(b), 1) <= 0.05; + }; + return close(cached.remainingKcal, req.kcal) + && close(cached.remainingProtein, req.protein) + && close(cached.remainingFat, req.fat) + && close(cached.remainingCarbs, req.carbs); +} + +export const GET: RequestHandler = async ({ url, locals }) => { + const user = await requireAuth(locals); + + const remainingKcal = Number(url.searchParams.get('remainingKcal')); + const remainingProtein = Number(url.searchParams.get('remainingProtein')); + const remainingFat = Number(url.searchParams.get('remainingFat')); + const remainingCarbs = Number(url.searchParams.get('remainingCarbs')); + const limit = Math.min(Number(url.searchParams.get('limit')) || 12, 30); + + if (isNaN(remainingKcal) || remainingKcal <= 0) { + throw error(400, 'remainingKcal must be a positive number'); + } + + const remaining: RemainingBudget = { + kcal: remainingKcal, + protein: remainingProtein || 0, + fat: remainingFat || 0, + carbs: remainingCarbs || 0, + }; + + await dbConnect(); + + const today = new Date().toISOString().slice(0, 10); + + // Check cache (validate shape: new schema has items array) + const cached = await RoundOffCache.findOne({ createdBy: user.nickname, date: today }).lean(); + if (cached && cached.suggestions?.[0]?.items && cacheMatchesParams(cached, remaining)) { + return json({ + suggestions: cached.suggestions.slice(0, limit), + foodPoolCount: cached.foodPoolCount, + recipeCount: cached.recipeCount, + }); + } + + // 1. Resolve user's favorites + recents + const userFoods = await resolveFavoritesAndRecents(user.nickname); + + // 2. Combine pantry + user foods (deduplicate by source:id) + const allFoodsSeen = new Set(); + const allFoods: ResolvedFood[] = []; + // User foods first (so they take priority in dedup) + for (const f of userFoods) { + const key = `${f.source}:${f.id}`; + if (allFoodsSeen.has(key)) continue; + allFoodsSeen.add(key); + allFoods.push(f); + } + for (const f of resolvedPantry) { + const key = `${f.source}:${f.id}`; + if (allFoodsSeen.has(key)) continue; + allFoodsSeen.add(key); + allFoods.push(f); + } + + // 3. Find best combos (1-3 foods) + const foodCombos = findBestCombos(allFoods, remaining, 'pantry', limit * 2); + + // 4. Find best recipes (single items only, no combos) + const recipes = await Recipe.find( + { cachedPer100g: { $exists: true, $ne: null } }, + { name: 1, short_name: 1, cachedPer100g: 1, cachedTotalGrams: 1, portions: 1 } + ).lean(); + + const resolvedRecipes: ResolvedFood[] = []; + for (const r of recipes as any[]) { + const p = r.cachedPer100g; + if (!p || !p.calories) continue; + resolvedRecipes.push({ + source: 'recipe', + id: r.short_name || String(r._id), + name: r.name, + nameEn: r.name, + per100g: { + calories: p.calories ?? 0, + protein: p.protein ?? 0, + fat: p.fat ?? 0, + carbs: p.carbs ?? 0, + }, + }); + } + + const recipeCombos = findBestCombos(resolvedRecipes, remaining, 'recipe', limit, 1); + + // 5. Merge and sort by score (lower = better) + const all: ComboSuggestion[] = [...foodCombos, ...recipeCombos]; + all.sort((a, b) => a.score - b.score); + const suggestions = all.slice(0, limit); + + // 6. Store in cache + await RoundOffCache.findOneAndUpdate( + { createdBy: user.nickname, date: today }, + { + remainingKcal, + remainingProtein: remainingProtein || 0, + remainingFat: remainingFat || 0, + remainingCarbs: remainingCarbs || 0, + suggestions, + foodPoolCount: allFoods.length, + recipeCount: resolvedRecipes.length, + computedAt: new Date(), + }, + { upsert: true }, + ); + + return json({ + suggestions, + foodPoolCount: allFoods.length, + recipeCount: resolvedRecipes.length, + }); +}; diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.server.ts b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.server.ts index d38c2ad..7f1d80c 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.server.ts +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.server.ts @@ -3,6 +3,7 @@ import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { WorkoutSession } from '$models/WorkoutSession'; import { Recipe } from '$models/Recipe'; +import { RoundOffCache } from '$models/RoundOffCache'; import mongoose from 'mongoose'; export const load: PageServerLoad = async ({ fetch, url, locals }) => { @@ -67,6 +68,24 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => { } catch {} } + // Try to load cached round-off suggestions for SSR (no loading flash) + let roundOffSuggestions = null; + try { + const today = new Date().toISOString().slice(0, 10); + if (dateParam === today) { + const user = await requireAuth(locals); + await dbConnect(); + const cached = await RoundOffCache.findOne({ createdBy: user.nickname, date: today }).lean(); + if (cached?.suggestions?.length && cached.suggestions[0]?.items) { + roundOffSuggestions = { + suggestions: cached.suggestions, + foodPoolCount: cached.foodPoolCount, + recipeCount: cached.recipeCount, + }; + } + } + } catch {} + const favData = favRes.ok ? await favRes.json() : { favorites: [] }; const recentData = recentRes.ok ? await recentRes.json() : { entries: [] }; @@ -92,5 +111,6 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => { recipeImages, favorites: favData.favorites ?? [], recentFoods, + roundOffSuggestions, }; }; diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte index 2ab5102..e677a4e 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/+page.svelte @@ -6,6 +6,8 @@ import AddButton from '$lib/components/AddButton.svelte'; import FoodSearch from '$lib/components/fitness/FoodSearch.svelte'; import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte'; + import MealTypePicker from '$lib/components/fitness/MealTypePicker.svelte'; + import RoundOffCard from '$lib/components/fitness/RoundOffCard.svelte'; import { toast } from '$lib/js/toast.svelte'; import { confirm } from '$lib/js/confirmDialog.svelte'; import { getDRI, NUTRIENT_META } from '$lib/data/dailyReferenceIntake'; @@ -470,6 +472,14 @@ const calorieProgress = $derived(Math.min(calorieProgressRaw, 100)); const calorieOverflow = $derived(Math.max(calorieProgressRaw - 100, 0)); + // Round-off suggestions + const remainingProtein = $derived(proteinGoalGrams ? proteinGoalGrams - dayTotals.protein : 0); + const remainingFat = $derived(fatGoalGrams ? fatGoalGrams - dayTotals.fat : 0); + const remainingCarbs = $derived(carbGoalGrams ? carbGoalGrams - dayTotals.carbs : 0); + const showRoundOff = $derived( + isToday && goalCalories && calorieBalance > 50 && calorieBalance <= goalCalories * 0.5 + ); + // DRI for micros const dri = $derived(getDRI(goalSex)); @@ -1069,6 +1079,22 @@ {/if} {/snippet} +{#snippet roundOffSnippet()} + { + invalidateAll(); + }} + /> +{/snippet} + {#snippet microPanel()}
{#each microSections as section} @@ -1438,6 +1464,13 @@
{/if} + + {#if showRoundOff} +
+ {@render roundOffSnippet()} +
+ {/if} +
@@ -1537,6 +1570,12 @@
+ + {#if showRoundOff} +
+ {@render roundOffSnippet()} +
+ {/if} {#each mealTypes as meal, mi} {@const mealEntries = grouped[meal]} {@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)} @@ -1643,19 +1682,7 @@

{isEn ? 'Quick Log' : 'Schnell eintragen'}

- {#each mealTypes as meal} - {@const meta = mealMeta[meal]} - {@const MealIcon = meta.icon} - - {/each} + quickLogMealType = m} />
{#if quickFavorites.length > 0} @@ -1778,7 +1805,16 @@ .quick-log-col { display: none; } + .round-off-desktop { + display: none; + } @media (min-width: 1024px) { + .round-off-mobile { + display: none; + } + .round-off-desktop { + display: block; + } .nutrition-page { max-width: none; display: grid; @@ -3485,31 +3521,8 @@ color: var(--color-text-primary); } .quick-log-meal-select { - display: flex; - gap: 0.25rem; margin-bottom: 0.75rem; } - .ql-meal-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - padding: 0.35rem; - border-radius: 8px; - border: 1px solid var(--color-border); - background: var(--color-bg-tertiary); - color: var(--color-text-secondary); - cursor: pointer; - transition: all 0.15s; - } - .ql-meal-btn.active { - background: color-mix(in srgb, var(--mc) 15%, transparent); - border-color: var(--mc); - color: var(--mc); - } - .ql-meal-btn:hover:not(.active) { - border-color: var(--color-text-tertiary); - } .ql-section { margin-bottom: 0.5rem; }