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:
+1
-1
@@ -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>
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user