Files
homepage/src/routes/fitness/[nutrition=fitnessNutrition]/food/[source]/[id]/+page.svelte
T
Alexander b2e271c3ea
CI / update (push) Successful in 4m10s
feat: major dependency upgrades, remove Redis, fix mongoose 9 types
Dependencies upgraded:
- svelte 5.38→5.55, @sveltejs/kit 2.37→2.56, adapter-node 5.3→5.5
- mongoose 8→9, sharp 0.33→0.34, typescript 5→6
- lucide-svelte → @lucide/svelte 1.7 (Svelte 5 native package)
- vite 7→8 with rolldown (build time 33s→14s)
- Removed terser (esbuild/oxc default minifier is 20-100x faster)

Infrastructure:
- Removed Redis/ioredis cache layer — MongoDB handles caching natively
- Deleted src/lib/server/cache.ts and all cache.get/set/invalidate usage
- Removed redis-cli from deploy workflow, Redis env vars from .env.example

Mongoose 9 migration:
- Replaced deprecated `new: true` with `returnDocument: 'after'` (16 files)
- Fixed strict query filter types for ObjectId/paymentId fields
- Fixed season param type (string→number) in recipe API
- Removed unused @ts-expect-error in WorkoutSession model
2026-04-06 12:21:26 +02:00

659 lines
17 KiB
Svelte

