feat: add "round off this day" nutrition suggestions

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
This commit is contained in:
2026-04-09 20:47:31 +02:00
parent 385e21b109
commit a1b80862f5
17 changed files with 1645 additions and 37 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.20.0",
"version": "1.21.0",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,63 @@
<script>
import { Coffee, Sun, Moon, Cookie } from '@lucide/svelte';
import { t } from '$lib/js/fitnessI18n';
let {
value = 'snack',
lang = 'de',
onchange = () => {},
} = $props();
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
const mealMeta = {
breakfast: { icon: Coffee, color: 'var(--nord13)' },
lunch: { icon: Sun, color: 'var(--nord12)' },
dinner: { icon: Moon, color: 'var(--nord15)' },
snack: { icon: Cookie, color: 'var(--nord14)' },
};
</script>
<div class="meal-type-picker">
{#each mealTypes as meal (meal)}
{@const meta = mealMeta[meal]}
{@const MealIcon = meta.icon}
<button
class="meal-btn"
class:active={value === meal}
style="--mc: {meta.color}"
onclick={() => { onchange(meal); }}
title={t(meal, lang)}
>
<MealIcon size={14} />
</button>
{/each}
</div>
<style>
.meal-type-picker {
display: flex;
gap: 0.25rem;
}
.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;
}
.meal-btn.active {
background: color-mix(in srgb, var(--mc) 15%, transparent);
border-color: var(--mc);
color: var(--mc);
}
.meal-btn:hover:not(.active) {
border-color: var(--color-text-tertiary);
}
</style>
@@ -0,0 +1,686 @@
<script>
import { Plus, ChevronDown, Sparkles, Beef, Droplet, Wheat } from '@lucide/svelte';
import { untrack } from 'svelte';
import { toast } from '$lib/js/toast.svelte';
import { t } from '$lib/js/fitnessI18n';
import MealTypePicker from '$lib/components/fitness/MealTypePicker.svelte';
let {
remainingKcal,
remainingProtein,
remainingFat,
remainingCarbs,
currentDate,
lang = 'de',
nutritionSlug = 'ernaehrung',
initialSuggestions = null,
onlogged = () => {},
} = $props();
const isEn = $derived(lang === 'en');
// svelte-ignore state_referenced_locally
let suggestions = $state(initialSuggestions);
// svelte-ignore state_referenced_locally
let loading = $state(!initialSuggestions);
let expanded = $state(false);
let loggingIdx = $state(-1);
function defaultMealType() {
const h = new Date().getHours();
if (h >= 5 && h < 10) return 'breakfast';
if (h >= 10 && h < 15) return 'lunch';
if (h >= 15 && h < 17) return 'snack';
return 'dinner';
}
let editingComboIdx = $state(-1);
let editMealType = $state('snack');
async function fetchSuggestions() {
loading = true;
try {
const params = new URLSearchParams({
remainingKcal: String(Math.round(remainingKcal)),
remainingProtein: String(Math.round(remainingProtein)),
remainingFat: String(Math.round(remainingFat)),
remainingCarbs: String(Math.round(remainingCarbs)),
limit: '12',
});
const res = await fetch(`/api/fitness/round-off-day?${params}`);
if (res.ok) {
const data = await res.json();
suggestions = data.suggestions;
}
} catch {
suggestions = [];
}
loading = false;
}
$effect(() => {
if (!initialSuggestions && remainingKcal > 0) {
untrack(() => fetchSuggestions());
}
});
function startLog(comboIdx) {
editingComboIdx = comboIdx;
editMealType = defaultMealType();
}
function cancelLog() {
editingComboIdx = -1;
}
async function logCombo(combo) {
loggingIdx = editingComboIdx;
try {
for (const item of combo.items) {
const res = await fetch('/api/fitness/food-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: currentDate,
mealType: editMealType,
name: item.name,
source: item.source,
sourceId: item.id,
amountGrams: item.grams,
per100g: item.atServing
? {
calories: item.atServing.calories / item.grams * 100,
protein: item.atServing.protein / item.grams * 100,
fat: item.atServing.fat / item.grams * 100,
carbs: item.atServing.carbs / item.grams * 100,
}
: undefined,
})
});
if (!res.ok) {
toast.error(isEn ? 'Failed to log food' : 'Fehler beim Eintragen');
loggingIdx = -1;
return;
}
}
const names = combo.items.map(i => i.name).join(' + ');
toast.success(isEn ? `Logged "${names}"` : `"${names}" eingetragen`);
editingComboIdx = -1;
onlogged();
} catch {
toast.error(isEn ? 'Failed to log food' : 'Fehler beim Eintragen');
}
loggingIdx = -1;
}
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (Math.abs(v) >= 100) return Math.round(v).toString();
return v.toFixed(1);
}
function fmtSigned(v) {
const s = fmt(v);
return v > 0 ? '+' + s : s;
}
const displayItems = $derived(expanded ? suggestions : suggestions?.slice(0, 5));
</script>
{#if suggestions?.length || loading}
<div class="round-off-card">
<div class="round-off-header">
<Sparkles size={16} />
<h3>{isEn ? 'Round off this day' : 'Tag abrunden'}</h3>
<span class="round-off-remaining">{Math.round(remainingKcal)} kcal {t('remaining', lang)}</span>
</div>
{#if loading}
<div class="round-off-loading">
<div class="skeleton-row"></div>
<div class="skeleton-row short"></div>
<div class="skeleton-row"></div>
</div>
{:else if suggestions?.length}
<!-- Hero: top suggestion -->
{@const hero = suggestions[0]}
{@const heroEditing = editingComboIdx === 0}
{@const hd = hero.delta}
<div class="hero-card" class:editing={heroEditing}>
<div class="hero-row">
<div class="hero-accent"></div>
<div class="hero-body">
<div class="hero-left">
<div class="hero-fit">
<span class="hero-fit-val">{hero.fitPercent}%</span>
</div>
<div class="hero-delta">
<div class="hero-delta-top">
<span class="hero-delta-item" class:overshoot={hd.protein < 0}>
<span class="hero-delta-val macro-p">{fmtSigned(hd.protein)}</span>
<span class="hero-delta-label macro-p"><Beef size={12} /></span>
</span>
</div>
<div class="hero-delta-bottom">
<span class="hero-delta-item" class:overshoot={hd.fat < 0}>
<span class="hero-delta-val macro-f">{fmtSigned(hd.fat)}</span>
<span class="hero-delta-label macro-f"><Droplet size={12} /></span>
</span>
<span class="hero-delta-item" class:overshoot={hd.carbs < 0}>
<span class="hero-delta-val macro-c">{fmtSigned(hd.carbs)}</span>
<span class="hero-delta-label macro-c"><Wheat size={12} /></span>
</span>
</div>
</div>
</div>
<div class="hero-ingredients">
{#each hero.items as item, i (item.id)}
{#if i > 0}<span class="hero-plus">+</span>{/if}
<span class="hero-ingredient">
<span class="hero-ing-name">
{#if hero.tierLabel === 'recipe'}🍳{/if}
{isEn && item.nameEn ? item.nameEn : item.name}
</span>
<span class="hero-ing-grams">{item.grams}g</span>
</span>
{/each}
</div>
</div>
<button
class="hero-add"
class:active={heroEditing}
onclick={() => heroEditing ? cancelLog() : startLog(0)}
disabled={loggingIdx >= 0}
aria-label={heroEditing ? (isEn ? 'Cancel' : 'Abbrechen') : (isEn ? 'Add' : 'Hinzufügen')}
>
<Plus size={16} />
</button>
</div>
{#if heroEditing}
<div class="hero-edit">
<MealTypePicker value={editMealType} {lang} onchange={(m) => editMealType = m} />
<div class="edit-actions">
<button class="edit-confirm" onclick={() => logCombo(hero)} disabled={loggingIdx >= 0}>
{hero.items.length > 1
? (isEn ? `Log ${hero.items.length} items` : `${hero.items.length} loggen`)
: (isEn ? 'Log' : 'OK')}
</button>
</div>
</div>
{/if}
</div>
<!-- Compact list: remaining suggestions -->
{#if displayItems && displayItems.length > 1}
<div class="compact-list">
{#each displayItems.slice(1) as combo, idx (idx + 1)}
{@const isEditing = editingComboIdx === idx + 1}
{@const d = combo.delta}
<div class="compact-row" class:editing={isEditing}>
<div class="compact-top">
<div class="compact-main">
<span class="compact-fit">{combo.fitPercent}%</span>
<span class="compact-name">
{#each combo.items as item, i (item.id)}
{#if i > 0}<span class="compact-plus"> + </span>{/if}
<span class="compact-item">
<span class="compact-grams">{item.grams}g</span>
{#if combo.tierLabel === 'recipe' && i === 0}🍳{/if}
{isEn && item.nameEn ? item.nameEn : item.name}
</span>
{/each}
</span>
<span class="compact-delta">
<span class="compact-delta-item" class:overshoot={d.protein < 0}><span class="macro-p">{fmtSigned(d.protein)}</span><span class="macro-p"><Beef size={12} /></span></span>
<span class="compact-delta-item" class:overshoot={d.fat < 0}><span class="macro-f">{fmtSigned(d.fat)}</span><span class="macro-f"><Droplet size={12} /></span></span>
<span class="compact-delta-item" class:overshoot={d.carbs < 0}><span class="macro-c">{fmtSigned(d.carbs)}</span><span class="macro-c"><Wheat size={12} /></span></span>
</span>
</div>
<button
class="compact-add"
class:active={isEditing}
onclick={() => isEditing ? cancelLog() : startLog(idx + 1)}
disabled={loggingIdx >= 0}
aria-label={isEditing ? (isEn ? 'Cancel' : 'Abbrechen') : (isEn ? 'Add' : 'Hinzufügen')}
>
<Plus size={14} />
</button>
</div>
{#if isEditing}
<div class="compact-edit">
<MealTypePicker value={editMealType} {lang} onchange={(m) => editMealType = m} />
<div class="edit-actions">
<button class="edit-confirm" onclick={() => logCombo(combo)} disabled={loggingIdx >= 0}>
{combo.items.length > 1
? (isEn ? `Log ${combo.items.length} items` : `${combo.items.length} loggen`)
: (isEn ? 'Log' : 'OK')}
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
{#if suggestions.length > 5 && !expanded}
<button class="round-off-toggle" onclick={() => expanded = true}>
<ChevronDown size={14} />
{isEn ? 'Show more' : 'Mehr anzeigen'} ({suggestions.length - 5})
</button>
{/if}
{:else}
<p class="round-off-empty">{isEn ? 'No matching suggestions found.' : 'Keine passenden Vorschläge gefunden.'}</p>
{/if}
</div>
{/if}
<style>
.round-off-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.round-off-header {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.6rem;
color: var(--color-primary);
}
.round-off-header h3 {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
flex: 1;
}
.round-off-remaining {
font-size: 0.75rem;
color: var(--color-text-tertiary);
white-space: nowrap;
}
/* Loading skeleton */
.round-off-loading {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeleton-row {
height: 2.4rem;
background: var(--color-bg-tertiary);
border-radius: 8px;
animation: pulse 1.5s ease-in-out infinite;
}
.skeleton-row.short {
width: 70%;
}
@keyframes pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.3; }
}
/* ── Hero card (top suggestion) ── */
.hero-card {
display: flex;
flex-direction: column;
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 20%, var(--color-border));
border-radius: 10px;
overflow: hidden;
box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,0,0,0.08));
margin-bottom: 0.5rem;
}
.hero-row {
display: flex;
align-items: stretch;
}
.hero-accent {
width: 4px;
flex-shrink: 0;
background: var(--color-primary);
}
.hero-body {
flex: 1;
display: flex;
align-items: stretch;
padding: 0.5rem;
gap: 0.5rem;
min-width: 0;
}
.hero-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
flex-shrink: 0;
min-width: 3.2rem;
}
.hero-fit {
display: flex;
align-items: center;
justify-content: center;
}
.hero-fit-val {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-primary);
font-variant-numeric: tabular-nums;
}
.hero-delta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.hero-delta-top {
display: flex;
justify-content: center;
}
.hero-delta-bottom {
display: flex;
gap: 0.3rem;
justify-content: center;
}
.hero-delta-item {
display: flex;
align-items: baseline;
gap: 0.1rem;
}
.hero-delta-val {
font-size: 0.68rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.hero-delta-label {
display: flex;
align-items: center;
}
.hero-ingredients {
display: flex;
align-items: stretch;
gap: 0.35rem;
min-width: 0;
flex: 1;
justify-content: center;
}
.hero-plus {
color: var(--color-text-tertiary);
font-size: 0.75rem;
font-weight: 400;
display: flex;
align-items: center;
}
.hero-ingredient {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
flex: 1;
min-width: 3rem;
max-width: 9rem;
padding: 0.4rem 0.5rem;
background: var(--color-bg-tertiary);
border-radius: 8px;
text-align: center;
}
.hero-ing-name {
font-weight: 600;
font-size: 0.82rem;
color: var(--color-text-primary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.hero-ing-grams {
font-weight: 500;
font-size: 0.72rem;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.hero-add {
display: flex;
align-items: center;
justify-content: center;
align-self: center;
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: var(--color-primary);
color: var(--color-text-on-primary);
cursor: pointer;
flex-shrink: 0;
margin-right: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.hero-add :global(svg) {
transition: transform 0.2s ease;
}
.hero-add.active {
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
}
.hero-add.active :global(svg) {
transform: rotate(45deg);
}
.hero-add:hover { background: var(--color-primary-hover); }
.hero-add.active:hover { background: var(--color-bg-elevated); }
.hero-add:disabled { opacity: 0.5; }
.hero-edit {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.5rem 0.75rem 0.6rem;
}
/* ── Compact list (remaining suggestions) ── */
.compact-list {
display: flex;
flex-direction: column;
}
.compact-row {
display: flex;
flex-direction: column;
padding: 0.35rem 0;
border-bottom: 1px solid var(--color-border);
}
.compact-row:last-child { border-bottom: none; }
.compact-top {
display: flex;
align-items: center;
gap: 0.4rem;
}
.compact-main {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
min-width: 0;
gap: 0.5rem;
}
.compact-fit {
font-size: 0.72rem;
font-weight: 700;
color: var(--color-primary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 2rem;
}
.compact-name {
font-size: 0.8rem;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.compact-plus {
color: var(--color-text-tertiary);
font-weight: 400;
}
.compact-grams {
font-weight: 600;
font-size: 0.73rem;
color: var(--color-text-secondary);
}
.compact-delta {
display: flex;
gap: 0.3rem;
font-size: 0.68rem;
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
white-space: nowrap;
}
.compact-delta-item {
display: flex;
align-items: center;
gap: 0.1rem;
}
.compact-delta-item.overshoot :global(*) {
color: var(--nord11) !important;
}
.compact-add {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
cursor: pointer;
flex-shrink: 0;
-webkit-tap-highlight-color: transparent;
}
.compact-add :global(svg) {
transition: transform 0.2s ease;
}
.compact-add.active :global(svg) {
transform: rotate(45deg);
}
.compact-add:hover {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.compact-add.active:hover {
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
}
.compact-add:disabled { opacity: 0.5; }
/* ── Shared edit row ── */
.compact-edit {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.3rem;
padding: 0.3rem 0 0;
}
.edit-actions {
display: flex;
gap: 0.3rem;
}
.edit-confirm {
padding: 0.25rem 0.6rem;
border: none;
border-radius: 5px;
background: var(--color-primary);
color: var(--color-text-on-primary);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.edit-confirm:hover { background: var(--color-primary-hover); }
.edit-confirm:disabled { opacity: 0.5; }
/* ── Macro colors ── */
.macro-p { color: var(--nord14); }
.macro-f { color: var(--nord12); }
.macro-c { color: var(--nord9); }
.overshoot { color: var(--nord11) !important; }
/* ── Toggle ── */
.round-off-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 100%;
padding: 0.4rem;
margin-top: 0.3rem;
border: none;
background: none;
color: var(--color-primary);
font-size: 0.78rem;
cursor: pointer;
}
.round-off-toggle:hover { text-decoration: underline; }
.round-off-empty {
text-align: center;
color: var(--color-text-tertiary);
font-size: 0.82rem;
padding: 0.5rem 0;
margin: 0;
}
/* ── Small screen refinements ── */
@media (max-width: 500px) {
.compact-delta {
font-size: 0.62rem;
gap: 0.2rem;
}
/* Hero: stack vertically, move deltas above ingredients */
.hero-body {
flex-direction: column;
align-items: stretch;
}
.hero-left {
flex-direction: row;
justify-content: flex-start;
gap: 0.5rem;
min-width: unset;
}
.hero-delta {
flex-direction: row;
gap: 0.4rem;
}
.hero-delta-top,
.hero-delta-bottom {
display: contents;
}
.hero-ingredients {
flex-direction: column;
gap: 0.25rem;
}
.hero-ingredient {
max-width: unset;
}
.hero-plus {
display: none;
}
}
</style>
+88
View File
@@ -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' },
];
+296
View File
@@ -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;
}
+4
View File
@@ -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},
+21
View File
@@ -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<any>;
try { _model = mongoose.model('RoundOffCache'); } catch { _model = mongoose.model('RoundOffCache', RoundOffCacheSchema); }
export const RoundOffCache = _model;
@@ -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}
</div>
<!-- Cache per100g for round-off suggestions -->
<div class="section">
<h2>{isEnglish ? 'Cache Per-100g Nutrition' : 'Per-100g-Nährwerte cachen'}</h2>
<p>{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.'}
</p>
<button disabled={cacheProcessing} onclick={async () => {
cacheProcessing = true;
cacheResult = null;
errorMsg = '';
try {
const res = await fetch(`/api/${recipeLang}/nutrition/cache-per100g`, { method: 'POST' });
cacheResult = await res.json();
if (!res.ok) throw new Error(cacheResult.message || 'Failed');
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'An error occurred';
}
cacheProcessing = false;
}}>
{cacheProcessing ? (isEnglish ? 'Caching...' : 'Cache wird erstellt...') : (isEnglish ? 'Cache All' : 'Alle cachen')}
</button>
{#if cacheResult}
<div class="summary">
<div class="summary-stat">
<div class="value">{cacheResult.updated}</div>
<div class="label">{isEnglish ? 'Cached' : 'Gecacht'}</div>
</div>
<div class="summary-stat">
<div class="value">{cacheResult.skipped}</div>
<div class="label">{isEnglish ? 'Skipped' : 'Übersprungen'}</div>
</div>
</div>
<div class="result-box">
<table class="result-table">
<thead><tr><th>{isEnglish ? 'Recipe' : 'Rezept'}</th><th>kcal/100g</th><th>{isEnglish ? 'Total (g)' : 'Gesamt (g)'}</th></tr></thead>
<tbody>
{#each cacheResult.details as d}
<tr>
<td>{d.name}</td>
<td>{d.calories}</td>
<td>{d.totalGrams}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{#if errorMsg}
<p class="error">{errorMsg}</p>
{/if}
@@ -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<string, number>; totalGrams: number } | null {
const mappings = recipe.nutritionMappings;
if (!mappings?.length) return null;
const totals: Record<string, number> = {};
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<string, number> = {};
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,
});
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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<string, (typeof BLS_DB)[0]>();
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<ResolvedFood[]> {
const foods: ResolvedFood[] = [];
const seen = new Set<string>();
// 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<string, number> | null = null;
if (fav.source === 'bls') {
const entry = getBlsEntryByCode(fav.sourceId);
if (entry) per100g = entry.per100g as unknown as Record<string, number>;
} else if (fav.source === 'usda') {
const entry = getNutritionEntryByFdcId(Number(fav.sourceId));
if (entry) per100g = entry.per100g as unknown as Record<string, number>;
} else if (fav.source === 'off') {
const entry = await OpenFoodFact.findOne({ barcode: fav.sourceId }).lean();
if (entry) per100g = entry.per100g as unknown as Record<string, number>;
}
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<string>();
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,
});
};
@@ -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,
};
};
@@ -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()}
<RoundOffCard
remainingKcal={calorieBalance}
{remainingProtein}
{remainingFat}
{remainingCarbs}
{currentDate}
{lang}
nutritionSlug={s.nutrition}
initialSuggestions={data.roundOffSuggestions?.suggestions ?? null}
onlogged={() => {
invalidateAll();
}}
/>
{/snippet}
{#snippet microPanel()}
<div class="micro-details" class:micro-hidden={!showMicros}>
{#each microSections as section}
@@ -1438,6 +1464,13 @@
</div>
{/if}
<!-- Round Off This Day (mobile) -->
{#if showRoundOff}
<div class="round-off-mobile">
{@render roundOffSnippet()}
</div>
{/if}
<!-- Liquid Tracking Card -->
<div class="water-card">
<div class="water-header">
@@ -1537,6 +1570,12 @@
<!-- Meal Sections -->
<div class="meals-col">
<!-- Round Off This Day (desktop) -->
{#if showRoundOff}
<div class="round-off-desktop">
{@render roundOffSnippet()}
</div>
{/if}
{#each mealTypes as meal, mi}
{@const mealEntries = grouped[meal]}
{@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)}
@@ -1643,19 +1682,7 @@
<div class="quick-log-card">
<h3 class="quick-log-title">{isEn ? 'Quick Log' : 'Schnell eintragen'}</h3>
<div class="quick-log-meal-select">
{#each mealTypes as meal}
{@const meta = mealMeta[meal]}
{@const MealIcon = meta.icon}
<button
class="ql-meal-btn"
class:active={quickLogMealType === meal}
style="--mc: {meta.color}"
onclick={() => quickLogMealType = meal}
title={t(meal, lang)}
>
<MealIcon size={14} />
</button>
{/each}
<MealTypePicker value={quickLogMealType} {lang} onchange={(m) => quickLogMealType = m} />
</div>
{#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;
}