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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user