<script>
import { page } from '$app/stores';
import { ChevronLeft, ChevronDown } from '@lucide/svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
const isEn = $derived(lang === 'en');
const food = $derived(data.food);
const dri = $derived(data.dri);
const n = $derived(food.per100g);
// --- Portion selector (USDA only) ---
let selectedPortionIdx = $state(-1); // -1 = per 100g
const portions = $derived(food.portions ?? []);
const portionMultiplier = $derived(
selectedPortionIdx >= 0 && portions[selectedPortionIdx]
? portions[selectedPortionIdx].grams / 100
: 1
);
const portionLabel = $derived(
selectedPortionIdx >= 0 && portions[selectedPortionIdx]
? `${portions[selectedPortionIdx].description} (${portions[selectedPortionIdx].grams}g)`
: isEn ? 'per 100 g' : 'pro 100 g'
);
/** Scale a nutrient value by the selected portion */
function scaled(val) {
return (val ?? 0) * portionMultiplier;
}
// --- Macro calorie percentages ---
const macroPercent = $derived.by(() => {
const proteinCal = n.protein * 4;
const fatCal = n.fat * 9;
const carbsCal = n.carbs * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
// --- SVG ring constants (same as NutritionSummary) ---
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
const ARC_ROTATE = 120;
function strokeOffset(percent) {
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
}
// --- Formatting ---
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
if (v >= 10) return v.toFixed(1);
return v.toFixed(1);
}
// --- Micronutrient sections ---
const mineralKeys = ['calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc'];
const vitaminKeys = ['vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', 'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate'];
const otherKeys = ['cholesterol'];
function mkMicroRows(keys) {
return keys.map(k => {
const meta = NUTRIENT_META[k];
const value = scaled(n[k]);
const goal = dri[k] ?? 0;
const pct = goal > 0 ? Math.round(value / goal * 100) : 0;
return { key: k, label: isEn ? meta.label : meta.labelDe, unit: meta.unit, value, goal, pct, isMax: meta.isMax };
});
}
const microSections = $derived([
{ title: isEn ? 'Minerals' : 'Mineralstoffe', rows: mkMicroRows(mineralKeys) },
{ title: isEn ? 'Vitamins' : 'Vitamine', rows: mkMicroRows(vitaminKeys) },
{ title: isEn ? 'Other' : 'Sonstiges', rows: mkMicroRows(otherKeys) },
]);
// --- Amino acids ---
const AMINO_META = {
leucine: { en: 'Leucine', de: 'Leucin' },
isoleucine: { en: 'Isoleucine', de: 'Isoleucin' },
valine: { en: 'Valine', de: 'Valin' },
lysine: { en: 'Lysine', de: 'Lysin' },
methionine: { en: 'Methionine', de: 'Methionin' },
phenylalanine: { en: 'Phenylalanine', de: 'Phenylalanin' },
threonine: { en: 'Threonine', de: 'Threonin' },
tryptophan: { en: 'Tryptophan', de: 'Tryptophan' },
histidine: { en: 'Histidine', de: 'Histidin' },
alanine: { en: 'Alanine', de: 'Alanin' },
arginine: { en: 'Arginine', de: 'Arginin' },
asparticAcid: { en: 'Aspartic Acid', de: 'Asparaginsäure' },
cysteine: { en: 'Cysteine', de: 'Cystein' },
glutamicAcid: { en: 'Glutamic Acid', de: 'Glutaminsäure' },
glycine: { en: 'Glycine', de: 'Glycin' },
proline: { en: 'Proline', de: 'Prolin' },
serine: { en: 'Serine', de: 'Serin' },
tyrosine: { en: 'Tyrosine', de: 'Tyrosin' },
};
const essentialOrder = ['leucine', 'isoleucine', 'valine', 'lysine', 'methionine', 'phenylalanine', 'threonine', 'tryptophan', 'histidine'];
const nonEssentialOrder = ['alanine', 'arginine', 'asparticAcid', 'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
const hasAminos = $derived.by(() => {
return essentialOrder.some(k => (n[k] ?? 0) > 0) || nonEssentialOrder.some(k => (n[k] ?? 0) > 0);
});
const aminoRows = $derived(
[...essentialOrder, ...nonEssentialOrder]
.filter(k => (n[k] ?? 0) > 0)
.map(k => ({
key: k,
label: isEn ? AMINO_META[k].en : AMINO_META[k].de,
value: scaled(n[k]),
essential: essentialOrder.includes(k),
}))
);
// --- Expand toggles ---
let showMicros = $state(true);
let showAminos = $state(false);
</script>
<svelte:head>
<title>{food.name} | {isEn ? 'Nutrition' : 'Ernährung'}</title>
</svelte:head>
<div class="food-detail">
<!-- Back link -->
<a class="back-link" href="/fitness/{s.nutrition}">
<ChevronLeft size={16} />
{t('nutrition_title', lang)}
</a>
<!-- Header -->
<header class="food-header">
<h1>{food.nameDe ?? food.name}</h1>
{#if food.nameEn && food.nameDe}
<p class="name-alt">{food.nameEn}</p>
{/if}
<div class="badges">
<span class="badge badge-source">{food.source === 'bls' ? 'BLS' : 'USDA'}</span>
<span class="badge badge-category">{food.category}</span>
</div>
</header>
<!-- Portion selector (USDA only) -->
{#if portions.length > 0}
<div class="portion-selector">
<label for="portion-select">{isEn ? 'Serving size' : 'Portionsgröße'}</label>
<select id="portion-select" bind:value={selectedPortionIdx}>
<option value={-1}>{isEn ? 'Per 100 g' : 'Pro 100 g'}</option>
{#each portions as portion, i}
<option value={i}>{portion.description} ({portion.grams}g)</option>
{/each}
</select>
</div>
{/if}
<!-- Calorie headline -->
<div class="calorie-headline">
<span class="cal-number">{Math.round(scaled(n.calories))}</span>
<span class="cal-unit">kcal</span>
<span class="cal-basis">{portionLabel}</span>
</div>
<!-- Macro rings -->
<div class="macro-rings">
{#each [
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: scaled(n.protein) },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: scaled(n.fat) },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: scaled(n.carbs) },
] as macro}
<div class="macro-ring">
<svg width="90" height="90" viewBox="0 0 70 70">
<circle
class="ring-bg"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)"
/>
<circle
class="ring-fill {macro.cls}"
cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(macro.pct)}
transform="rotate({ARC_ROTATE} 35 35)"
/>
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="macro-label">{macro.label}</span>
<span class="macro-grams">{fmt(macro.grams)}g</span>
</div>
{/each}
</div>
<!-- Macro detail grid -->
<div class="macro-detail-card">
<div class="detail-row">
<span class="detail-name">{isEn ? 'Protein' : 'Eiweiß'}</span>
<span class="detail-val">{fmt(scaled(n.protein))} g</span>
</div>
<div class="detail-row">
<span class="detail-name">{isEn ? 'Fat' : 'Fett'}</span>
<span class="detail-val">{fmt(scaled(n.fat))} g</span>
</div>
<div class="detail-row sub">
<span class="detail-name">{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
<span class="detail-val">{fmt(scaled(n.saturatedFat))} g</span>
</div>
<div class="detail-row">
<span class="detail-name">{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
<span class="detail-val">{fmt(scaled(n.carbs))} g</span>
</div>
<div class="detail-row sub">
<span class="detail-name">{isEn ? 'Sugars' : 'Zucker'}</span>
<span class="detail-val">{fmt(scaled(n.sugars))} g</span>
</div>
<div class="detail-row">
<span class="detail-name">{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
<span class="detail-val">{fmt(scaled(n.fiber))} g</span>
</div>
</div>
<!-- Micronutrients -->
<div class="section-card">
<button class="section-toggle" onclick={() => showMicros = !showMicros}>
<h2>{isEn ? 'Micronutrients' : 'Mikronährstoffe'}</h2>
<ChevronDown size={18} style={showMicros ? 'transform: rotate(180deg)' : ''} />
</button>
{#if showMicros}
<div class="micro-details">
{#each microSections as section}
<div class="micro-section">
<h4>{section.title}</h4>
{#each section.rows as row}
<div class="micro-row">
<span class="micro-label">{row.label}</span>
<div class="micro-bar-wrap">
<div class="micro-bar" class:is-max={row.isMax} style="width: {Math.min(row.pct, 100)}%"></div>
</div>
<span class="micro-value">{fmt(row.value)} {row.unit}</span>
<span class="micro-pct">{row.pct}%</span>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<!-- Amino Acids -->
{#if hasAminos}
<div class="section-card">
<button class="section-toggle" onclick={() => showAminos = !showAminos}>
<h2>{isEn ? 'Amino Acids' : 'Aminosäuren'}</h2>
<ChevronDown size={18} style={showAminos ? 'transform: rotate(180deg)' : ''} />
</button>
{#if showAminos}
<div class="amino-list">
{#each aminoRows as row}
<div class="amino-row" class:essential={row.essential}>
<span class="amino-label">{row.label}</span>
<span class="amino-value">{fmt(row.value)} g</span>
{#if row.essential}
<span class="amino-badge">{isEn ? 'essential' : 'essenziell'}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Portions table (USDA only) -->
{#if portions.length > 0}
<div class="section-card">
<h2>{isEn ? 'Common Serving Sizes' : 'Übliche Portionsgrößen'}</h2>
<div class="portions-table">
<div class="portions-header">
<span>{isEn ? 'Serving' : 'Portion'}</span>
<span>kcal</span>
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
<span>{isEn ? 'Fat' : 'Fett'}</span>
<span>{isEn ? 'Carbs' : 'KH'}</span>
</div>
{#each portions as portion}
{@const m = portion.grams / 100}
<div class="portions-row">
<span class="portion-desc">{portion.description} <small>({portion.grams}g)</small></span>
<span>{Math.round(n.calories * m)}</span>
<span>{fmt(n.protein * m)}g</span>
<span>{fmt(n.fat * m)}g</span>
<span>{fmt(n.carbs * m)}g</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.food-detail {
max-width: 600px;
margin: 0 auto;
padding: 1rem;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.85rem;
margin-bottom: 0.75rem;
}
.back-link:hover {
color: var(--color-text-primary);
}
/* Header */
.food-header {
margin-bottom: 1rem;
}
.food-header h1 {
font-size: 1.4rem;
margin: 0 0 0.25rem;
color: var(--color-text-primary);
line-height: 1.3;
}
.name-alt {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: var(--color-text-tertiary);
}
.badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-source {
background: var(--nord10);
color: #fff;
}
.badge-category {
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
/* Portion selector */
.portion-selector {
margin-bottom: 1rem;
}
.portion-selector label {
display: block;
font-size: 0.8rem;
color: var(--color-text-secondary);
margin-bottom: 0.25rem;
}
.portion-selector select {
width: 100%;
padding: 0.4rem 0.5rem;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font-size: 0.9rem;
}
/* Calorie headline */
.calorie-headline {
text-align: center;
margin-bottom: 1rem;
}
.cal-number {
font-size: 2.8rem;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1;
}
.cal-unit {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-left: 0.25rem;
}
.cal-basis {
display: block;
font-size: 0.8rem;
color: var(--color-text-tertiary);
margin-top: 0.15rem;
}
/* Macro rings */
.macro-rings {
display: flex;
justify-content: space-around;
margin: 0 0 1.25rem;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
flex: 1;
}
.ring-bg {
fill: none;
stroke: var(--color-border);
stroke-width: 5;
stroke-linecap: round;
}
.ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.ring-protein { stroke: var(--nord14); }
.ring-fat { stroke: var(--nord12); }
.ring-carbs { stroke: var(--nord9); }
.macro-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.macro-grams {
font-size: 0.78rem;
color: var(--color-text-tertiary);
}
/* Macro detail card */
.macro-detail-card {
background: var(--color-surface);
border-radius: 10px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
border-bottom: 1px solid var(--color-border);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-row.sub .detail-name {
padding-left: 1rem;
color: var(--color-text-tertiary);
font-size: 0.85rem;
}
.detail-name {
color: var(--color-text-primary);
font-weight: 500;
}
.detail-val {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* Section cards */
.section-card {
background: var(--color-surface);
border-radius: 10px;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
}
.section-card h2 {
font-size: 1rem;
margin: 0;
color: var(--color-text-primary);
}
.section-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--color-text-primary);
}
/* Micro details */
.micro-details {
margin-top: 0.75rem;
}
.micro-section {
margin-bottom: 0.75rem;
}
.micro-section h4 {
margin: 0 0 0.4rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-secondary);
}
.micro-row {
display: grid;
grid-template-columns: 7rem 1fr 4rem 2.5rem;
gap: 0.5rem;
align-items: center;
padding: 0.2rem 0;
font-size: 0.78rem;
}
.micro-label {
color: var(--color-text-primary);
font-weight: 500;
}
.micro-bar-wrap {
height: 5px;
background: var(--color-bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.micro-bar {
height: 100%;
background: var(--nord14);
border-radius: 3px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.micro-bar.is-max {
background: var(--nord12);
}
.micro-value {
text-align: right;
color: var(--color-text-tertiary);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.micro-pct {
text-align: right;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
/* Amino acids */
.amino-list {
margin-top: 0.75rem;
}
.amino-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
}
.amino-row:last-child {
border-bottom: none;
}
.amino-label {
flex: 1;
color: var(--color-text-primary);
}
.amino-value {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.amino-badge {
font-size: 0.65rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
background: var(--nord14);
color: #fff;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Portions table */
.portions-table {
margin-top: 0.5rem;
font-size: 0.82rem;
}
.portions-header, .portions-row {
display: grid;
grid-template-columns: 1fr 3.5rem 3.5rem 3.5rem 3.5rem;
gap: 0.25rem;
padding: 0.35rem 0;
}
.portions-header {
font-weight: 700;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
border-bottom: 2px solid var(--color-border);
}
.portions-row {
border-bottom: 1px solid var(--color-border);
color: var(--color-text-primary);
}
.portions-row:last-child {
border-bottom: none;
}
.portions-row span:not(.portion-desc) {
text-align: right;
font-variant-numeric: tabular-nums;
}
.portion-desc {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portion-desc small {
color: var(--color-text-tertiary);
}
@media (max-width: 500px) {
.micro-row {
grid-template-columns: 5.5rem 1fr 3.5rem 2.2rem;
gap: 0.3rem;
font-size: 0.72rem;
}
.portions-header, .portions-row {
grid-template-columns: 1fr 3rem 3rem 3rem 3rem;
font-size: 0.72rem;
}
}
</style>