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 6029cfe18c
commit 1e23ed02c2
17 changed files with 1645 additions and 37 deletions
@@ -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;
}