b287affeb2
CI / update (push) Successful in 3m36s
Shows a Today button when not viewing current date/month. Nutrition page button appears right-aligned in the date nav. Period tracker button appears top-right of the calendar header with centered month title and chevrons.
3323 lines
97 KiB
Svelte
3323 lines
97 KiB
Svelte
<script>
|
||
import { page } from '$app/stores';
|
||
import { goto, invalidateAll } from '$app/navigation';
|
||
import { ChevronLeft, ChevronRight, Plus, Trash2, ChevronDown, Settings, Coffee, Sun, Moon, Cookie, Utensils, Info, UtensilsCrossed, AlertTriangle, Check, GlassWater, Pencil, Heart, Clock } from '@lucide/svelte';
|
||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||
import AddButton from '$lib/components/AddButton.svelte';
|
||
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
|
||
import { toast } from '$lib/js/toast.svelte';
|
||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||
import { getDRI, NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
|
||
|
||
const lang = $derived(detectFitnessLang($page.url.pathname));
|
||
const s = $derived(fitnessSlugs(lang));
|
||
const isEn = $derived(lang === 'en');
|
||
|
||
let { data } = $props();
|
||
|
||
// --- Date navigation ---
|
||
// svelte-ignore state_referenced_locally
|
||
let currentDate = $state(data.date);
|
||
const todayStr = new Date().toISOString().slice(0, 10);
|
||
const isToday = $derived(currentDate === todayStr);
|
||
|
||
const displayDate = $derived.by(() => {
|
||
const d = new Date(currentDate + 'T12:00:00');
|
||
return d.toLocaleDateString(isEn ? 'en-US' : 'de-DE', { weekday: 'short', day: 'numeric', month: 'short' });
|
||
});
|
||
|
||
async function navigateDate(offset) {
|
||
const d = new Date(currentDate + 'T12:00:00');
|
||
d.setDate(d.getDate() + offset);
|
||
currentDate = d.toISOString().slice(0, 10);
|
||
await loadEntries();
|
||
}
|
||
|
||
async function goToday() {
|
||
currentDate = todayStr;
|
||
await loadEntries();
|
||
}
|
||
|
||
// --- Entries ---
|
||
// svelte-ignore state_referenced_locally
|
||
let entries = $state(data.foodLog?.entries ?? []);
|
||
// svelte-ignore state_referenced_locally
|
||
let recipeImages = $state(data.recipeImages ?? {});
|
||
|
||
async function loadEntries() {
|
||
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
|
||
}
|
||
|
||
// Keep reactive with server data when navigating
|
||
$effect(() => {
|
||
entries = data.foodLog?.entries ?? [];
|
||
recipeImages = data.recipeImages ?? {};
|
||
exerciseKcal = Number(data.exerciseKcal) || 0;
|
||
currentDate = data.date;
|
||
});
|
||
|
||
// --- Goals ---
|
||
// svelte-ignore state_referenced_locally
|
||
let goalCalories = $state(data.goal?.dailyCalories ?? null);
|
||
// svelte-ignore state_referenced_locally
|
||
let goalProteinMode = $state(data.goal?.proteinMode ?? 'fixed');
|
||
// svelte-ignore state_referenced_locally
|
||
let goalProteinTarget = $state(data.goal?.proteinTarget ?? null);
|
||
// svelte-ignore state_referenced_locally
|
||
let goalFatPercent = $state(data.goal?.fatPercent ?? null);
|
||
// svelte-ignore state_referenced_locally
|
||
let goalCarbPercent = $state(data.goal?.carbPercent ?? null);
|
||
// svelte-ignore state_referenced_locally
|
||
let goalSex = $state(data.goal?.sex ?? 'male');
|
||
// svelte-ignore state_referenced_locally
|
||
let activityLevel = $state(data.goal?.activityLevel ?? 'light');
|
||
// svelte-ignore state_referenced_locally
|
||
let latestWeight = $state(data.latestWeight?.weight?.value ?? null);
|
||
|
||
let showGoalEditor = $state(false);
|
||
let goalSaving = $state(false);
|
||
|
||
// Editable goal fields (populated by openGoalEditor)
|
||
let editCalories = $state('');
|
||
let editActivityLevel = $state('light');
|
||
let editProteinMode = $state('fixed');
|
||
let editProteinTarget = $state('');
|
||
let editFatPercent = $state('');
|
||
let editCarbPercent = $state('');
|
||
|
||
const dietPresets = [
|
||
{ emoji: '⚖️', en: 'WHO Balanced', de: 'WHO Ausgewogen',
|
||
descEn: 'Standard WHO guidelines — moderate protein, balanced macros',
|
||
descDe: 'WHO-Standardempfehlung — moderates Protein, ausgewogene Makros',
|
||
proteinMode: 'per_kg', proteinTarget: 0.83, fatPercent: 30, carbPercent: 55 },
|
||
{ emoji: '💪', en: 'Maintain', de: 'Halten',
|
||
descEn: 'Eat at TDEE with higher protein to preserve muscle',
|
||
descDe: 'Essen auf TDEE-Niveau mit erhöhtem Protein für Muskelerhalt',
|
||
proteinMode: 'per_kg', proteinTarget: 1.6, fatPercent: 30, carbPercent: 45, calMult: 1.0 },
|
||
{ emoji: '🔪', en: 'Cut', de: 'Definieren',
|
||
descEn: '20% calorie deficit, high protein to minimize muscle loss',
|
||
descDe: '20% Kaloriendefizit, hohes Protein gegen Muskelverlust',
|
||
proteinMode: 'per_kg', proteinTarget: 2.2, fatPercent: 25, carbPercent: 40, calMult: 0.8 },
|
||
{ emoji: '🍖', en: 'Bulk', de: 'Aufbauen',
|
||
descEn: '15% calorie surplus for muscle growth with moderate protein',
|
||
descDe: '15% Kalorienüberschuss für Muskelaufbau, moderates Protein',
|
||
proteinMode: 'per_kg', proteinTarget: 1.8, fatPercent: 30, carbPercent: 50, calMult: 1.15 },
|
||
{ emoji: '🥑', en: 'Keto', de: 'Keto',
|
||
descEn: 'Very low carb, high fat — forces ketosis for fat burning',
|
||
descDe: 'Sehr wenig Kohlenhydrate, viel Fett — erzwingt Ketose',
|
||
proteinMode: 'per_kg', proteinTarget: 1.5, fatPercent: 70, carbPercent: 5 },
|
||
{ emoji: '🏋️', en: 'High Protein', de: 'Proteinreich',
|
||
descEn: 'Maximum protein for strength athletes and bodybuilders',
|
||
descDe: 'Maximales Protein für Kraftsportler und Bodybuilder',
|
||
proteinMode: 'per_kg', proteinTarget: 2.5, fatPercent: 30, carbPercent: 30 },
|
||
{ emoji: '🥩', en: 'Carnivore', de: 'Karnivor',
|
||
descEn: 'Animal products only — zero carb, high fat and protein',
|
||
descDe: 'Nur tierische Produkte — null Kohlenhydrate, viel Fett und Protein',
|
||
proteinMode: 'per_kg', proteinTarget: 2.5, fatPercent: 65, carbPercent: 0 },
|
||
];
|
||
|
||
// Wizard step: 1=presets, 2=calories+activity, 3=macros
|
||
let goalStep = $state(1);
|
||
let selectedPresetIdx = $state(-1);
|
||
|
||
function applyPreset(preset, idx) {
|
||
selectedPresetIdx = idx;
|
||
editProteinMode = preset.proteinMode;
|
||
editProteinTarget = String(preset.proteinTarget);
|
||
editFatPercent = String(preset.fatPercent);
|
||
editCarbPercent = String(preset.carbPercent);
|
||
if (preset.calMult && hasBmrData) {
|
||
editCalories = String(Math.round(dailyTdee * preset.calMult));
|
||
}
|
||
// Auto-advance to step 2
|
||
goalStep = 2;
|
||
}
|
||
|
||
function openGoalEditor() {
|
||
editCalories = String(goalCalories ?? '');
|
||
editActivityLevel = activityLevel;
|
||
editProteinMode = goalProteinMode;
|
||
editProteinTarget = String(goalProteinTarget ?? '');
|
||
editFatPercent = String(goalFatPercent ?? '');
|
||
editCarbPercent = String(goalCarbPercent ?? '');
|
||
selectedPresetIdx = -1;
|
||
goalStep = 1;
|
||
showGoalEditor = true;
|
||
}
|
||
|
||
// Macro ring preview (derived from edit fields)
|
||
const RING_R = 48;
|
||
const RING_C = 2 * Math.PI * RING_R;
|
||
const RING_GAP = 4;
|
||
const editMacroRing = $derived.by(() => {
|
||
const cal = Number(editCalories) || 0;
|
||
const fat = Number(editFatPercent) || 0;
|
||
const carb = Number(editCarbPercent) || 0;
|
||
const prot = Math.max(0, 100 - fat - carb);
|
||
const fatDeg = (fat / 100) * 360;
|
||
const carbDeg = (carb / 100) * 360;
|
||
const fatLen = (fat / 100) * RING_C;
|
||
const carbLen = (carb / 100) * RING_C;
|
||
const protLen = (prot / 100) * RING_C;
|
||
return { cal, fat, carb, prot, fatDeg, carbDeg, fatLen, carbLen, protLen };
|
||
});
|
||
|
||
// Step summary labels
|
||
const stepPresetSummary = $derived(
|
||
selectedPresetIdx >= 0 ? (isEn ? dietPresets[selectedPresetIdx].en : dietPresets[selectedPresetIdx].de) : (isEn ? 'Custom' : 'Benutzerdefiniert')
|
||
);
|
||
const stepCalSummary = $derived(
|
||
editCalories ? `${editCalories} kcal` : '—'
|
||
);
|
||
const stepMacroSummary = $derived.by(() => {
|
||
const f = Number(editFatPercent) || 0;
|
||
const c = Number(editCarbPercent) || 0;
|
||
const p = Math.max(0, 100 - f - c);
|
||
return `P${p}% F${f}% C${c}%`;
|
||
});
|
||
|
||
async function saveGoals() {
|
||
goalSaving = true;
|
||
try {
|
||
const body = {
|
||
weeklyWorkouts: data.goal?.weeklyWorkouts ?? 4,
|
||
sex: goalSex,
|
||
heightCm: data.goal?.heightCm,
|
||
activityLevel: editActivityLevel,
|
||
dailyCalories: Number(editCalories) || null,
|
||
proteinMode: editProteinMode,
|
||
proteinTarget: Number(editProteinTarget) || null,
|
||
fatPercent: Number(editFatPercent) || null,
|
||
carbPercent: Number(editCarbPercent) || null,
|
||
};
|
||
const res = await fetch('/api/fitness/goal', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body)
|
||
});
|
||
if (res.ok) {
|
||
const d = await res.json();
|
||
goalCalories = d.dailyCalories;
|
||
activityLevel = d.activityLevel ?? 'light';
|
||
goalProteinMode = d.proteinMode ?? 'fixed';
|
||
goalProteinTarget = d.proteinTarget;
|
||
goalFatPercent = d.fatPercent;
|
||
goalCarbPercent = d.carbPercent;
|
||
showGoalEditor = false;
|
||
}
|
||
} finally {
|
||
goalSaving = false;
|
||
}
|
||
}
|
||
|
||
// --- Computed daily totals ---
|
||
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
|
||
|
||
const grouped = $derived.by(() => {
|
||
/** @type {Record<string, any[]>} */
|
||
const g = { breakfast: [], lunch: [], dinner: [], snack: [] };
|
||
for (const e of entries) {
|
||
if (e.mealType === 'water') continue;
|
||
if (g[e.mealType]) g[e.mealType].push(e);
|
||
}
|
||
return g;
|
||
});
|
||
|
||
// --- Water & liquid tracking ---
|
||
const WATER_CUP_ML = 250;
|
||
|
||
/** BLS Trinkwasser (N110000) per100g */
|
||
const WATER_PER100G = {
|
||
calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0,
|
||
calcium: 5.3, iron: 0, magnesium: 0.9, phosphorus: 0.011, potassium: 0.2,
|
||
sodium: 2.3, zinc: 0.001, vitaminA: 0, vitaminC: 0, vitaminD: 0, vitaminE: 0,
|
||
vitaminK: 0, thiamin: 0, riboflavin: 0, niacin: 0, vitaminB6: 0, vitaminB12: 0,
|
||
folate: 0, cholesterol: 0,
|
||
};
|
||
|
||
/** Detect if a food log entry is a beverage (non-water) */
|
||
const DRINK_PATTERNS = /^(milch|kaffee|coffee|tee|tea|cola|fanta|sprite|saft|juice|limo|smoothie|kakao|cocoa|bier|beer|wein|wine|eistee|ice tea|energy|redbull|red bull|mate|schorle|sprudel|mineral|orangensaft|apfelsaft|multivitamin|iso|gatorade|powerade)/i;
|
||
function isBeverage(e) {
|
||
if (e.mealType === 'water') return false;
|
||
if (e.source === 'bls' && e.sourceId?.startsWith('N')) return true;
|
||
return DRINK_PATTERNS.test(e.name);
|
||
}
|
||
|
||
/** Detect if a custom meal ingredient is a liquid (for hydration auto-logging) */
|
||
function isLiquidIngredient(ing) {
|
||
if (ing.source === 'bls' && ing.sourceId?.startsWith('N')) return true;
|
||
return DRINK_PATTERNS.test(ing.name) || /^(wasser|water|trinkwasser)/i.test(ing.name);
|
||
}
|
||
|
||
let waterGoalMl = $state(2000);
|
||
let editingGoal = $state(false);
|
||
let goalInputL = $state(2);
|
||
|
||
$effect(() => {
|
||
const saved = localStorage.getItem('water_goal_ml');
|
||
if (saved) {
|
||
const v = parseInt(saved);
|
||
if (v > 0) waterGoalMl = v;
|
||
}
|
||
});
|
||
|
||
function saveGoal() {
|
||
const ml = Math.max(250, Math.round(goalInputL * 1000));
|
||
waterGoalMl = ml;
|
||
localStorage.setItem('water_goal_ml', String(ml));
|
||
editingGoal = false;
|
||
}
|
||
|
||
let waterEntries = $derived(entries.filter(e => e.mealType === 'water'));
|
||
let beverageEntries = $derived(entries.filter(isBeverage));
|
||
let waterMl = $derived(waterEntries.reduce((s, e) => s + e.amountGrams, 0));
|
||
let beverageMl = $derived(beverageEntries.reduce((s, e) => s + e.amountGrams, 0));
|
||
let mealLiquidMl = $derived(entries.reduce((s, e) => s + (e.liquidMl ?? 0), 0));
|
||
let totalLiquidMl = $derived(waterMl + beverageMl + mealLiquidMl);
|
||
let beverageCups = $derived(Math.round(beverageMl / WATER_CUP_ML));
|
||
let waterCups = $derived(Math.round(waterMl / WATER_CUP_ML));
|
||
let mealLiquidCups = $derived(Math.round(mealLiquidMl / WATER_CUP_ML));
|
||
let totalCups = $derived(beverageCups + waterCups + mealLiquidCups);
|
||
let goalCups = $derived(Math.round(waterGoalMl / WATER_CUP_ML));
|
||
let displayCups = $derived(Math.max(goalCups, totalCups + 1));
|
||
|
||
/** @type {Set<number>} cups currently animating fill/drain */
|
||
let fillingCups = $state(new Set());
|
||
let drainingCups = $state(new Set());
|
||
let lastTotalCups = $state(-1);
|
||
|
||
$effect(() => {
|
||
const cur = totalCups;
|
||
if (lastTotalCups === -1) {
|
||
lastTotalCups = cur;
|
||
return;
|
||
}
|
||
if (cur > lastTotalCups) {
|
||
const s = new Set(fillingCups);
|
||
for (let i = lastTotalCups; i < cur; i++) s.add(i);
|
||
fillingCups = s;
|
||
setTimeout(() => { fillingCups = new Set(); }, 1500);
|
||
} else if (cur < lastTotalCups) {
|
||
const s = new Set(drainingCups);
|
||
for (let i = cur; i < lastTotalCups; i++) s.add(i);
|
||
drainingCups = s;
|
||
setTimeout(() => { drainingCups = new Set(); }, 700);
|
||
}
|
||
lastTotalCups = cur;
|
||
});
|
||
|
||
async function setWaterCups(target) {
|
||
const current = waterCups;
|
||
if (target === current) return;
|
||
try {
|
||
if (target > current) {
|
||
const toAdd = target - current;
|
||
const promises = Array.from({ length: toAdd }, () =>
|
||
fetch('/api/fitness/food-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
date: currentDate,
|
||
mealType: 'water',
|
||
name: 'Trinkwasser',
|
||
source: 'bls',
|
||
sourceId: 'N110000',
|
||
amountGrams: WATER_CUP_ML,
|
||
per100g: WATER_PER100G,
|
||
})
|
||
}).then(r => r.ok ? r.json() : null)
|
||
);
|
||
const results = await Promise.all(promises);
|
||
const newEntries = results.filter(Boolean);
|
||
if (newEntries.length) entries = [...entries, ...newEntries];
|
||
} else {
|
||
const toRemove = waterEntries.slice(target);
|
||
const ids = toRemove.map(e => e._id);
|
||
await Promise.all(ids.map(id =>
|
||
fetch(`/api/fitness/food-log/${id}`, { method: 'DELETE' })
|
||
));
|
||
entries = entries.filter(e => !ids.includes(e._id));
|
||
}
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to update water' : 'Fehler beim Aktualisieren');
|
||
}
|
||
}
|
||
|
||
function entryCalories(e) {
|
||
return (e.per100g?.calories ?? 0) * e.amountGrams / 100;
|
||
}
|
||
function entryNutrient(e, key) {
|
||
return (e.per100g?.[key] ?? 0) * e.amountGrams / 100;
|
||
}
|
||
|
||
const microKeys = ['calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc',
|
||
'vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', 'thiamin', 'riboflavin', 'niacin',
|
||
'vitaminB6', 'vitaminB12', 'folate', 'cholesterol'];
|
||
const aminoKeys = ['isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine',
|
||
'threonine', 'tryptophan', 'valine', 'histidine', 'alanine', 'arginine',
|
||
'asparticAcid', 'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||
|
||
const dayTotals = $derived.by(() => {
|
||
let calories = 0, protein = 0, fat = 0, carbs = 0, fiber = 0, sugars = 0, saturatedFat = 0;
|
||
const micros = {};
|
||
const aminos = {};
|
||
for (const k of microKeys) micros[k] = 0;
|
||
for (const k of aminoKeys) aminos[k] = 0;
|
||
|
||
for (const e of entries) {
|
||
const r = e.amountGrams / 100;
|
||
const p = e.per100g ?? {};
|
||
calories += (p.calories ?? 0) * r;
|
||
protein += (p.protein ?? 0) * r;
|
||
fat += (p.fat ?? 0) * r;
|
||
carbs += (p.carbs ?? 0) * r;
|
||
fiber += (p.fiber ?? 0) * r;
|
||
sugars += (p.sugars ?? 0) * r;
|
||
saturatedFat += (p.saturatedFat ?? 0) * r;
|
||
for (const k of microKeys) micros[k] += (p[k] ?? 0) * r;
|
||
for (const k of aminoKeys) aminos[k] += (p[k] ?? 0) * r;
|
||
}
|
||
return { calories, protein, fat, carbs, fiber, sugars, saturatedFat, micros, aminos };
|
||
});
|
||
|
||
// Macro percentages by calorie contribution
|
||
const macroPercent = $derived.by(() => {
|
||
const proteinCal = dayTotals.protein * 4;
|
||
const fatCal = dayTotals.fat * 9;
|
||
const carbsCal = dayTotals.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),
|
||
};
|
||
});
|
||
|
||
// Protein goal in grams
|
||
const proteinGoalGrams = $derived.by(() => {
|
||
if (goalProteinTarget) {
|
||
if (goalProteinMode === 'per_kg' && latestWeight) {
|
||
return goalProteinTarget * latestWeight;
|
||
}
|
||
if (goalProteinMode === 'fixed') return goalProteinTarget;
|
||
}
|
||
// Fallback: derive from remaining calorie % (100 - fat% - carb%)
|
||
if (goalCalories && goalFatPercent != null && goalCarbPercent != null) {
|
||
const proteinPct = 100 - (goalFatPercent || 0) - (goalCarbPercent || 0);
|
||
if (proteinPct > 0) return (goalCalories * proteinPct / 100) / 4;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
// Fat/carb goals in grams (from calorie %)
|
||
const fatGoalGrams = $derived(goalCalories && goalFatPercent ? (goalCalories * goalFatPercent / 100) / 9 : null);
|
||
const carbGoalGrams = $derived(goalCalories && goalCarbPercent ? (goalCalories * goalCarbPercent / 100) / 4 : null);
|
||
|
||
// --- Burned kcal ---
|
||
// svelte-ignore state_referenced_locally
|
||
let exerciseKcal = $state(Number(data.exerciseKcal) || 0);
|
||
|
||
// BMR via Mifflin-St Jeor (doi:10.1093/ajcn/51.2.241)
|
||
const birthYear = $derived(data.goal?.birthYear ?? null);
|
||
const userAge = $derived(birthYear ? new Date().getFullYear() - birthYear : null);
|
||
const hasBmrData = $derived(latestWeight != null && data.goal?.heightCm != null && birthYear != null);
|
||
|
||
// NEAT-only multipliers (exercise tracked separately)
|
||
const ACTIVITY_MULT = { sedentary: 1.2, light: 1.3, moderate: 1.4, very_active: 1.5 };
|
||
|
||
const dailyBmr = $derived.by(() => {
|
||
const w = Number(latestWeight) || 80;
|
||
const h = Number(data.goal?.heightCm) || 175;
|
||
const age = userAge ?? 30;
|
||
if (goalSex === 'female') return 10 * w + 6.25 * h - 5 * age - 161;
|
||
return 10 * w + 6.25 * h - 5 * age + 5;
|
||
});
|
||
|
||
const dailyTdee = $derived(dailyBmr * (ACTIVITY_MULT[activityLevel] ?? 1.3));
|
||
|
||
// TDEE comparison bar (for goal editor)
|
||
const editTdee = $derived(dailyBmr * (ACTIVITY_MULT[editActivityLevel] ?? 1.3));
|
||
const editCalNum = $derived(Number(editCalories) || 0);
|
||
const tdeeBarData = $derived.by(() => {
|
||
const tdee = Math.round(editTdee);
|
||
const target = editCalNum;
|
||
if (!tdee || !target) return null;
|
||
const diff = target - tdee;
|
||
const pctOfTdee = Math.round((diff / tdee) * 100);
|
||
let zone = 'maintenance';
|
||
if (diff < -50) zone = 'deficit';
|
||
else if (diff > 50) zone = 'surplus';
|
||
// Target as percentage of TDEE (TDEE = 100% reference)
|
||
const targetPct = Math.min((target / tdee) * 100, 150);
|
||
return { tdee, target, diff, pctOfTdee, zone, targetPct };
|
||
});
|
||
|
||
// TDEE (excl. exercise) prorated to current time of day (for today only)
|
||
const tdeeSoFar = $derived.by(() => {
|
||
if (currentDate !== todayStr) return Math.round(dailyTdee);
|
||
const now = new Date();
|
||
const fraction = (now.getHours() * 60 + now.getMinutes()) / 1440;
|
||
return Math.round(dailyTdee * fraction);
|
||
});
|
||
|
||
|
||
|
||
// Net calorie balance: goal + burned - eaten
|
||
const calorieBalance = $derived(goalCalories ? (goalCalories + (exerciseKcal || 0) - dayTotals.calories) : 0);
|
||
const calorieProgressRaw = $derived(goalCalories ? dayTotals.calories / (goalCalories + (exerciseKcal || 0)) * 100 : 0);
|
||
const calorieProgress = $derived(Math.min(calorieProgressRaw, 100));
|
||
const calorieOverflow = $derived(Math.max(calorieProgressRaw - 100, 0));
|
||
|
||
// DRI for micros
|
||
const dri = $derived(getDRI(goalSex));
|
||
|
||
// 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 - (Math.min(percent, 100) / 100) * ARC_LENGTH;
|
||
}
|
||
|
||
/** Stroke offset for overflow arc drawn from the end backwards */
|
||
function overflowOffset(overflowPct) {
|
||
return ARC_LENGTH - (Math.min(overflowPct, 100) / 100) * ARC_LENGTH;
|
||
}
|
||
|
||
// --- Inline add food ---
|
||
let addingMeal = $state(null);
|
||
let inlineTab = $state('search'); // 'search' | 'meals'
|
||
|
||
// --- FAB modal (route-based via ?add param) ---
|
||
const showFabModal = $derived($page.url.searchParams.has('add'));
|
||
let fabMealType = $state('lunch');
|
||
|
||
const fabHref = $derived(`/fitness/${s.nutrition}?add`);
|
||
|
||
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';
|
||
}
|
||
|
||
// Reset modal state when it opens
|
||
$effect(() => {
|
||
if (showFabModal) {
|
||
fabMealType = defaultMealType();
|
||
fabTab = 'search';
|
||
loadCustomMeals();
|
||
}
|
||
});
|
||
|
||
function closeFabModal() {
|
||
goto(`/fitness/${s.nutrition}`, { replaceState: true, keepFocus: true, noScroll: true });
|
||
}
|
||
|
||
async function fabLogFood(food) {
|
||
try {
|
||
const res = await fetch('/api/fitness/food-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
date: currentDate,
|
||
mealType: fabMealType,
|
||
name: food.name,
|
||
source: food.source,
|
||
sourceId: food.sourceId,
|
||
amountGrams: food.amountGrams,
|
||
per100g: food.per100g,
|
||
})
|
||
});
|
||
if (res.ok) {
|
||
const entry = await res.json();
|
||
entries = [...entries, entry];
|
||
closeFabModal();
|
||
}
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to log food' : 'Fehler beim Eintragen');
|
||
}
|
||
}
|
||
|
||
// --- Custom meals in FAB ---
|
||
let fabTab = $state('search'); // 'search' | 'meals'
|
||
let customMeals = $state([]);
|
||
let customMealsLoaded = $state(false);
|
||
|
||
async function loadCustomMeals() {
|
||
if (customMealsLoaded) return;
|
||
try {
|
||
const res = await fetch('/api/fitness/custom-meals');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
customMeals = data.meals ?? [];
|
||
}
|
||
} catch {}
|
||
customMealsLoaded = true;
|
||
}
|
||
|
||
function mealTotalCal(meal) {
|
||
return meal.ingredients.reduce((sum, ing) => sum + (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, 0);
|
||
}
|
||
|
||
async function logCustomMeal(meal) {
|
||
try {
|
||
// Aggregate all ingredients into a single per100g snapshot
|
||
const totals = {};
|
||
const nutrientKeys = ['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',
|
||
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine', 'threonine',
|
||
'tryptophan', 'valine', 'histidine', 'alanine', 'arginine', 'asparticAcid',
|
||
'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||
for (const k of nutrientKeys) totals[k] = 0;
|
||
let totalGrams = 0;
|
||
for (const ing of meal.ingredients) {
|
||
const r = ing.amountGrams / 100;
|
||
totalGrams += ing.amountGrams;
|
||
for (const k of nutrientKeys) totals[k] += (ing.per100g?.[k] ?? 0) * r;
|
||
}
|
||
// Convert absolute totals back to per-100g
|
||
const per100g = {};
|
||
const scale = totalGrams > 0 ? 100 / totalGrams : 0;
|
||
for (const k of nutrientKeys) per100g[k] = totals[k] * scale;
|
||
|
||
const liquidMl = meal.ingredients
|
||
.filter(isLiquidIngredient)
|
||
.reduce((sum, ing) => sum + ing.amountGrams, 0);
|
||
|
||
await fetch('/api/fitness/food-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
date: currentDate,
|
||
mealType: fabMealType,
|
||
name: meal.name,
|
||
source: 'custom',
|
||
sourceId: meal._id,
|
||
amountGrams: totalGrams,
|
||
per100g,
|
||
...(liquidMl > 0 && { liquidMl }),
|
||
})
|
||
});
|
||
|
||
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
|
||
closeFabModal();
|
||
toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`);
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to log meal' : 'Fehler beim Eintragen');
|
||
}
|
||
}
|
||
|
||
function startAdd(meal) {
|
||
addingMeal = meal;
|
||
inlineTab = 'search';
|
||
loadCustomMeals();
|
||
}
|
||
|
||
function cancelAdd() {
|
||
addingMeal = null;
|
||
}
|
||
|
||
async function inlineLogCustomMeal(meal) {
|
||
if (!addingMeal) return;
|
||
try {
|
||
const totals = {};
|
||
const nutrientKeys = ['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',
|
||
'isoleucine', 'leucine', 'lysine', 'methionine', 'phenylalanine', 'threonine',
|
||
'tryptophan', 'valine', 'histidine', 'alanine', 'arginine', 'asparticAcid',
|
||
'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||
for (const k of nutrientKeys) totals[k] = 0;
|
||
let totalGrams = 0;
|
||
for (const ing of meal.ingredients) {
|
||
const r = ing.amountGrams / 100;
|
||
totalGrams += ing.amountGrams;
|
||
for (const k of nutrientKeys) totals[k] += (ing.per100g?.[k] ?? 0) * r;
|
||
}
|
||
const per100g = {};
|
||
const scale = totalGrams > 0 ? 100 / totalGrams : 0;
|
||
for (const k of nutrientKeys) per100g[k] = totals[k] * scale;
|
||
|
||
const liquidMl = meal.ingredients
|
||
.filter(isLiquidIngredient)
|
||
.reduce((sum, ing) => sum + ing.amountGrams, 0);
|
||
|
||
await fetch('/api/fitness/food-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
date: currentDate,
|
||
mealType: addingMeal,
|
||
name: meal.name,
|
||
source: 'custom',
|
||
sourceId: meal._id,
|
||
amountGrams: totalGrams,
|
||
per100g,
|
||
...(liquidMl > 0 && { liquidMl }),
|
||
})
|
||
});
|
||
|
||
await goto(`/fitness/${s.nutrition}?date=${currentDate}`, { replaceState: true, noScroll: true });
|
||
cancelAdd();
|
||
toast.success(isEn ? `Logged "${meal.name}"` : `"${meal.name}" eingetragen`);
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to log meal' : 'Fehler beim Eintragen');
|
||
}
|
||
}
|
||
|
||
async function inlineLogFood(food) {
|
||
try {
|
||
const res = await fetch('/api/fitness/food-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
date: currentDate,
|
||
mealType: addingMeal,
|
||
name: food.name,
|
||
source: food.source,
|
||
sourceId: food.sourceId,
|
||
amountGrams: food.amountGrams,
|
||
per100g: food.per100g,
|
||
})
|
||
});
|
||
if (res.ok) {
|
||
const entry = await res.json();
|
||
entries = [...entries, entry];
|
||
cancelAdd();
|
||
}
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to log food' : 'Fehler beim Eintragen');
|
||
}
|
||
}
|
||
|
||
/** @type {string|null} */
|
||
let editingEntryId = $state(null);
|
||
let editingGrams = $state(0);
|
||
|
||
function startEditEntry(entry) {
|
||
editingEntryId = entry._id;
|
||
editingGrams = entry.amountGrams;
|
||
}
|
||
|
||
async function saveEditEntry() {
|
||
if (!editingEntryId || editingGrams <= 0) return;
|
||
try {
|
||
const res = await fetch(`/api/fitness/food-log/${editingEntryId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ amountGrams: editingGrams }),
|
||
});
|
||
if (res.ok) {
|
||
const updated = await res.json();
|
||
entries = entries.map(e => e._id === editingEntryId ? updated : e);
|
||
}
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to update' : 'Fehler beim Aktualisieren');
|
||
}
|
||
editingEntryId = null;
|
||
}
|
||
|
||
async function deleteEntry(id) {
|
||
if (!await confirm(t('delete_entry_confirm', lang))) return;
|
||
try {
|
||
const res = await fetch(`/api/fitness/food-log/${id}`, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
entries = entries.filter(e => e._id !== id);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// --- Micro details ---
|
||
let showMicros = $state(false);
|
||
let showTdeeInfo = $state(false);
|
||
|
||
// WHO essential amino acid requirements (mg/kg/day for adults)
|
||
const AMINO_DRI_PER_KG = {
|
||
histidine: 10, isoleucine: 20, leucine: 39, lysine: 30,
|
||
methionine: 15, phenylalanine: 25, threonine: 15, tryptophan: 4, valine: 26
|
||
};
|
||
|
||
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 microSections = $derived.by(() => {
|
||
const minerals = ['calcium', 'iron', 'magnesium', 'phosphorus', 'potassium', 'sodium', 'zinc'];
|
||
const vitamins = ['vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', 'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate'];
|
||
const other = ['cholesterol'];
|
||
|
||
function mkRows(keys) {
|
||
return keys.map(k => {
|
||
const meta = NUTRIENT_META[k];
|
||
const value = dayTotals.micros[k] ?? 0;
|
||
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 };
|
||
});
|
||
}
|
||
|
||
// Amino acids: essential first (with DRI), then non-essential
|
||
const essentialOrder = ['leucine', 'isoleucine', 'valine', 'lysine', 'methionine', 'phenylalanine', 'threonine', 'tryptophan', 'histidine'];
|
||
const nonEssentialOrder = ['alanine', 'arginine', 'asparticAcid', 'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
|
||
const w = latestWeight ?? 70;
|
||
const aminoRows = [...essentialOrder, ...nonEssentialOrder].map(k => {
|
||
const value = dayTotals.aminos[k] ?? 0;
|
||
// WHO DRI is mg/kg/day; value is in grams → convert goal to grams
|
||
const driPerKg = AMINO_DRI_PER_KG[k];
|
||
const goal = driPerKg ? (driPerKg * w) / 1000 : 0;
|
||
const pct = goal > 0 ? Math.round(value / goal * 100) : 0;
|
||
const meta = AMINO_META[k];
|
||
return { key: k, label: isEn ? meta.en : meta.de, unit: 'g', value, goal, pct, isMax: false };
|
||
});
|
||
|
||
return [
|
||
{ title: isEn ? 'Minerals' : 'Mineralstoffe', rows: mkRows(minerals) },
|
||
{ title: isEn ? 'Vitamins' : 'Vitamine', rows: mkRows(vitamins) },
|
||
{ title: isEn ? 'Amino Acids' : 'Aminosäuren', rows: aminoRows },
|
||
{ title: isEn ? 'Other' : 'Sonstiges', rows: mkRows(other) },
|
||
];
|
||
});
|
||
|
||
function fmt(v) {
|
||
if (v >= 100) return Math.round(v).toString();
|
||
if (v >= 10) return v.toFixed(1);
|
||
return v.toFixed(1);
|
||
}
|
||
|
||
function fmtCal(v) {
|
||
return Math.round(v).toString();
|
||
}
|
||
|
||
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)' },
|
||
};
|
||
|
||
// --- Quick-log sidebar ---
|
||
let quickLogMealType = $state(defaultMealType());
|
||
// svelte-ignore state_referenced_locally
|
||
let quickFavorites = $state(data.favorites ?? []);
|
||
// svelte-ignore state_referenced_locally
|
||
let recentFoods = $state(data.recentFoods ?? []);
|
||
|
||
$effect(() => {
|
||
quickFavorites = data.favorites ?? [];
|
||
recentFoods = data.recentFoods ?? [];
|
||
});
|
||
|
||
/** @type {{ name: string, source: string, sourceId: string, per100g?: any, amountGrams?: number } | null} */
|
||
let qlSelected = $state(null);
|
||
let qlGrams = $state(100);
|
||
let qlLoading = $state(false);
|
||
|
||
async function qlSelect(item) {
|
||
if (qlSelected && qlSelected.source === item.source && qlSelected.sourceId === item.sourceId) {
|
||
qlSelected = null;
|
||
return;
|
||
}
|
||
qlGrams = item.amountGrams ?? 100;
|
||
if (item.per100g) {
|
||
qlSelected = item;
|
||
} else {
|
||
// Favorites don't have per100g — fetch by exact source+id
|
||
qlLoading = true;
|
||
try {
|
||
const res = await fetch(`/api/nutrition/lookup?source=${item.source}&id=${encodeURIComponent(item.sourceId)}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data.per100g) {
|
||
qlSelected = { ...item, per100g: data.per100g };
|
||
} else {
|
||
toast.error(isEn ? 'Could not load food data' : 'Lebensmitteldaten nicht gefunden');
|
||
}
|
||
}
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to load food data' : 'Fehler beim Laden');
|
||
}
|
||
qlLoading = false;
|
||
}
|
||
}
|
||
|
||
async function qlConfirm() {
|
||
if (!qlSelected?.per100g) return;
|
||
try {
|
||
const res = await fetch('/api/fitness/food-log', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
date: currentDate,
|
||
mealType: quickLogMealType,
|
||
name: qlSelected.name,
|
||
source: qlSelected.source,
|
||
sourceId: qlSelected.sourceId,
|
||
amountGrams: qlGrams,
|
||
per100g: qlSelected.per100g,
|
||
})
|
||
});
|
||
if (res.ok) {
|
||
const entry = await res.json();
|
||
entries = [...entries, entry];
|
||
toast.success(isEn ? `Logged "${qlSelected.name}"` : `"${qlSelected.name}" eingetragen`);
|
||
qlSelected = null;
|
||
}
|
||
} catch {
|
||
toast.error(isEn ? 'Failed to log food' : 'Fehler beim Eintragen');
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{t('nutrition_title', lang)} — Fitness</title>
|
||
</svelte:head>
|
||
|
||
{#snippet microPanel()}
|
||
<div class="micro-details" class:micro-hidden={!showMicros}>
|
||
{#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>
|
||
{/snippet}
|
||
|
||
<div class="nutrition-page">
|
||
<!-- Date Navigator -->
|
||
<div class="date-nav">
|
||
<button class="date-btn" onclick={() => navigateDate(-1)} aria-label="Previous day">
|
||
<ChevronLeft size={20} />
|
||
</button>
|
||
<button class="date-display" onclick={goToday} class:is-today={isToday}>
|
||
{displayDate}
|
||
{#if isToday}<span class="today-badge">{t('today', lang)}</span>{/if}
|
||
</button>
|
||
<button class="date-btn" onclick={() => navigateDate(1)} aria-label="Next day">
|
||
<ChevronRight size={20} />
|
||
</button>
|
||
{#if !isToday}
|
||
<button class="go-today-btn" onclick={goToday}>{t('today', lang)}</button>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="sidebar-col">
|
||
<!-- Daily Summary -->
|
||
{#if goalCalories}
|
||
<div class="daily-summary">
|
||
<!-- Eaten / Ring / Burned row -->
|
||
<div class="calorie-trio">
|
||
<div class="cal-stat eaten">
|
||
<span class="cal-stat-value">{fmtCal(dayTotals.calories)}</span>
|
||
<span class="cal-stat-label">{isEn ? 'EATEN' : 'GEGESSEN'}</span>
|
||
</div>
|
||
|
||
<div class="calorie-ring-wrap">
|
||
<svg class="calorie-ring" width="130" height="130" 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)" />
|
||
{#if calorieOverflow > 0}
|
||
<circle class="ring-glow ring-calories-glow" cx="35" cy="35" r={RADIUS}
|
||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||
style="--glow-target: {strokeOffset(Math.max(calorieProgress - calorieOverflow, 0))}; --glow-start: {strokeOffset(100)}"
|
||
transform="rotate({ARC_ROTATE} 35 35)" />
|
||
{/if}
|
||
<circle class="ring-fill ring-calories{calorieOverflow > 0 ? ' no-glow' : ''}" cx="35" cy="35" r={RADIUS}
|
||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||
stroke-dashoffset={strokeOffset(calorieProgress)}
|
||
transform="rotate({ARC_ROTATE} 35 35)" />
|
||
{#if calorieOverflow > 0}
|
||
<circle class="ring-fill ring-overflow" cx="35" cy="35" r={RADIUS}
|
||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||
style="--overflow-target: {overflowOffset(calorieOverflow)}; --arc-length: {ARC_LENGTH}"
|
||
stroke-linecap="butt"
|
||
transform="translate(70, 0) scale(-1, 1) rotate({ARC_ROTATE} 35 35)" />
|
||
{/if}
|
||
<text class="ring-text-main" x="35" y="30">{fmtCal(Math.abs(calorieBalance))}</text>
|
||
<text class="ring-text-sub" x="35" y="42">{calorieBalance >= 0 ? (isEn ? 'KCAL LEFT' : 'KCAL ÜBRIG') : (isEn ? 'KCAL OVER' : 'KCAL ÜBER')}</text>
|
||
</svg>
|
||
</div>
|
||
|
||
<div class="cal-stat burned">
|
||
<span class="cal-stat-value">{fmtCal(exerciseKcal)}</span>
|
||
<span class="cal-stat-label">{isEn ? 'BURNED' : 'VERBRANNT'}</span>
|
||
<span class="burned-bmr tdee-info-wrap">
|
||
+{fmtCal(tdeeSoFar)} TDEE<button class="tdee-info-trigger" onclick={() => showTdeeInfo = !showTdeeInfo} aria-label="TDEE info"><Info size={10} /></button>
|
||
{#if showTdeeInfo}
|
||
<div class="tdee-info-tooltip">
|
||
<span class="cite-note" style="margin-bottom: 0.2rem">{isEn ? 'TDEE = Total Daily Energy Expenditure — calories your body burns per day at rest + daily activity.' : 'TDEE = Gesamtenergieumsatz — Kalorien, die dein Körper pro Tag in Ruhe + Alltagsaktivität verbrennt.'}</span>
|
||
{#if latestWeight}
|
||
<span class="cite-note" style="margin-bottom: 0.2rem">{isEn ? `Based on your latest logged weight (${latestWeight} kg).` : `Basierend auf deinem letzten Gewicht (${latestWeight} kg).`}</span>
|
||
{/if}
|
||
<span class="cite-line"><strong>BMR:</strong> <a href="https://doi.org/10.1093/ajcn/51.2.241" target="_blank" rel="noopener">Mifflin-St Jeor (1990)</a></span>
|
||
<span class="cite-line"><strong>NEAT:</strong> <a href="https://doi.org/10.1093/ajcn/75.5.914" target="_blank" rel="noopener">Levine (2002)</a></span>
|
||
<span class="cite-note">{isEn ? 'Multipliers reduced vs. standard Harris-Benedict factors — logged exercise kcal added separately.' : 'Multiplikatoren reduziert ggü. Harris-Benedict — geloggte Trainings-kcal werden separat addiert.'}</span>
|
||
</div>
|
||
{/if}
|
||
</span>
|
||
{#if !hasBmrData}
|
||
<div class="bmr-hint">{isEn ? 'Set profile in' : 'Profil unter'} <a href="/fitness/{s.measure}">{t('measure_title', lang)}</a></div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Macro progress bars -->
|
||
<div class="macro-bars">
|
||
{#each [
|
||
{ value: dayTotals.protein, goal: proteinGoalGrams, label: t('protein', lang), color: 'var(--nord14)' },
|
||
{ value: dayTotals.fat, goal: fatGoalGrams, label: t('fat', lang), color: 'var(--nord12)' },
|
||
{ value: dayTotals.carbs, goal: carbGoalGrams, label: t('carbs', lang), color: 'var(--nord9)' },
|
||
] as macro}
|
||
{@const pct = macro.goal ? macro.value / macro.goal * 100 : 0}
|
||
{@const over = pct > 100}
|
||
{@const overPct = Math.min(pct - 100, 100)}
|
||
{@const remaining = macro.goal ? macro.goal - macro.value : 0}
|
||
<div class="macro-bar-item">
|
||
<span class="macro-bar-label">{macro.label}</span>
|
||
<div class="macro-bar-track">
|
||
<div class="macro-bar-fill" style="width: {Math.min(pct, 100)}%; background: {macro.color}"></div>
|
||
{#if over}
|
||
<div class="macro-bar-overflow" style="width: {overPct}%"></div>
|
||
{/if}
|
||
</div>
|
||
{#if macro.goal}
|
||
<span class="macro-bar-info" class:over>
|
||
{remaining >= 0 ? `${fmt(remaining)}g ${t('remaining', lang)}` : `${fmt(-remaining)}g ${t('over', lang)}`}
|
||
</span>
|
||
{:else}
|
||
<span class="macro-bar-info">{fmt(macro.value)}g</span>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Micros inline (mobile) -->
|
||
<div class="micro-inline">
|
||
<div class="details-toggle-row">
|
||
<button class="details-toggle" onclick={() => showMicros = !showMicros}>
|
||
<ChevronDown size={14} style={showMicros ? 'transform: rotate(180deg)' : ''} />
|
||
{t('micro_details', lang)}
|
||
</button>
|
||
</div>
|
||
{@render microPanel()}
|
||
</div>
|
||
|
||
</div>
|
||
{:else}
|
||
<div class="no-goal">
|
||
<div class="no-goal-icon"><Utensils size={32} /></div>
|
||
<p>{t('set_goal_prompt', lang)}</p>
|
||
<button class="btn-primary" onclick={openGoalEditor}>{t('set_goal', lang)}</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Goal editor toggle -->
|
||
{#if goalCalories && !showGoalEditor}
|
||
<button class="goal-edit-btn" onclick={openGoalEditor}>
|
||
<Settings size={14} />
|
||
{t('daily_goal', lang)}
|
||
</button>
|
||
{/if}
|
||
|
||
<!-- Goal Editor (Stepped Wizard) -->
|
||
{#if showGoalEditor}
|
||
<div class="goal-editor">
|
||
<h3>{t('daily_goal', lang)}</h3>
|
||
|
||
<!-- Step indicators -->
|
||
<div class="wizard-steps">
|
||
<button class="wizard-step" class:active={goalStep === 1} class:done={goalStep > 1} onclick={() => goalStep = 1}>
|
||
<span class="ws-num">{goalStep > 1 ? '' : '1'}{#if goalStep > 1}<Check size={12} />{/if}</span>
|
||
<span class="ws-label">{isEn ? 'Plan' : 'Plan'}</span>
|
||
</button>
|
||
<div class="ws-line" class:done={goalStep > 1}></div>
|
||
<button class="wizard-step" class:active={goalStep === 2} class:done={goalStep > 2} onclick={() => goalStep = 2}>
|
||
<span class="ws-num">{goalStep > 2 ? '' : '2'}{#if goalStep > 2}<Check size={12} />{/if}</span>
|
||
<span class="ws-label">{isEn ? 'Calories' : 'Kalorien'}</span>
|
||
</button>
|
||
<div class="ws-line" class:done={goalStep > 2}></div>
|
||
<button class="wizard-step" class:active={goalStep === 3} onclick={() => goalStep = 3}>
|
||
<span class="ws-num">3</span>
|
||
<span class="ws-label">{isEn ? 'Macros' : 'Makros'}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Step 1: Diet Presets -->
|
||
{#if goalStep === 1}
|
||
<div class="preset-section">
|
||
<div class="preset-grid">
|
||
{#each dietPresets as preset, i}
|
||
<button
|
||
class="preset-card"
|
||
class:selected={selectedPresetIdx === i}
|
||
type="button"
|
||
onclick={() => applyPreset(preset, i)}
|
||
>
|
||
<span class="preset-card-emoji">{preset.emoji}</span>
|
||
<div class="preset-card-text">
|
||
<span class="preset-card-name">{isEn ? preset.en : preset.de}</span>
|
||
<span class="preset-card-desc">{isEn ? preset.descEn : preset.descDe}</span>
|
||
</div>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
<button class="wizard-skip" type="button" onclick={() => goalStep = 2}>
|
||
{isEn ? 'Skip — set manually' : 'Überspringen — manuell einstellen'} →
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Step 2: Calories & Activity -->
|
||
{:else if goalStep === 2}
|
||
<div class="step-calories">
|
||
{#if selectedPresetIdx >= 0}
|
||
<button class="step-pill" type="button" onclick={() => goalStep = 1}>
|
||
{dietPresets[selectedPresetIdx].emoji} {stepPresetSummary}
|
||
</button>
|
||
{/if}
|
||
|
||
{#if !hasBmrData}
|
||
<div class="tdee-warning">
|
||
<AlertTriangle size={16} />
|
||
<div class="tdee-warning-text">
|
||
<strong>{isEn ? 'TDEE unavailable' : 'TDEE nicht verfügbar'}</strong>
|
||
<span>{isEn
|
||
? 'Your TDEE (Total Daily Energy Expenditure) is the calories you burn per day. Set weight, height, and birth year under'
|
||
: 'Dein TDEE (Gesamtenergieumsatz) sind die Kalorien, die du pro Tag verbrauchst. Gewicht, Größe und Geburtsjahr einstellen unter'}
|
||
<a href="/fitness/{s.measure}">{t('measure_title', lang)}</a>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="goal-field">
|
||
<label for="goal-calories">{t('calorie_target', lang)}</label>
|
||
<div class="calorie-input-row">
|
||
<input id="goal-calories" type="number" bind:value={editCalories} min="500" max="10000" />
|
||
{#if hasBmrData}
|
||
<button class="bmr-calc-btn" type="button" onclick={() => editCalories = String(Math.round(editTdee))}>
|
||
TDEE ({Math.round(editTdee)})
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{#if hasBmrData && latestWeight}
|
||
<span class="tdee-basis">{isEn ? `TDEE based on latest weight: ${latestWeight} kg` : `TDEE basierend auf letztem Gewicht: ${latestWeight} kg`}</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- TDEE Comparison Bar -->
|
||
{#if hasBmrData && tdeeBarData}
|
||
<div class="tdee-compare">
|
||
<div class="tdee-compare-bar-wrap">
|
||
<!-- TDEE = full width reference -->
|
||
<div class="tdee-compare-ref"></div>
|
||
<!-- Target overlaid -->
|
||
<div class="tdee-compare-target"
|
||
class:deficit={tdeeBarData.zone === 'deficit'}
|
||
class:surplus={tdeeBarData.zone === 'surplus'}
|
||
class:maintenance={tdeeBarData.zone === 'maintenance'}
|
||
style="width: {tdeeBarData.targetPct}%"
|
||
></div>
|
||
</div>
|
||
<div class="tdee-compare-labels">
|
||
<span class="tdee-compare-label">{isEn ? 'Target' : 'Ziel'}: <strong>{tdeeBarData.target}</strong> kcal</span>
|
||
<span class="tdee-compare-label">TDEE: <strong>{tdeeBarData.tdee}</strong> kcal</span>
|
||
</div>
|
||
<span class="tdee-compare-diff"
|
||
class:deficit={tdeeBarData.zone === 'deficit'}
|
||
class:surplus={tdeeBarData.zone === 'surplus'}
|
||
class:maintenance={tdeeBarData.zone === 'maintenance'}>
|
||
{tdeeBarData.zone === 'deficit' ? (isEn ? 'Deficit' : 'Defizit') : tdeeBarData.zone === 'surplus' ? (isEn ? 'Surplus' : 'Überschuss') : (isEn ? 'Maintenance' : 'Erhaltung')}:
|
||
{tdeeBarData.diff > 0 ? '+' : ''}{tdeeBarData.diff} kcal ({tdeeBarData.pctOfTdee > 0 ? '+' : ''}{tdeeBarData.pctOfTdee}%)
|
||
</span>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="goal-field">
|
||
<label for="goal-activity">{isEn ? 'Activity Level (excl. exercise)' : 'Aktivitätslevel (ohne Training)'}</label>
|
||
<select id="goal-activity" bind:value={editActivityLevel}>
|
||
<option value="sedentary">{isEn ? 'Sedentary — desk job' : 'Sitzend — Bürojob'} (×1.2)</option>
|
||
<option value="light">{isEn ? 'Lightly active — some walking' : 'Leicht aktiv — etwas Gehen'} (×1.3)</option>
|
||
<option value="moderate">{isEn ? 'Moderately active — on feet' : 'Mäßig aktiv — auf den Beinen'} (×1.4)</option>
|
||
<option value="very_active">{isEn ? 'Very active — physical job' : 'Sehr aktiv — körperliche Arbeit'} (×1.5)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="wizard-nav">
|
||
<button class="btn-secondary" type="button" onclick={() => goalStep = 1}>← {isEn ? 'Back' : 'Zurück'}</button>
|
||
<button class="btn-primary" type="button" onclick={() => goalStep = 3}>{isEn ? 'Next' : 'Weiter'} →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Step 3: Macros -->
|
||
{:else if goalStep === 3}
|
||
<div class="step-macros">
|
||
<!-- Summary pills for previous steps -->
|
||
<div class="step-pills">
|
||
<button class="step-pill" type="button" onclick={() => goalStep = 1}>
|
||
{#if selectedPresetIdx >= 0}{dietPresets[selectedPresetIdx].emoji}{/if} {stepPresetSummary}
|
||
</button>
|
||
<button class="step-pill" type="button" onclick={() => goalStep = 2}>{stepCalSummary}</button>
|
||
</div>
|
||
|
||
<!-- Macro donut ring -->
|
||
{#if editCalNum > 0}
|
||
<div class="macro-ring-wrap">
|
||
<svg class="macro-ring" width="120" height="120" viewBox="0 0 120 120">
|
||
<!-- Fat -->
|
||
<circle cx="60" cy="60" r={RING_R}
|
||
fill="none" stroke="var(--nord12)" stroke-width="10"
|
||
stroke-dasharray="{Math.max(0, editMacroRing.fatLen - RING_GAP)} {RING_C - Math.max(0, editMacroRing.fatLen - RING_GAP)}"
|
||
stroke-linecap="round"
|
||
transform="rotate(-90 60 60)"
|
||
opacity={editMacroRing.fat > 0 ? 1 : 0} />
|
||
<!-- Carbs -->
|
||
<circle cx="60" cy="60" r={RING_R}
|
||
fill="none" stroke="var(--nord9)" stroke-width="10"
|
||
stroke-dasharray="{Math.max(0, editMacroRing.carbLen - RING_GAP)} {RING_C - Math.max(0, editMacroRing.carbLen - RING_GAP)}"
|
||
stroke-linecap="round"
|
||
transform="rotate({-90 + editMacroRing.fatDeg} 60 60)"
|
||
opacity={editMacroRing.carb > 0 ? 1 : 0} />
|
||
<!-- Protein -->
|
||
<circle cx="60" cy="60" r={RING_R}
|
||
fill="none" stroke="var(--nord14)" stroke-width="10"
|
||
stroke-dasharray="{Math.max(0, editMacroRing.protLen - RING_GAP)} {RING_C - Math.max(0, editMacroRing.protLen - RING_GAP)}"
|
||
stroke-linecap="round"
|
||
transform="rotate({-90 + editMacroRing.fatDeg + editMacroRing.carbDeg} 60 60)"
|
||
opacity={editMacroRing.prot > 0 ? 1 : 0} />
|
||
<text x="60" y="55" text-anchor="middle" class="ring-cal-main">{editCalNum}</text>
|
||
<text x="60" y="70" text-anchor="middle" class="ring-cal-sub">kcal</text>
|
||
</svg>
|
||
<div class="macro-ring-legend">
|
||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord14)"></span> {t('protein', lang)} {editMacroRing.prot}%</span>
|
||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord12)"></span> {t('fat', lang)} {editMacroRing.fat}%</span>
|
||
<span class="mrl-item"><span class="mrl-dot" style="background: var(--nord9)"></span> {t('carbs', lang)} {editMacroRing.carb}%</span>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="goal-field">
|
||
<label for="goal-protein-mode">{t('protein_goal', lang)}</label>
|
||
<div class="protein-mode">
|
||
<select id="goal-protein-mode" bind:value={editProteinMode}>
|
||
<option value="fixed">{t('protein_fixed', lang)}</option>
|
||
<option value="per_kg">{t('protein_per_kg', lang)}</option>
|
||
</select>
|
||
<input id="goal-protein-target" type="number" bind:value={editProteinTarget} min="0" step="0.1"
|
||
placeholder={editProteinMode === 'per_kg' ? 'g/kg' : 'g'} />
|
||
</div>
|
||
</div>
|
||
<div class="goal-row">
|
||
<div class="goal-field">
|
||
<label for="goal-fat">{t('fat_percent', lang)}</label>
|
||
<input id="goal-fat" type="number" bind:value={editFatPercent} min="0" max="100" />
|
||
</div>
|
||
<div class="goal-field">
|
||
<label for="goal-carbs">{t('carb_percent', lang)}</label>
|
||
<input id="goal-carbs" type="number" bind:value={editCarbPercent} min="0" max="100" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="wizard-nav">
|
||
<button class="btn-secondary" type="button" onclick={() => goalStep = 2}>← {isEn ? 'Back' : 'Zurück'}</button>
|
||
<div class="goal-actions-final">
|
||
<button class="btn-secondary" onclick={() => showGoalEditor = false}>{t('cancel', lang)}</button>
|
||
<button class="btn-primary" onclick={saveGoals} disabled={goalSaving}>
|
||
{goalSaving ? t('saving', lang) : t('save', lang)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Liquid Tracking Card -->
|
||
<div class="water-card">
|
||
<div class="water-header">
|
||
<div class="water-title">
|
||
<GlassWater size={16} />
|
||
<h3>{isEn ? 'Liquids' : 'Flüssigkeit'}</h3>
|
||
</div>
|
||
<div class="water-stats">
|
||
<span class="water-amount">{parseFloat((totalLiquidMl / 1000).toFixed(2))} L</span>
|
||
{#if editingGoal}
|
||
<form class="goal-edit-inline" onsubmit={e => { e.preventDefault(); saveGoal(); }}>
|
||
<span class="goal-slash">/</span>
|
||
<input type="number" class="goal-input-inline" bind:value={goalInputL} min="0.25" step="0.25" />
|
||
<span class="goal-unit">L</span>
|
||
<button type="submit" class="goal-save-inline"><Check size={12} /></button>
|
||
</form>
|
||
{:else}
|
||
<button class="water-goal-btn" onclick={() => { goalInputL = parseFloat((waterGoalMl / 1000).toFixed(2)); editingGoal = true; }}>
|
||
/ {parseFloat((waterGoalMl / 1000).toFixed(2))} L
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
<div class="water-cups">
|
||
{#each Array(displayCups) as _, i}
|
||
{@const isBev = i < beverageCups}
|
||
{@const isMealLiquid = !isBev && i < beverageCups + mealLiquidCups}
|
||
{@const isAuto = isBev || isMealLiquid}
|
||
{@const isFilled = i < totalCups}
|
||
{@const showWater = isFilled || drainingCups.has(i)}
|
||
{@const isNextEmpty = i === totalCups && !drainingCups.has(i)}
|
||
<button
|
||
class="water-cup"
|
||
class:filled={isFilled}
|
||
class:beverage={isBev}
|
||
class:meal-liquid={isMealLiquid}
|
||
class:filling={fillingCups.has(i)}
|
||
class:draining={drainingCups.has(i)}
|
||
class:next-empty={isNextEmpty}
|
||
disabled={isAuto}
|
||
onclick={() => {
|
||
if (isAuto) return;
|
||
const autoOffset = beverageCups + mealLiquidCups;
|
||
const waterTarget = i < totalCups ? i - autoOffset : i - autoOffset + 1;
|
||
setWaterCups(Math.max(0, waterTarget));
|
||
}}
|
||
title="{isAuto ? (isEn ? (isBev ? 'Beverage' : 'From meal') : (isBev ? 'Getränk' : 'Aus Mahlzeit')) : (i + 1) * WATER_CUP_ML + ' ml'}"
|
||
>
|
||
<svg viewBox="0 0 24 32" class="cup-svg" overflow="hidden">
|
||
<defs>
|
||
<clipPath id="cup-clip-{i}">
|
||
<path d="M4 4 L6 28 C6 30 8 30 8 30 L16 30 C16 30 18 30 18 28 L20 4 Z" />
|
||
</clipPath>
|
||
</defs>
|
||
<path d="M4 4 L6 28 C6 30 8 30 8 30 L16 30 C16 30 18 30 18 28 L20 4 Z" fill="var(--color-bg-tertiary)" stroke="var(--color-border)" stroke-width="1.2" />
|
||
{#if showWater}
|
||
<g clip-path="url(#cup-clip-{i})" class="water-body">
|
||
<path class="water-wave w1" d="M-8 10 Q-2 6 4 10 T16 10 T28 10 T40 10 V34 H-8 Z" fill={isAuto ? 'var(--nord7)' : 'var(--nord10)'} opacity="0.85" />
|
||
<path class="water-wave w2" d="M-4 12 Q2 8 8 12 T20 12 T32 12 V34 H-4 Z" fill={isAuto ? 'var(--nord8)' : 'var(--nord9)'} opacity="0.5" />
|
||
<path class="water-wave w3" d="M0 11 Q6 7 12 11 T24 11 T36 11 V34 H0 Z" fill={isAuto ? 'var(--nord7)' : 'var(--nord10)'} opacity="0.35" />
|
||
</g>
|
||
{/if}
|
||
{#if isNextEmpty}
|
||
<text x="12" y="20" text-anchor="middle" font-size="14" font-weight="bold" fill="var(--color-text-secondary)">+</text>
|
||
{/if}
|
||
</svg>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
{#if beverageEntries.length > 0 || mealLiquidMl > 0}
|
||
<div class="beverage-list">
|
||
{#each beverageEntries as bev}
|
||
<div class="beverage-item">
|
||
<span class="beverage-name">{bev.name}</span>
|
||
<span class="beverage-ml">{Math.round(bev.amountGrams)} ml</span>
|
||
</div>
|
||
{/each}
|
||
{#each entries.filter(e => (e.liquidMl ?? 0) > 0) as e}
|
||
<div class="beverage-item">
|
||
<span class="beverage-name">{e.name}</span>
|
||
<span class="beverage-ml">{Math.round(e.liquidMl)} ml</span>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if goalCalories}
|
||
<!-- Micros card (desktop) -->
|
||
<div class="micro-card">
|
||
<h3 class="micro-card-title">{t('micro_details', lang)}</h3>
|
||
{@render microPanel()}
|
||
</div>
|
||
{/if}
|
||
|
||
</div>
|
||
|
||
<!-- Meal Sections -->
|
||
<div class="meals-col">
|
||
{#each mealTypes as meal, mi}
|
||
{@const mealEntries = grouped[meal]}
|
||
{@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)}
|
||
{@const meta = mealMeta[meal]}
|
||
{@const MealSectionIcon = meta.icon}
|
||
<div class="meal-section" style="--meal-color: {meta.color}">
|
||
<div class="meal-header">
|
||
<div class="meal-title">
|
||
<div class="meal-icon">
|
||
<MealSectionIcon size={15} />
|
||
</div>
|
||
<h3>{t(meal, lang)}</h3>
|
||
</div>
|
||
{#if mealEntries.length > 0}
|
||
<span class="meal-cal">{fmtCal(mealCal)} {t('kcal', lang)}</span>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="meal-entries">
|
||
{#each mealEntries as entry}
|
||
{@const imgUrl = entry.source === 'recipe' && entry.sourceId ? recipeImages[entry.sourceId] : null}
|
||
<div class="food-card" class:has-image={!!imgUrl}>
|
||
{#if imgUrl}
|
||
<img class="food-card-img" src={imgUrl} alt={entry.name} loading="lazy" />
|
||
{:else}
|
||
<div class="food-card-accent" style="background: var(--meal-color)"></div>
|
||
{/if}
|
||
<div class="food-card-body">
|
||
{#if entry.source === 'bls' || entry.source === 'usda'}
|
||
<a class="food-card-name food-card-link" href="/fitness/{s.nutrition}/food/{entry.source}/{entry.sourceId}">{entry.name}</a>
|
||
{:else}
|
||
<span class="food-card-name">{entry.name}</span>
|
||
{/if}
|
||
{#if editingEntryId === entry._id}
|
||
<form class="food-card-edit-form" onsubmit={e => { e.preventDefault(); saveEditEntry(); }}>
|
||
<input type="number" class="food-card-edit-input" bind:value={editingGrams} min="1" step="1" />
|
||
<span class="food-card-edit-unit">g</span>
|
||
</form>
|
||
{:else}
|
||
<span class="food-card-detail">{entry.amountGrams}g · {fmtCal(entryCalories(entry))} kcal</span>
|
||
{/if}
|
||
<span class="food-card-macros">{fmt(entryNutrient(entry, 'protein'))}g P · {fmt(entryNutrient(entry, 'fat'))}g F · {fmt(entryNutrient(entry, 'carbs'))}g C</span>
|
||
</div>
|
||
<div class="food-card-actions">
|
||
<button class="food-card-action edit" class:active={editingEntryId === entry._id} onclick={() => {
|
||
if (editingEntryId === entry._id) { saveEditEntry(); } else { startEditEntry(entry); }
|
||
}} aria-label="Edit">
|
||
{#if editingEntryId === entry._id}
|
||
<Check size={14} />
|
||
{:else}
|
||
<Pencil size={14} />
|
||
{/if}
|
||
</button>
|
||
<button class="food-card-action delete" onclick={() => deleteEntry(entry._id)} aria-label={t('delete_', lang)}>
|
||
<Trash2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
{#if addingMeal === meal}
|
||
<div class="add-food-form">
|
||
<div class="add-food-form-header">
|
||
<div class="fab-tabs">
|
||
<button class="fab-tab" class:active={inlineTab === 'search'} onclick={() => inlineTab = 'search'}>
|
||
{t('search_food', lang).replace('…', '')}
|
||
</button>
|
||
<button class="fab-tab" class:active={inlineTab === 'meals'} onclick={() => { inlineTab = 'meals'; loadCustomMeals(); }}>
|
||
<UtensilsCrossed size={13} />
|
||
{t('custom_meals', lang)}
|
||
</button>
|
||
</div>
|
||
<button class="fab-close" onclick={cancelAdd}><Plus size={18} style="transform: rotate(45deg)" /></button>
|
||
</div>
|
||
|
||
{#if inlineTab === 'search'}
|
||
<FoodSearch onselect={inlineLogFood} showDetailLinks={false} autofocus={true} />
|
||
{:else}
|
||
<div class="custom-meals-list">
|
||
{#if customMeals.length === 0}
|
||
<p class="meals-empty">{t('no_custom_meals', lang)}</p>
|
||
{/if}
|
||
{#each customMeals as cm}
|
||
<div class="custom-meal-card">
|
||
<div class="custom-meal-info">
|
||
<span class="custom-meal-name">{cm.name}</span>
|
||
<span class="custom-meal-detail">{cm.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(cm))} kcal</span>
|
||
</div>
|
||
<button class="btn-primary btn-sm" onclick={() => inlineLogCustomMeal(cm)}>{t('log_meal', lang)}</button>
|
||
</div>
|
||
{/each}
|
||
<a class="manage-meals-link" href="/fitness/{s.nutrition}/meals">
|
||
<Settings size={13} />
|
||
{isEn ? 'Manage meals' : 'Mahlzeiten verwalten'}
|
||
</a>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<button class="add-food-btn" onclick={() => startAdd(meal)}>
|
||
<Plus size={14} />
|
||
{t('add_food', lang)}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Quick-log sidebar (desktop) -->
|
||
<aside class="quick-log-col">
|
||
<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}
|
||
</div>
|
||
|
||
{#if quickFavorites.length > 0}
|
||
<div class="ql-section">
|
||
<h4 class="ql-section-title"><Heart size={12} /> {isEn ? 'Favorites' : 'Favoriten'}</h4>
|
||
{#each quickFavorites as fav}
|
||
{@const isActive = qlSelected?.source === fav.source && qlSelected?.sourceId === fav.sourceId}
|
||
<button class="ql-item" class:active={isActive} onclick={() => qlSelect(fav)}>
|
||
<span class="ql-item-name">{fav.name}</span>
|
||
<Plus size={14} class="ql-item-add" />
|
||
</button>
|
||
{#if isActive}
|
||
<form class="ql-amount-row" onsubmit={e => { e.preventDefault(); qlConfirm(); }}>
|
||
<input type="number" class="ql-amount-input" bind:value={qlGrams} min="1" step="1" />
|
||
<span class="ql-amount-unit">g</span>
|
||
<button type="submit" class="ql-amount-confirm"><Check size={14} /></button>
|
||
</form>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if recentFoods.length > 0}
|
||
<div class="ql-section">
|
||
<h4 class="ql-section-title"><Clock size={12} /> {isEn ? 'Recent' : 'Kürzlich'}</h4>
|
||
{#each recentFoods as item}
|
||
{@const isActive = qlSelected?.source === item.source && qlSelected?.sourceId === item.sourceId}
|
||
<button class="ql-item" class:active={isActive} onclick={() => qlSelect(item)}>
|
||
<span class="ql-item-name">{item.name}</span>
|
||
<Plus size={14} class="ql-item-add" />
|
||
</button>
|
||
{#if isActive}
|
||
<form class="ql-amount-row" onsubmit={e => { e.preventDefault(); qlConfirm(); }}>
|
||
<input type="number" class="ql-amount-input" bind:value={qlGrams} min="1" step="1" />
|
||
<span class="ql-amount-unit">g</span>
|
||
<button type="submit" class="ql-amount-confirm"><Check size={14} /></button>
|
||
</form>
|
||
{/if}
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if quickFavorites.length === 0 && recentFoods.length === 0}
|
||
<p class="ql-empty">{isEn ? 'No favorites yet. Star foods in search to see them here.' : 'Noch keine Favoriten. Markiere Lebensmittel in der Suche.'}</p>
|
||
{/if}
|
||
</div>
|
||
</aside>
|
||
|
||
</div>
|
||
|
||
<!-- FAB -->
|
||
<AddButton href={fabHref} />
|
||
|
||
<!-- FAB Modal -->
|
||
{#if showFabModal}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div class="fab-overlay" onclick={closeFabModal} onkeydown={(e) => e.key === 'Escape' && closeFabModal()}>
|
||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||
<div class="fab-modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||
<div class="fab-modal-header">
|
||
<h3>{t('add_food', lang)}</h3>
|
||
<button class="fab-close" onclick={closeFabModal}><Plus size={18} style="transform: rotate(45deg)" /></button>
|
||
</div>
|
||
|
||
<!-- Meal type selector -->
|
||
<div class="fab-meal-select">
|
||
{#each mealTypes as meal}
|
||
{@const meta = mealMeta[meal]}
|
||
{@const MealIcon = meta.icon}
|
||
<button
|
||
class="fab-meal-btn"
|
||
class:active={fabMealType === meal}
|
||
style="--mc: {meta.color}"
|
||
onclick={() => fabMealType = meal}
|
||
>
|
||
<MealIcon size={14} />
|
||
<span>{t(meal, lang)}</span>
|
||
</button>
|
||
{/each}
|
||
</div>
|
||
|
||
<!-- Tabs: Search / Custom Meals -->
|
||
<div class="fab-tabs">
|
||
<button class="fab-tab" class:active={fabTab === 'search'} onclick={() => fabTab = 'search'}>
|
||
{t('search_food', lang).replace('…', '')}
|
||
</button>
|
||
<button class="fab-tab" class:active={fabTab === 'meals'} onclick={() => { fabTab = 'meals'; loadCustomMeals(); }}>
|
||
<UtensilsCrossed size={13} />
|
||
{t('custom_meals', lang)}
|
||
</button>
|
||
</div>
|
||
|
||
{#if fabTab === 'search'}
|
||
<FoodSearch onselect={fabLogFood} autofocus={true} />
|
||
{:else}
|
||
<!-- Custom Meals tab -->
|
||
<div class="custom-meals-list">
|
||
{#if customMeals.length === 0}
|
||
<p class="meals-empty">{t('no_custom_meals', lang)}</p>
|
||
{/if}
|
||
{#each customMeals as meal}
|
||
<div class="custom-meal-card">
|
||
<div class="custom-meal-info">
|
||
<span class="custom-meal-name">{meal.name}</span>
|
||
<span class="custom-meal-detail">{meal.ingredients.length} {t('ingredients', lang)} · {fmtCal(mealTotalCal(meal))} kcal</span>
|
||
</div>
|
||
<button class="btn-primary btn-sm" onclick={() => logCustomMeal(meal)}>{t('log_meal', lang)}</button>
|
||
</div>
|
||
{/each}
|
||
<a class="manage-meals-link" href="/fitness/{s.nutrition}/meals">
|
||
<Settings size={13} />
|
||
{isEn ? 'Manage meals' : 'Mahlzeiten verwalten'}
|
||
</a>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.nutrition-page {
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
.sidebar-col, .meals-col, .quick-log-col {
|
||
display: contents;
|
||
}
|
||
.quick-log-col {
|
||
display: none;
|
||
}
|
||
@media (min-width: 1024px) {
|
||
.nutrition-page {
|
||
max-width: none;
|
||
display: grid;
|
||
grid-template-columns: minmax(320px, 380px) 1fr;
|
||
grid-template-rows: auto 1fr;
|
||
column-gap: 1.5rem;
|
||
align-items: start;
|
||
}
|
||
.date-nav {
|
||
grid-column: 1 / -1;
|
||
}
|
||
.sidebar-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
position: sticky;
|
||
top: 1rem;
|
||
}
|
||
.meals-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
min-width: 0;
|
||
}
|
||
.micro-inline {
|
||
display: none;
|
||
}
|
||
.micro-card {
|
||
display: block;
|
||
}
|
||
.micro-card .micro-details,
|
||
.micro-card .micro-details.micro-hidden {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
@media (min-width: 1600px) {
|
||
.nutrition-page {
|
||
grid-template-columns: minmax(320px, 380px) 1fr 260px;
|
||
}
|
||
.quick-log-col {
|
||
display: block;
|
||
position: sticky;
|
||
top: 1rem;
|
||
}
|
||
}
|
||
|
||
|
||
/* ── Date Navigator ── */
|
||
.date-nav {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.25rem;
|
||
position: relative;
|
||
}
|
||
.date-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
padding: 0.5rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
.date-btn:hover {
|
||
background: var(--color-bg-elevated);
|
||
color: var(--color-text-primary);
|
||
}
|
||
.date-display {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-primary);
|
||
font-size: 1.05rem;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
padding: 0.35rem 0.75rem;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
transition: background 0.15s;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.date-display:hover {
|
||
background: var(--color-bg-elevated);
|
||
}
|
||
.today-badge {
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--color-primary);
|
||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
.go-today-btn {
|
||
position: absolute;
|
||
right: 0;
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
color: var(--color-primary);
|
||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||
padding: 0.25rem 0.6rem;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background 0.15s;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.go-today-btn:hover {
|
||
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||
}
|
||
|
||
/* ── Daily Summary Card ── */
|
||
.daily-summary {
|
||
background: var(--color-surface);
|
||
border-radius: 12px;
|
||
padding: 1.25rem;
|
||
box-shadow: var(--shadow-sm);
|
||
position: relative;
|
||
|
||
|
||
}
|
||
.daily-summary::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: 12px;
|
||
background: linear-gradient(135deg, color-mix(in srgb, var(--nord8) 8%, transparent), transparent 60%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ── Calorie Trio: Eaten / Ring / Burned ── */
|
||
.calorie-trio {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
.cal-stat {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.1rem;
|
||
}
|
||
.cal-stat-value {
|
||
font-size: 1.4rem;
|
||
font-weight: 800;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: -0.03em;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.cal-stat-label {
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.burned-bmr {
|
||
margin-top: 0.1rem;
|
||
font-size: 0.6rem;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.tdee-info-wrap {
|
||
position: relative;
|
||
}
|
||
.tdee-info-trigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
vertical-align: middle;
|
||
opacity: 0.5;
|
||
cursor: pointer;
|
||
margin-left: 0.1rem;
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
color: inherit;
|
||
}
|
||
.tdee-info-trigger:hover {
|
||
opacity: 0.9;
|
||
}
|
||
.tdee-info-tooltip {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
margin-top: 0.35rem;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border, var(--nord3));
|
||
border-radius: 8px;
|
||
padding: 0.45rem 0.6rem;
|
||
font-size: 0.65rem;
|
||
font-weight: 400;
|
||
font-style: normal;
|
||
line-height: 1.5;
|
||
color: var(--color-text-secondary);
|
||
white-space: nowrap;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||
z-index: 20;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
}
|
||
.tdee-info-tooltip a {
|
||
color: var(--nord12);
|
||
text-decoration: underline;
|
||
}
|
||
.tdee-info-tooltip .cite-note {
|
||
font-size: 0.58rem;
|
||
color: var(--color-text-tertiary);
|
||
white-space: normal;
|
||
max-width: 220px;
|
||
margin-top: 0.1rem;
|
||
}
|
||
.bmr-hint {
|
||
font-size: 0.55rem;
|
||
color: var(--color-text-secondary);
|
||
opacity: 0.7;
|
||
margin-top: 0.15rem;
|
||
line-height: 1.3;
|
||
}
|
||
.bmr-hint a {
|
||
color: var(--nord12);
|
||
text-decoration: underline;
|
||
}
|
||
.calorie-ring-wrap {
|
||
flex-shrink: 0;
|
||
}
|
||
.calorie-ring {
|
||
overflow: visible;
|
||
}
|
||
|
||
/* ── Macro Progress Bars ── */
|
||
.macro-bars {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
padding: 0 0.25rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.macro-bar-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
.macro-bar-label {
|
||
font-size: 0.65rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.macro-bar-track {
|
||
width: 100%;
|
||
height: 6px;
|
||
background: var(--color-bg-elevated);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
.macro-bar-fill {
|
||
height: 100%;
|
||
border-radius: 3px;
|
||
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.macro-bar-overflow {
|
||
position: absolute;
|
||
top: 0;
|
||
right: 0;
|
||
height: 100%;
|
||
background: var(--nord11);
|
||
border-radius: 0 3px 3px 0;
|
||
animation: bar-overflow-fill 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.5s both;
|
||
}
|
||
@keyframes bar-overflow-fill {
|
||
from { width: 0%; }
|
||
}
|
||
.macro-bar-info {
|
||
font-size: 0.68rem;
|
||
color: var(--color-text-tertiary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.macro-bar-info.over {
|
||
color: var(--nord11);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* ── SVG Ring Styles ── */
|
||
.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.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
.ring-calories {
|
||
stroke: var(--color-primary);
|
||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--color-primary) 25%, transparent));
|
||
}
|
||
.ring-calories.no-glow {
|
||
filter: none;
|
||
}
|
||
.ring-glow {
|
||
fill: none;
|
||
stroke-width: 5;
|
||
stroke-linecap: round;
|
||
pointer-events: none;
|
||
}
|
||
.ring-calories-glow {
|
||
stroke: var(--color-primary);
|
||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--color-primary) 25%, transparent));
|
||
stroke-dashoffset: var(--glow-target);
|
||
animation: glow-shrink 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.6s both;
|
||
}
|
||
@keyframes glow-shrink {
|
||
from { stroke-dashoffset: var(--glow-start); }
|
||
to { stroke-dashoffset: var(--glow-target); }
|
||
}
|
||
.ring-overflow {
|
||
stroke: var(--nord11);
|
||
filter: drop-shadow(0 0 4px color-mix(in srgb, var(--nord11) 25%, transparent));
|
||
stroke-dashoffset: var(--overflow-target);
|
||
animation: overflow-fill 0.8s cubic-bezier(0.4, 0, 0.2, 1) 0.6s both;
|
||
}
|
||
@keyframes overflow-fill {
|
||
from { stroke-dashoffset: var(--arc-length); }
|
||
to { stroke-dashoffset: var(--overflow-target); }
|
||
}
|
||
.ring-text-main {
|
||
font-size: 14px;
|
||
font-weight: 800;
|
||
fill: currentColor;
|
||
text-anchor: middle;
|
||
}
|
||
.ring-text-sub {
|
||
font-size: 8px;
|
||
fill: var(--color-text-secondary);
|
||
text-anchor: middle;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
/* (macro rings replaced by macro bars) */
|
||
|
||
/* ── Micro Details ── */
|
||
.micro-inline {
|
||
margin-top: 0.75rem;
|
||
padding-top: 0.5rem;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
.micro-card {
|
||
background: var(--color-surface);
|
||
border-radius: 14px;
|
||
padding: 1rem;
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.micro-card {
|
||
display: none;
|
||
}
|
||
.micro-inline {
|
||
display: block;
|
||
}
|
||
}
|
||
.micro-card-title {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.details-toggle-row {
|
||
text-align: center;
|
||
}
|
||
.details-toggle {
|
||
font-size: 0.8rem;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
background: none;
|
||
border: none;
|
||
padding: 0.25rem 0.5rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
transition: color 0.15s;
|
||
font-weight: 500;
|
||
}
|
||
.details-toggle:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.micro-details {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 1rem;
|
||
}
|
||
.micro-details.micro-hidden {
|
||
display: none;
|
||
}
|
||
.micro-section h4 {
|
||
margin: 0 0 0.4rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.micro-row {
|
||
display: grid;
|
||
grid-template-columns: 7rem 1fr 4rem 2.5rem;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
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);
|
||
font-size: 0.72rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.micro-pct {
|
||
text-align: right;
|
||
font-weight: 700;
|
||
font-size: 0.72rem;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* ── Empty / No Goal State ── */
|
||
.no-goal {
|
||
text-align: center;
|
||
padding: 3rem 1.5rem 2.5rem;
|
||
background: var(--color-surface);
|
||
border-radius: 12px;
|
||
box-shadow: var(--shadow-sm);
|
||
|
||
}
|
||
.no-goal-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 3.5rem;
|
||
height: 3.5rem;
|
||
border-radius: 50%;
|
||
margin: 0 auto 1rem;
|
||
color: var(--nord15);
|
||
background: color-mix(in srgb, var(--nord15) 12%, transparent);
|
||
}
|
||
.no-goal p {
|
||
color: var(--color-text-secondary);
|
||
margin: 0 0 1.25rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
/* ── Goal Editor ── */
|
||
.goal-edit-btn {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-tertiary);
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
padding: 0.2rem 0;
|
||
transition: color 0.15s;
|
||
font-weight: 500;
|
||
}
|
||
.goal-edit-btn:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.goal-editor {
|
||
background: var(--color-surface);
|
||
border-radius: 12px;
|
||
padding: 1.25rem;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
.goal-editor h3 {
|
||
margin: 0 0 0.75rem;
|
||
font-size: 0.95rem;
|
||
font-weight: 700;
|
||
}
|
||
|
||
/* Wizard steps indicator */
|
||
.wizard-steps {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
.wizard-step {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
.ws-num {
|
||
width: 1.5rem;
|
||
height: 1.5rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-secondary);
|
||
border: 2px solid var(--color-border);
|
||
transition: all 0.2s;
|
||
}
|
||
.wizard-step.active .ws-num {
|
||
background: var(--nord10);
|
||
color: white;
|
||
border-color: var(--nord10);
|
||
}
|
||
.wizard-step.done .ws-num {
|
||
background: var(--nord14);
|
||
color: white;
|
||
border-color: var(--nord14);
|
||
}
|
||
.ws-label {
|
||
font-size: 0.6rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.wizard-step.active .ws-label {
|
||
color: var(--nord10);
|
||
}
|
||
.ws-line {
|
||
flex: 1;
|
||
height: 2px;
|
||
background: var(--color-border);
|
||
margin: 0 0.3rem;
|
||
margin-bottom: 1rem;
|
||
transition: background 0.2s;
|
||
}
|
||
.ws-line.done {
|
||
background: var(--nord14);
|
||
}
|
||
|
||
/* Preset cards (Step 1) */
|
||
.preset-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
.preset-grid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.preset-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.65rem;
|
||
padding: 0.65rem 0.75rem;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1.5px solid var(--color-border);
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||
}
|
||
.preset-card:hover {
|
||
border-color: var(--nord10);
|
||
background: color-mix(in srgb, var(--nord10) 6%, var(--color-bg-tertiary));
|
||
}
|
||
.preset-card.selected {
|
||
border-color: var(--nord10);
|
||
background: color-mix(in srgb, var(--nord10) 10%, var(--color-bg-tertiary));
|
||
box-shadow: 0 0 0 1px var(--nord10);
|
||
}
|
||
.preset-card-emoji {
|
||
font-size: 1.3rem;
|
||
flex-shrink: 0;
|
||
width: 2rem;
|
||
text-align: center;
|
||
}
|
||
.preset-card-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
min-width: 0;
|
||
}
|
||
.preset-card-name {
|
||
font-size: 0.82rem;
|
||
font-weight: 650;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.preset-card-desc {
|
||
font-size: 0.68rem;
|
||
color: var(--color-text-tertiary);
|
||
line-height: 1.3;
|
||
}
|
||
.wizard-skip {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-tertiary);
|
||
font-size: 0.72rem;
|
||
cursor: pointer;
|
||
padding: 0.3rem 0;
|
||
text-align: center;
|
||
transition: color 0.15s;
|
||
}
|
||
.wizard-skip:hover {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* Step summary pills */
|
||
.step-pills {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
margin-bottom: 0.75rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.step-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
padding: 0.25rem 0.6rem;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 20px;
|
||
font-size: 0.7rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.step-pill:hover {
|
||
border-color: var(--nord10);
|
||
color: var(--nord10);
|
||
}
|
||
|
||
/* TDEE warning */
|
||
.tdee-warning {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
padding: 0.6rem 0.75rem;
|
||
background: color-mix(in srgb, var(--nord13) 10%, var(--color-bg-tertiary));
|
||
border: 1px solid color-mix(in srgb, var(--nord13) 30%, var(--color-border));
|
||
border-radius: 8px;
|
||
margin-bottom: 0.75rem;
|
||
color: var(--nord13);
|
||
}
|
||
.tdee-warning :global(svg) {
|
||
flex-shrink: 0;
|
||
margin-top: 0.1rem;
|
||
}
|
||
.tdee-warning-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.15rem;
|
||
font-size: 0.75rem;
|
||
}
|
||
.tdee-warning-text strong {
|
||
font-size: 0.78rem;
|
||
}
|
||
.tdee-warning-text span {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.tdee-warning-text a {
|
||
color: var(--nord10);
|
||
text-decoration: underline;
|
||
}
|
||
|
||
/* TDEE comparison */
|
||
.tdee-compare {
|
||
margin-bottom: 0.75rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
}
|
||
.tdee-compare-bar-wrap {
|
||
position: relative;
|
||
height: 10px;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: 5px;
|
||
overflow: hidden;
|
||
}
|
||
.tdee-compare-ref {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: var(--color-text-tertiary);
|
||
opacity: 0.18;
|
||
border-radius: 5px;
|
||
}
|
||
.tdee-compare-target {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
height: 100%;
|
||
border-radius: 5px;
|
||
transition: width 0.3s;
|
||
}
|
||
.tdee-compare-target.deficit { background: var(--nord9); }
|
||
.tdee-compare-target.surplus { background: var(--nord12); }
|
||
.tdee-compare-target.maintenance { background: var(--nord14); }
|
||
.tdee-compare-labels {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 0.65rem;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.tdee-compare-label strong {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.tdee-compare-diff {
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
}
|
||
.tdee-compare-diff.deficit { color: var(--nord9); }
|
||
.tdee-compare-diff.surplus { color: var(--nord12); }
|
||
.tdee-compare-diff.maintenance { color: var(--nord14); }
|
||
|
||
/* Macro donut ring */
|
||
.macro-ring-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 1rem;
|
||
margin-bottom: 0.75rem;
|
||
padding: 0.5rem 0;
|
||
}
|
||
.macro-ring {
|
||
flex-shrink: 0;
|
||
}
|
||
.ring-cal-main {
|
||
font-size: 1.1rem;
|
||
font-weight: 700;
|
||
fill: var(--color-text-primary);
|
||
}
|
||
.ring-cal-sub {
|
||
font-size: 0.55rem;
|
||
font-weight: 500;
|
||
fill: var(--color-text-tertiary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
.macro-ring-legend {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
}
|
||
.mrl-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
font-size: 0.72rem;
|
||
font-weight: 500;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.mrl-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Goal form fields */
|
||
.goal-field {
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.calorie-input-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
.calorie-input-row input {
|
||
flex: 1;
|
||
}
|
||
.bmr-calc-btn {
|
||
padding: 0.4rem 0.6rem;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.7rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.bmr-calc-btn:hover {
|
||
border-color: var(--nord10);
|
||
color: var(--nord10);
|
||
}
|
||
.tdee-basis {
|
||
display: block;
|
||
font-size: 0.62rem;
|
||
color: var(--color-text-tertiary);
|
||
margin-top: 0.25rem;
|
||
}
|
||
.goal-field label {
|
||
display: block;
|
||
font-size: 0.72rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.03em;
|
||
text-transform: uppercase;
|
||
color: var(--color-text-secondary);
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
.goal-field input,
|
||
.goal-field select {
|
||
width: 100%;
|
||
padding: 0.55rem 0.65rem;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
color: var(--color-text-primary);
|
||
font-size: 0.9rem;
|
||
transition: border-color 0.15s;
|
||
box-sizing: border-box;
|
||
}
|
||
.goal-field input:focus,
|
||
.goal-field select:focus {
|
||
outline: none;
|
||
border-color: var(--nord8);
|
||
}
|
||
.protein-mode {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
.protein-mode select {
|
||
flex: 1;
|
||
}
|
||
.protein-mode input {
|
||
flex: 1;
|
||
}
|
||
.goal-row {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
}
|
||
.goal-row .goal-field {
|
||
flex: 1;
|
||
}
|
||
.wizard-nav {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-top: 0.75rem;
|
||
}
|
||
.goal-actions-final {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
/* ── Meal Sections ── */
|
||
/* Water card */
|
||
.water-card {
|
||
background: var(--color-surface);
|
||
border-radius: 12px;
|
||
padding: 0.75rem 1rem;
|
||
margin-bottom: 0.75rem;
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
.water-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.water-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
color: var(--nord10);
|
||
}
|
||
.water-title h3 {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-primary);
|
||
}
|
||
.water-stats {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
}
|
||
.water-amount {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--nord10);
|
||
}
|
||
.water-goal-btn {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
padding: 0;
|
||
}
|
||
.goal-edit-inline {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.25rem;
|
||
}
|
||
.goal-slash {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.goal-input-inline {
|
||
width: 48px;
|
||
padding: 0.15rem 0.3rem;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
color: var(--color-text-primary);
|
||
font-size: 0.8rem;
|
||
text-align: right;
|
||
}
|
||
.goal-input-inline:focus {
|
||
outline: none;
|
||
border-color: var(--nord10);
|
||
}
|
||
.goal-unit {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.goal-save-inline {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px;
|
||
height: 22px;
|
||
border-radius: 50%;
|
||
border: 2px solid var(--color-border);
|
||
background: transparent;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
transition: all 200ms;
|
||
padding: 0;
|
||
}
|
||
.goal-save-inline:hover {
|
||
border-color: var(--nord14);
|
||
background: var(--nord14);
|
||
color: white;
|
||
box-shadow: 0 2px 8px rgba(163, 190, 140, 0.35);
|
||
transform: scale(1.08);
|
||
}
|
||
.goal-save-inline:active {
|
||
transform: scale(0.95);
|
||
background: #8fad7a;
|
||
border-color: #8fad7a;
|
||
color: white;
|
||
}
|
||
.water-cups {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.water-cup {
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 26px;
|
||
height: 34px;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
@media (min-width: 500px) {
|
||
.water-cup {
|
||
width: 34px;
|
||
height: 44px;
|
||
}
|
||
}
|
||
.cup-svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.water-cup:disabled {
|
||
cursor: default;
|
||
opacity: 1;
|
||
}
|
||
.water-cup.next-empty:hover .cup-svg > path:first-of-type {
|
||
fill: color-mix(in srgb, var(--nord10) 20%, var(--color-bg-tertiary));
|
||
}
|
||
|
||
/* Water fill animation — only on newly filled cups */
|
||
.water-cup.filling .water-body {
|
||
animation: water-rise 800ms ease-out both;
|
||
}
|
||
.water-cup.filling .water-wave.w1 {
|
||
animation: wave-slosh 1.2s ease-in-out both;
|
||
}
|
||
.water-cup.filling .water-wave.w2 {
|
||
animation: wave-slosh 1s ease-in-out 0.1s both reverse;
|
||
}
|
||
.water-cup.filling .water-wave.w3 {
|
||
animation: wave-slosh 1.4s ease-in-out 0.05s both;
|
||
}
|
||
.water-cup.draining .water-body {
|
||
animation: water-drain 400ms ease-in both;
|
||
}
|
||
.water-cup.draining .water-wave.w1 {
|
||
animation: wave-slosh 500ms ease-in-out both;
|
||
}
|
||
.water-cup.draining .water-wave.w2 {
|
||
animation: wave-slosh 400ms ease-in-out both reverse;
|
||
}
|
||
.water-cup.draining .water-wave.w3 {
|
||
animation: wave-slosh 550ms ease-in-out both;
|
||
}
|
||
@keyframes water-rise {
|
||
from { transform: translateY(24px); }
|
||
to { transform: translateY(0); }
|
||
}
|
||
@keyframes water-drain {
|
||
from { transform: translateY(0); }
|
||
to { transform: translateY(24px); }
|
||
}
|
||
@keyframes wave-slosh {
|
||
0% { transform: translateX(0); }
|
||
20% { transform: translateX(-6px); }
|
||
40% { transform: translateX(5px); }
|
||
60% { transform: translateX(-3px); }
|
||
80% { transform: translateX(2px); }
|
||
100% { transform: translateX(0); }
|
||
}
|
||
|
||
/* Beverage list */
|
||
.beverage-list {
|
||
margin-top: 0.5rem;
|
||
padding-top: 0.5rem;
|
||
border-top: 1px solid var(--color-border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
}
|
||
.beverage-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
font-size: 0.8rem;
|
||
}
|
||
.beverage-name {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.beverage-ml {
|
||
color: var(--nord10);
|
||
font-weight: 500;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.meal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 0.3rem;
|
||
padding: 0 0.1rem;
|
||
}
|
||
.meal-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
}
|
||
.meal-icon {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 1.6rem;
|
||
height: 1.6rem;
|
||
border-radius: 6px;
|
||
color: var(--meal-color);
|
||
background: color-mix(in srgb, var(--meal-color) 12%, transparent);
|
||
}
|
||
.meal-header h3 {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.meal-cal {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
font-weight: 600;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.meal-entries {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
/* ── Food card: horizontal (mobile default) ── */
|
||
.food-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
padding: 0.5rem;
|
||
background: var(--color-surface);
|
||
border-radius: 10px;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: background 0.12s;
|
||
position: relative;
|
||
}
|
||
.food-card:hover {
|
||
background: var(--color-bg-elevated);
|
||
}
|
||
.food-card-img {
|
||
width: 3.2rem;
|
||
height: 3.2rem;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
}
|
||
.food-card-accent {
|
||
width: 4px;
|
||
height: 2.6rem;
|
||
border-radius: 2px;
|
||
opacity: 0.5;
|
||
flex-shrink: 0;
|
||
margin-left: 0.2rem;
|
||
}
|
||
.food-card-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.food-card-name {
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
.food-card-link {
|
||
color: var(--color-text-primary);
|
||
text-decoration: none;
|
||
}
|
||
.food-card-link:hover {
|
||
text-decoration: underline;
|
||
text-decoration-color: var(--nord8);
|
||
text-underline-offset: 2px;
|
||
}
|
||
.food-card-detail {
|
||
font-size: 0.72rem;
|
||
color: var(--color-text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.food-card-edit-form {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
}
|
||
.food-card-edit-input {
|
||
width: 55px;
|
||
padding: 0.1rem 0.3rem;
|
||
background: var(--color-bg-tertiary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
color: var(--color-text-primary);
|
||
font-size: 0.75rem;
|
||
text-align: right;
|
||
}
|
||
.food-card-edit-input:focus {
|
||
outline: none;
|
||
border-color: var(--nord10);
|
||
}
|
||
.food-card-edit-unit {
|
||
font-size: 0.7rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.food-card-macros {
|
||
font-size: 0.65rem;
|
||
color: var(--color-text-tertiary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.food-card-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.2rem;
|
||
flex-shrink: 0;
|
||
align-self: flex-start;
|
||
}
|
||
.food-card-action {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-tertiary);
|
||
cursor: pointer;
|
||
padding: 0.3rem;
|
||
border-radius: 6px;
|
||
transition: color 0.12s, background 0.12s;
|
||
display: flex;
|
||
}
|
||
.food-card-action.edit:hover {
|
||
color: var(--nord10);
|
||
background: color-mix(in srgb, var(--nord10) 10%, transparent);
|
||
}
|
||
.food-card-action.edit.active {
|
||
color: var(--nord14);
|
||
}
|
||
.food-card-action.edit.active:hover {
|
||
color: var(--nord14);
|
||
background: color-mix(in srgb, var(--nord14) 10%, transparent);
|
||
}
|
||
.food-card-action.delete:hover {
|
||
color: var(--nord11);
|
||
background: color-mix(in srgb, var(--nord11) 10%, transparent);
|
||
}
|
||
|
||
/* ── Wider screens: grid of vertical cards ── */
|
||
@media (min-width: 600px) {
|
||
.meal-entries {
|
||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||
gap: 0.5rem;
|
||
}
|
||
.food-card {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
padding: 0;
|
||
overflow: hidden;
|
||
}
|
||
.food-card-img {
|
||
width: 100%;
|
||
height: 7rem;
|
||
border-radius: 0;
|
||
}
|
||
.food-card-accent {
|
||
width: 100%;
|
||
height: 4px;
|
||
margin-left: 0;
|
||
border-radius: 0;
|
||
}
|
||
.food-card-body {
|
||
padding: 0.5rem 0.6rem;
|
||
gap: 0.15rem;
|
||
}
|
||
.food-card-name {
|
||
font-size: 0.8rem;
|
||
white-space: normal;
|
||
}
|
||
.food-card-actions {
|
||
position: absolute;
|
||
bottom: 0.3rem;
|
||
right: 0.3rem;
|
||
flex-direction: row;
|
||
opacity: 0;
|
||
transition: opacity 0.15s;
|
||
}
|
||
.food-card-action {
|
||
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||
border-radius: 50%;
|
||
padding: 0.25rem;
|
||
}
|
||
.food-card:hover .food-card-actions,
|
||
.food-card-actions:has(.active) {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
/* ── Add Food ── */
|
||
.add-food-btn {
|
||
background: none;
|
||
border: 1px dashed var(--color-border);
|
||
color: var(--color-text-tertiary);
|
||
cursor: pointer;
|
||
padding: 0.45rem 0.75rem;
|
||
border-radius: 10px;
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.35rem;
|
||
width: 100%;
|
||
justify-content: center;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.add-food-btn:hover {
|
||
color: var(--meal-color);
|
||
border-color: var(--meal-color);
|
||
}
|
||
|
||
.add-food-form {
|
||
background: var(--color-surface);
|
||
border-radius: 10px;
|
||
padding: 0.85rem;
|
||
box-shadow: var(--shadow-sm);
|
||
margin-top: 0.5rem;
|
||
}
|
||
.add-food-form-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
}
|
||
.add-food-form-header .fab-close {
|
||
margin-top: -0.5rem;
|
||
margin-right: -0.5rem;
|
||
}
|
||
.add-food-form-header .fab-tabs {
|
||
flex: 1;
|
||
}
|
||
/* Search/food selection handled by FoodSearch component */
|
||
|
||
/* ── Buttons ── */
|
||
.btn-primary {
|
||
padding: 0.5rem 1.1rem;
|
||
background: var(--color-primary);
|
||
color: var(--color-text-on-primary);
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
font-weight: 700;
|
||
letter-spacing: 0.01em;
|
||
transition: background 0.15s, transform 0.1s;
|
||
}
|
||
.btn-primary:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
.btn-primary:active {
|
||
transform: scale(0.97);
|
||
}
|
||
.btn-primary:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none;
|
||
}
|
||
.btn-secondary {
|
||
padding: 0.5rem 1.1rem;
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-primary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
font-weight: 500;
|
||
transition: background 0.15s;
|
||
}
|
||
|
||
/* ── Day Total ── */
|
||
/* ── FAB Modal ── */
|
||
.fab-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
z-index: 200;
|
||
animation: fade-in 0.15s ease;
|
||
}
|
||
@keyframes fade-in {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
.fab-modal {
|
||
background: var(--color-surface);
|
||
border-radius: 16px 16px 0 0;
|
||
width: 100%;
|
||
max-width: 500px;
|
||
max-height: 85vh;
|
||
overflow-y: auto;
|
||
padding: 1rem 1.25rem 1.5rem;
|
||
animation: slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
@keyframes slide-up {
|
||
from { transform: translateY(100%); }
|
||
to { transform: translateY(0); }
|
||
}
|
||
.fab-modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.fab-modal-header h3 {
|
||
margin: 0;
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
}
|
||
.fab-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
padding: 0.3rem;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
transition: background 0.12s;
|
||
}
|
||
.fab-close:hover {
|
||
background: var(--color-bg-elevated);
|
||
}
|
||
|
||
/* ── FAB Meal selector ── */
|
||
.fab-meal-select {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
}
|
||
.fab-meal-btn {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.2rem;
|
||
padding: 0.55rem 0.25rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 10px;
|
||
background: var(--color-bg-tertiary);
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.68rem;
|
||
font-weight: 600;
|
||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||
}
|
||
.fab-meal-btn.active {
|
||
border-color: var(--mc);
|
||
color: var(--mc);
|
||
background: color-mix(in srgb, var(--mc) 8%, var(--color-bg-tertiary));
|
||
}
|
||
.fab-meal-btn:hover:not(.active) {
|
||
border-color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* ── FAB Tabs ── */
|
||
.fab-tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
border-bottom: 2px solid var(--color-border);
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.fab-tab {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.3rem;
|
||
padding: 0.5rem 0.25rem;
|
||
background: none;
|
||
border: none;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -2px;
|
||
cursor: pointer;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-tertiary);
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.fab-tab.active {
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
}
|
||
.fab-tab:hover:not(.active) {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* ── Custom Meals in FAB ── */
|
||
.custom-meals-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
}
|
||
.meals-empty {
|
||
text-align: center;
|
||
color: var(--color-text-tertiary);
|
||
font-size: 0.85rem;
|
||
padding: 1rem 0;
|
||
margin: 0;
|
||
}
|
||
.custom-meal-card {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.75rem;
|
||
padding: 0.6rem 0.75rem;
|
||
background: var(--color-bg-tertiary);
|
||
border-radius: 10px;
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
.custom-meal-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.1rem;
|
||
min-width: 0;
|
||
}
|
||
.custom-meal-name {
|
||
font-weight: 600;
|
||
font-size: 0.88rem;
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.custom-meal-detail {
|
||
font-size: 0.72rem;
|
||
color: var(--color-text-tertiary);
|
||
}
|
||
.btn-sm {
|
||
padding: 0.3rem 0.65rem;
|
||
font-size: 0.72rem;
|
||
}
|
||
.manage-meals-link {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 0.3rem;
|
||
padding: 0.5rem;
|
||
color: var(--color-text-secondary);
|
||
text-decoration: none;
|
||
font-size: 0.78rem;
|
||
font-weight: 500;
|
||
transition: color 0.15s;
|
||
}
|
||
.manage-meals-link:hover {
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
/* ── Quick-log Sidebar ── */
|
||
.quick-log-card {
|
||
background: var(--color-surface);
|
||
border-radius: 14px;
|
||
padding: 0.75rem;
|
||
}
|
||
.quick-log-title {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 700;
|
||
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;
|
||
}
|
||
.ql-section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
margin: 0 0 0.3rem;
|
||
font-size: 0.68rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.ql-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
padding: 0.4rem 0.5rem;
|
||
border-radius: 8px;
|
||
border: none;
|
||
background: none;
|
||
cursor: pointer;
|
||
color: var(--color-text-primary);
|
||
font-size: 0.78rem;
|
||
text-align: left;
|
||
transition: background 0.12s;
|
||
}
|
||
.ql-item:hover {
|
||
background: var(--color-bg-elevated);
|
||
}
|
||
.ql-item-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.ql-item :global(.ql-item-add) {
|
||
flex-shrink: 0;
|
||
color: var(--color-text-tertiary);
|
||
transition: color 0.12s;
|
||
}
|
||
.ql-item:hover :global(.ql-item-add) {
|
||
color: var(--color-primary);
|
||
}
|
||
.ql-item.active {
|
||
background: var(--color-bg-elevated);
|
||
}
|
||
.ql-amount-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 0.3rem;
|
||
padding: 0.25rem 0.5rem 0.4rem;
|
||
}
|
||
.ql-amount-input {
|
||
width: 4rem;
|
||
padding: 0.3rem 0.4rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
background: var(--color-bg-tertiary);
|
||
color: var(--color-text-primary);
|
||
font-size: 0.78rem;
|
||
text-align: right;
|
||
}
|
||
.ql-amount-input:focus {
|
||
border-color: var(--nord8);
|
||
outline: none;
|
||
}
|
||
.ql-amount-unit {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.ql-amount-confirm {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0.3rem;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: var(--color-primary);
|
||
color: white;
|
||
cursor: pointer;
|
||
transition: filter 0.12s;
|
||
}
|
||
.ql-amount-confirm:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
.ql-empty {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-tertiary);
|
||
text-align: center;
|
||
padding: 1rem 0.5rem;
|
||
margin: 0;
|
||
}
|
||
</style>
|