Files
platform/frontend-v2/src/routes/(app)/fitness/+page.svelte
Yusuf Suleman 810502ab9d
Some checks failed
Security Checks / dependency-audit (push) Has been cancelled
Security Checks / secret-scanning (push) Has been cancelled
Security Checks / dockerfile-lint (push) Has been cancelled
feat: multi-user support, goals editing, shared food library
Multi-user:
- Madiha account with per-user nav visibility
- Dashboard greeting uses actual user display name
- Navbar and MobileTabBar accept visibleApps prop
- Madiha sees: Dashboard, Trips, Fitness, Budget, Media (no Inventory, Reader)

Goals editing:
- Goals page now has Edit Goals mode with inline number inputs
- Saves via PUT /api/fitness/goals
- Shows "No goals set" state for new users

Food library:
- Default view shows all shared foods (not just user's recent)
- Both users see the same food database
- Cleaned up duplicates: archived Eggs (kept Egg), Green Grapes (kept Grapes),
  duplicate Bellwether Yogurt, Latte Macchiato (kept Madiha's Caramel Latte)

Add to meal buttons:
- "Add to breakfast/lunch/dinner/snack" now focuses the resolve input
  and sets the meal type so AI logs to the correct meal
2026-03-29 14:44:46 -05:00

1606 lines
70 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
type FitnessTab = 'log' | 'foods' | 'templates';
interface FoodEntry {
id: string;
name: string;
serving: string;
calories: number;
protein: number;
carbs: number;
fat: number;
method: string;
image?: string;
note?: string;
meal: MealType;
rawQty: number;
rawUnit: string;
}
interface FoodItem {
id: string;
name: string;
info: string;
calories: number;
favorite?: boolean;
}
interface Template {
id: string;
name: string;
calories: number;
items: number;
meal: string;
}
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
let activeTab = $state<FitnessTab>('log');
let selectedDate = $state(todayStr);
let expandedMeals = $state<Set<string>>(new Set(['breakfast', 'lunch', 'dinner', 'snack']));
let foodSearch = $state('');
let highlightTemplate = $state('');
let fabOpen = $state(false);
let loading = $state(true);
let foodsLoading = $state(false);
let templatesLoading = $state(false);
let entries = $state<FoodEntry[]>([]);
let goal = $state({ calories: 2000, protein: 150, carbs: 200, fat: 65 });
let foods = $state<FoodItem[]>([]);
let templates = $state<Template[]>([]);
// Map backend entry to UI entry
function mapEntry(e: any): FoodEntry {
return {
id: e.id,
name: e.snapshot_food_name || 'Unknown',
serving: e.serving_description || e.snapshot_serving_label || `${e.quantity || 1} ${e.unit || 'serving'}`,
calories: Math.round(e.snapshot_calories || 0),
protein: Math.round(e.snapshot_protein || 0),
carbs: Math.round(e.snapshot_carbs || 0),
fat: Math.round(e.snapshot_fat || 0),
method: e.entry_method || 'manual',
image: e.food_image_path,
note: e.note,
meal: (e.meal_type || 'snack') as MealType,
rawQty: e.quantity || 1,
rawUnit: e.unit || 'serving',
};
}
// Map backend food to UI food (handles both /foods and /foods/recent shapes)
function mapFood(f: any): FoodItem {
const id = f.id || f.food_id || '';
const name = f.name || f.snapshot_food_name || 'Unknown';
const brand = f.brand ? `${f.brand} · ` : '';
const unit = f.base_unit === '100g' ? '100g' : (f.servings?.[0]?.label || f.base_unit || 'serving');
return {
id,
name,
info: `${brand}${unit}`,
calories: Math.round(f.calories_per_base || 0),
favorite: f.is_favorite || false,
};
}
// Map backend template to UI template
function mapTemplate(t: any): Template {
const items = t.items || [];
const totalCal = items.reduce((s: number, i: any) => s + (i.snapshot_calories || 0) * (i.quantity || 1), 0);
return {
id: t.id,
name: t.name,
calories: Math.round(totalCal),
items: items.length,
meal: t.meal_type || 'snack',
};
}
async function loadDayData() {
loading = true;
try {
const [entriesRes, goalsRes] = await Promise.all([
fetch(`/api/fitness/entries?date=${selectedDate}`, { credentials: 'include' }),
fetch(`/api/fitness/goals/for-date?date=${selectedDate}`, { credentials: 'include' }),
]);
if (entriesRes.ok) {
const raw = await entriesRes.json();
entries = (Array.isArray(raw) ? raw : []).map(mapEntry);
} else {
entries = [];
}
if (goalsRes.ok) {
const g = await goalsRes.json();
goal = { calories: g.calories || 2000, protein: g.protein || 150, carbs: g.carbs || 200, fat: g.fat || 65 };
}
} catch { /* silent */ }
loading = false;
}
async function loadFoods(query?: string) {
foodsLoading = true;
try {
const url = query
? `/api/fitness/foods/search?q=${encodeURIComponent(query)}&limit=30`
: `/api/fitness/foods?limit=100`;
const res = await fetch(url, { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
foods = (Array.isArray(raw) ? raw : []).map(mapFood);
}
} catch { /* silent */ }
foodsLoading = false;
}
async function loadTemplates() {
templatesLoading = true;
try {
const res = await fetch('/api/fitness/templates', { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
templates = (Array.isArray(raw) ? raw : []).map(mapTemplate);
}
} catch { /* silent */ }
templatesLoading = false;
}
let expandedEntry = $state<string | null>(null);
async function deleteEntry(id: string) {
expandedEntry = null;
entries = entries.filter(e => e.id !== id);
try {
await fetch(`/api/fitness/entries/${id}`, { method: 'DELETE', credentials: 'include' });
} catch {
await loadDayData();
}
}
let editQtyValue = $state('');
function toggleEntry(id: string) {
if (expandedEntry === id) {
expandedEntry = null;
} else {
expandedEntry = id;
const entry = entries.find(e => e.id === id);
editQtyValue = entry ? String(entry.rawQty) : '1';
}
}
async function updateEntryQty(id: string) {
const newQty = parseFloat(editQtyValue);
if (!newQty || newQty <= 0) return;
expandedEntry = null;
try {
const res = await fetch(`/api/fitness/entries/${id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity: newQty }),
});
if (res.ok) {
await loadDayData();
}
} catch { /* silent */ }
}
async function logTemplate(tpl: Template) {
try {
const res = await fetch(`/api/fitness/templates/${tpl.id}/log`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ meal_type: tpl.meal, entry_date: selectedDate }),
});
if (res.ok) {
activeTab = 'log';
await loadDayData();
}
} catch { /* silent */ }
}
// ── AI Food Resolve (multi-item) ──
interface ResolvedItem {
result: any;
name: string;
qty: number;
unit: string;
calories: number;
protein: number;
carbs: number;
fat: number;
}
let resolveInput = $state('');
let resolveMeal = $state<MealType>('snack');
let resolving = $state(false);
let resolvedItems = $state<ResolvedItem[]>([]);
let resolveError = $state('');
let confirmLogging = $state(false);
function guessCurrentMeal(): MealType {
const h = new Date().getHours();
if (h >= 6 && h < 11) return 'breakfast';
if (h >= 11 && h < 15) return 'lunch';
if (h >= 17 && h < 21) return 'dinner';
return 'snack';
}
async function splitInputItems(input: string): Promise<string[]> {
// Use AI to split multi-item input into individual food items
try {
const res = await fetch('/api/fitness/foods/split', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phrase: input }),
});
if (res.ok) {
const data = await res.json();
if (data.items?.length > 0) return data.items;
}
} catch { /* fallback below */ }
// Fallback: comma split
return input.split(/,/).map(s => s.trim()).filter(s => s.length > 0);
}
function resolveToItem(r: any): ResolvedItem {
const qty = r.parsed?.quantity || 1;
const food = r.matched_food;
let cal = 0, pro = 0, carb = 0, f = 0;
if (food) {
cal = (food.calories_per_base || 0) * qty;
pro = (food.protein_per_base || 0) * qty;
carb = (food.carbs_per_base || 0) * qty;
f = (food.fat_per_base || 0) * qty;
} else if (r.ai_estimate) {
cal = (r.ai_estimate.calories_per_base || 0) * qty;
pro = (r.ai_estimate.protein_per_base || 0) * qty;
carb = (r.ai_estimate.carbs_per_base || 0) * qty;
f = (r.ai_estimate.fat_per_base || 0) * qty;
} else if (r.resolution_type === 'quick_add') {
cal = r.parsed?.quantity || 0;
}
const name = r.snapshot_name_override || food?.name || r.ai_estimate?.food_name || r.raw_text || 'Unknown';
return {
result: r,
name,
qty: Math.round(qty * 10) / 10,
unit: r.parsed?.unit || 'serving',
calories: Math.round(cal),
protein: Math.round(pro),
carbs: Math.round(carb),
fat: Math.round(f),
};
}
async function submitResolve() {
const phrase = resolveInput.trim();
if (!phrase) return;
resolving = true;
resolveError = '';
resolvedItems = [];
resolveMeal = guessCurrentMeal();
const parts = await splitInputItems(phrase);
try {
const results = await Promise.all(parts.map(async (part) => {
const res = await fetch('/api/fitness/foods/resolve', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw_phrase: part, meal_type: resolveMeal, entry_date: selectedDate }),
});
if (!res.ok) {
if (res.status === 401) throw new Error('Not connected — connect fitness in Settings');
throw new Error(`Failed to resolve "${part}" (${res.status})`);
}
return res.json();
}));
resolvedItems = results.map(resolveToItem);
} catch (e: any) {
resolveError = e?.message || 'Network error';
}
resolving = false;
}
function updateItemQty(index: number, newQty: number) {
if (newQty <= 0) return;
const item = resolvedItems[index];
const r = item.result;
const food = r.matched_food;
const base = food || r.ai_estimate;
if (!base) return;
resolvedItems[index] = {
...item,
qty: Math.round(newQty * 10) / 10,
calories: Math.round((base.calories_per_base || 0) * newQty),
protein: Math.round((base.protein_per_base || 0) * newQty),
carbs: Math.round((base.carbs_per_base || 0) * newQty),
fat: Math.round((base.fat_per_base || 0) * newQty),
};
}
function removeItem(index: number) {
resolvedItems = resolvedItems.filter((_, i) => i !== index);
if (resolvedItems.length === 0) cancelResolve();
}
const resolveTotalCal = $derived(resolvedItems.reduce((s, i) => s + i.calories, 0));
async function confirmResolve() {
if (resolvedItems.length === 0) return;
confirmLogging = true;
try {
for (const item of resolvedItems) {
const r = item.result;
let body: any;
if (r.resolution_type === 'quick_add' && !r.matched_food) {
body = {
entry_type: 'quick_add',
snapshot_food_name: r.raw_text || 'Quick add',
snapshot_calories: item.calories,
meal_type: resolveMeal,
entry_date: selectedDate,
source: 'web',
raw_text: r.raw_text,
};
} else if (r.matched_food) {
body = {
food_id: r.matched_food.id,
quantity: item.qty,
unit: item.unit,
meal_type: resolveMeal,
entry_date: selectedDate,
entry_method: r.resolution_type === 'ai_estimated' ? 'ai_label' : 'search',
source: 'web',
raw_text: r.raw_text,
confidence_score: r.confidence,
note: r.note || undefined,
snapshot_food_name_override: r.snapshot_name_override || undefined,
};
}
if (body) {
await fetch('/api/fitness/entries', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
}
resolvedItems = [];
resolveInput = '';
await loadDayData();
} catch { /* silent */ }
confirmLogging = false;
}
function cancelResolve() {
resolvedItems = [];
resolveError = '';
}
// ── Food Edit/Delete ──
let editingFood = $state<any>(null);
let editFoodName = $state('');
let editFoodCal = $state('');
let editFoodProtein = $state('');
let editFoodCarbs = $state('');
let editFoodFat = $state('');
let editFoodUnit = $state('');
let savingFood = $state(false);
function openFoodEdit(food: FoodItem) {
editingFood = food;
editFoodName = food.name;
editFoodCal = String(food.calories);
editFoodProtein = '';
editFoodCarbs = '';
editFoodFat = '';
editFoodUnit = food.info.split('·').pop()?.trim() || 'serving';
// Fetch full food data for macros
fetch(`/api/fitness/foods/${food.id}`, { credentials: 'include' })
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
editFoodProtein = String(Math.round(data.protein_per_base || 0));
editFoodCarbs = String(Math.round(data.carbs_per_base || 0));
editFoodFat = String(Math.round(data.fat_per_base || 0));
editFoodUnit = data.base_unit || 'serving';
}
})
.catch(() => {});
}
function closeFoodEdit() {
editingFood = null;
}
async function saveFoodEdit() {
if (!editingFood) return;
savingFood = true;
try {
await fetch(`/api/fitness/foods/${editingFood.id}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editFoodName.trim(),
calories_per_base: parseFloat(editFoodCal) || 0,
protein_per_base: parseFloat(editFoodProtein) || 0,
carbs_per_base: parseFloat(editFoodCarbs) || 0,
fat_per_base: parseFloat(editFoodFat) || 0,
}),
});
editingFood = null;
await loadFoods(foodSearch || undefined);
} catch { /* silent */ }
savingFood = false;
}
async function deleteFood() {
if (!editingFood) return;
savingFood = true;
try {
await fetch(`/api/fitness/foods/${editingFood.id}`, {
method: 'DELETE',
credentials: 'include',
});
editingFood = null;
await loadFoods(foodSearch || undefined);
} catch { /* silent */ }
savingFood = false;
}
// Reload day data when date changes
$effect(() => {
selectedDate; // track
loadDayData();
});
// Reload foods when search changes (debounced)
let foodSearchTimeout: ReturnType<typeof setTimeout>;
$effect(() => {
const q = foodSearch;
clearTimeout(foodSearchTimeout);
foodSearchTimeout = setTimeout(() => loadFoods(q || undefined), q ? 300 : 0);
});
onMount(() => {
loadTemplates();
});
function jumpToTemplates() {
activeTab = 'templates';
if (rankedTemplates.length > 0) {
highlightTemplate = rankedTemplates[0].name;
setTimeout(() => { highlightTemplate = ''; }, 2000);
}
}
const filteredFoods = $derived(foods);
const totals = $derived({
calories: entries.reduce((s, e) => s + e.calories, 0),
protein: entries.reduce((s, e) => s + e.protein, 0),
carbs: entries.reduce((s, e) => s + e.carbs, 0),
fat: entries.reduce((s, e) => s + e.fat, 0),
count: entries.length
});
const caloriesRemaining = $derived(goal.calories > 0 ? Math.max(goal.calories - totals.calories, 0) : 0);
const caloriesPercent = $derived(goal.calories > 0 ? Math.round((totals.calories / goal.calories) * 100) : 0);
const isToday = $derived(selectedDate === todayStr);
function entriesByMeal(meal: MealType) { return entries.filter(e => e.meal === meal); }
function mealCalories(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.calories, 0); }
function mealProtein(meal: MealType) { return entriesByMeal(meal).reduce((s, e) => s + e.protein, 0); }
function toggleMeal(meal: string) {
const next = new Set(expandedMeals);
if (next.has(meal)) next.delete(meal); else next.add(meal);
expandedMeals = next;
}
function shiftDate(days: number) {
const d = new Date(selectedDate + 'T00:00:00');
d.setDate(d.getDate() + days);
selectedDate = d.toISOString().split('T')[0];
}
function formatDate(date: string) {
const d = new Date(date + 'T00:00:00');
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
}
// Action-oriented coaching based on progress
function coachMessage(remaining: number, percent: number): string {
if (percent >= 100) return 'Done for the day — you hit your goal';
if (percent >= 90) return 'Almost done — keep it light';
if (percent >= 75) return 'Finish strong — ' + remaining.toLocaleString() + ' cal left';
if (percent >= 50) return 'You\'ve got room for a solid meal';
if (percent >= 25) return 'On track — plan your next meal';
if (percent > 0) return 'Just getting started — ' + remaining.toLocaleString() + ' cal to work with';
return 'Start logging to see your progress';
}
// Coaching focus — what the user needs most right now
type CoachFocus = 'protein' | 'low_carb' | 'low_fat' | 'light' | 'balanced';
function getCoachFocus(t: typeof totals, g: typeof goal, calLeft: number): CoachFocus {
const proteinPct = t.protein / g.protein;
const carbsPct = t.carbs / g.carbs;
const fatPct = t.fat / g.fat;
if (calLeft < 300) return 'light';
if (proteinPct < 0.7) return 'protein';
if (carbsPct > 0.8 && proteinPct < 0.8) return 'low_carb';
if (fatPct > 0.8) return 'low_fat';
return 'balanced';
}
const coachFocus = $derived(getCoachFocus(totals, goal, caloriesRemaining));
// "Best next move" — realistic meal suggestions
function bestNextMove(t: typeof totals, g: typeof goal, calRemaining: number): string {
if (calRemaining <= 0) return '';
const proteinPct = t.protein / g.protein;
const carbsPct = t.carbs / g.carbs;
const fatPct = t.fat / g.fat;
const proteinLeft = Math.max(g.protein - t.protein, 0);
// Protein-focused — use realistic meal combos
if (proteinPct < 0.4 && proteinLeft > 40) {
const lo = Math.round(proteinLeft * 0.3);
const hi = Math.round(proteinLeft * 0.45);
return `Best next: ~${lo}${hi}g protein (chicken + rice, 3 eggs, or a shake)`;
}
if (proteinPct < 0.6 && proteinLeft > 25) {
const lo = Math.round(proteinLeft * 0.35);
const hi = Math.round(proteinLeft * 0.5);
return `Best next: ~${lo}${hi}g protein (chicken, Greek yogurt, or protein shake)`;
}
if (proteinPct < 0.8 && proteinLeft > 10) {
return `Best next: ~${Math.round(proteinLeft * 0.6)}${proteinLeft}g protein (eggs, yogurt, or tuna)`;
}
// Carbs running high + still need protein
if (carbsPct > 0.8 && proteinPct < 0.75) {
return 'Best next: skip the carbs — chicken salad, eggs, or a protein shake';
}
// Fat running high
if (fatPct > 0.85) {
return 'Best next: lean protein + rice or sweet potato — skip the oils';
}
if (fatPct > 0.7 && carbsPct < 0.5) {
return 'Best next: oatmeal, rice bowl, or pasta with grilled chicken';
}
// Low calories remaining
if (calRemaining < 200) {
return 'Best next: something light — yogurt, fruit, or veggies';
}
if (calRemaining < 400) {
return 'Best next: small meal — salad with chicken, or a shake with fruit';
}
// Everything balanced
return 'Best next: balanced plate — protein, carbs, and veggies';
}
// Macro guidance — considers both target gap and calories budget
function macroInstruction(macro: 'protein' | 'carbs' | 'fat', current: number, target: number, calLeft: number): string {
const pct = Math.round((current / target) * 100);
const left = Math.max(target - current, 0);
if (pct >= 100) return 'On target';
const budgetTight = calLeft < 400;
if (macro === 'protein') {
if (pct < 50 && !budgetTight) return 'Prioritize protein';
if (pct < 50 && budgetTight) return 'Add lean protein';
if (pct < 80) return 'Add protein';
return 'Almost there';
}
if (macro === 'carbs') {
if (pct >= 85) return 'Ease off carbs';
if (pct >= 70) return 'Watch carbs';
return 'Carbs on track';
}
// fat
if (pct >= 85) return 'Keep fats minimal';
if (pct >= 70) return 'Keep fats light';
return 'Fats on track';
}
function macroLeft(current: number, target: number): string {
const left = Math.max(target - current, 0);
if (left === 0) return '';
return `${left}g left`;
}
// Meal weight label — contextual per meal type
function mealWeight(mealCal: number, dailyGoal: number, meal: MealType): string {
if (mealCal === 0) return '';
const pct = Math.round((mealCal / dailyGoal) * 100);
if (meal === 'snack') {
// Snacks: bias toward Light/Snack
if (pct >= 25) return 'Heavy';
if (pct >= 12) return 'Moderate';
return 'Light';
}
if (meal === 'breakfast') {
// Breakfast: cap at Moderate
if (pct >= 30) return 'Moderate';
if (pct >= 12) return 'Light';
return 'Snack';
}
// Lunch / Dinner: normal scale
if (pct >= 35) return 'Heavy';
if (pct >= 20) return 'Moderate';
if (pct >= 8) return 'Light';
return 'Snack';
}
// Template scoring — decisive ranking aligned with coaching focus
function templateScore(tpl: Template, t: typeof totals, g: typeof goal, calLeft: number, focus: CoachFocus): number {
let score = 0;
const hour = new Date().getHours();
const isProteinRich = /protein|chicken|egg|salmon/i.test(tpl.name);
const isCarbHeavy = /oatmeal|rice|pasta/i.test(tpl.name);
// ── Calorie fit (highest weight) ──
if (calLeft > 0) {
if (tpl.calories <= calLeft) {
const ratio = tpl.calories / calLeft;
// Perfect fit: within ±15%
if (ratio >= 0.85 && ratio <= 1.0) score += 50;
// Good fit: within ±30%
else if (ratio >= 0.7) score += 35;
// Usable: under budget but not close
else if (ratio >= 0.4) score += 15;
} else {
// Over remaining — penalize harder
const overBy = (tpl.calories - calLeft) / calLeft;
score -= overBy > 0.3 ? 30 : 15;
}
}
// ── Coaching alignment (reinforce "Best next" direction) ──
if (focus === 'protein' && isProteinRich) score += 35;
if (focus === 'protein' && !isProteinRich) score -= 5;
if (focus === 'low_carb' && isCarbHeavy) score -= 20;
if (focus === 'low_carb' && isProteinRich) score += 20;
if (focus === 'low_fat' && isProteinRich) score += 15;
if (focus === 'light' && tpl.calories < 350) score += 20;
if (focus === 'light' && tpl.calories > 500) score -= 20;
// ── Time-of-day ──
if (tpl.meal === 'breakfast' && hour >= 6 && hour < 11) score += 15;
if (tpl.meal === 'lunch' && hour >= 11 && hour < 15) score += 15;
if (tpl.meal === 'dinner' && hour >= 17 && hour < 21) score += 15;
if (tpl.meal === 'snack' && hour >= 14 && hour < 17) score += 10;
// ── Macro penalties ──
const carbsPct = t.carbs / g.carbs;
const fatPct = t.fat / g.fat;
if (carbsPct > 0.85 && isCarbHeavy) score -= 15;
if (fatPct > 0.85) score -= 5;
return score;
}
// Sorted templates by score (descending)
const rankedTemplates = $derived(
[...templates]
.map(tpl => ({ tpl, score: templateScore(tpl, totals, goal, caloriesRemaining, coachFocus) }))
.sort((a, b) => b.score - a.score)
.map(item => item.tpl)
);
// Template hints — selective, max 3 total, top-ranked only
function templateHints(ranked: Template[], t: typeof totals, g: typeof goal, calLeft: number): Map<string, string> {
const hints = new Map<string, string>();
const hour = new Date().getHours();
const proteinPct = t.protein / g.protein;
const proteinPhrases = ['High protein option', 'Great for protein', 'Helps close your protein gap'];
let proteinIdx = 0;
const MAX_HINTS = 3;
// Only consider top-ranked templates for hints
const candidates = ranked.slice(0, Math.min(ranked.length, 5));
for (const tpl of candidates) {
if (hints.size >= MAX_HINTS) break;
// Calorie fit — tight ±15%
if (calLeft > 0 && tpl.calories <= calLeft) {
const ratio = tpl.calories / calLeft;
if (ratio >= 0.85 && ratio <= 1.0) {
hints.set(tpl.name, 'Fits your remaining calories');
continue;
}
}
// Protein hint — varied phrasing, max 2
const isProteinRich = /protein|chicken|egg|salmon/i.test(tpl.name);
if (isProteinRich && proteinPct < 0.7 && proteinIdx < 2) {
hints.set(tpl.name, proteinPhrases[proteinIdx]);
proteinIdx++;
continue;
}
// Time-of-day — max 1
if (!Array.from(hints.values()).some(h => h.startsWith('Good '))) {
if (tpl.meal === 'breakfast' && hour >= 6 && hour < 11) { hints.set(tpl.name, 'Good for this morning'); continue; }
if (tpl.meal === 'lunch' && hour >= 11 && hour < 15) { hints.set(tpl.name, 'Good for lunch right now'); continue; }
if (tpl.meal === 'dinner' && hour >= 17 && hour < 21) { hints.set(tpl.name, 'Good dinner choice'); continue; }
if (tpl.meal === 'snack' && hour >= 14 && hour < 17) { hints.set(tpl.name, 'Good afternoon pick-me-up'); continue; }
}
}
return hints;
}
const templateHintMap = $derived(templateHints(rankedTemplates, totals, goal, caloriesRemaining));
const mealTypes: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack'];
</script>
<div class="page">
<div class="app-surface">
<!-- Header -->
<div class="page-header">
<div class="page-title">FITNESS</div>
<div class="page-date">{#if isToday}Today{:else}{formatDate(selectedDate)}{/if}</div>
</div>
<!-- Top tabs (subtle) -->
<div class="top-tabs">
<button class="top-tab" class:active={activeTab === 'log'} onclick={() => activeTab = 'log'}>Food Log</button>
<button class="top-tab" class:active={activeTab === 'foods'} onclick={() => activeTab = 'foods'}>Food Library</button>
<button class="top-tab" class:active={activeTab === 'templates'} onclick={() => activeTab = 'templates'}>Quick Meals</button>
</div>
<!-- ═══════════════════════════════ -->
<!-- TAB: Food Log -->
<!-- ═══════════════════════════════ -->
{#if activeTab === 'log'}
<!-- Date nav -->
<div class="date-nav">
<button class="btn-icon" onclick={() => shiftDate(-1)} aria-label="Previous day">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<input type="date" class="date-input" bind:value={selectedDate} />
<button class="btn-icon" onclick={() => shiftDate(1)} aria-label="Next day">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
{#if !isToday}
<button class="today-btn" onclick={() => selectedDate = todayStr}>Today</button>
{/if}
</div>
<!-- Calorie hero -->
<div class="calorie-card">
<div class="calorie-eaten">
<span class="calorie-number">{totals.calories.toLocaleString()}</span>
<span class="calorie-of">/ {goal.calories.toLocaleString()}</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width:{Math.min(caloriesPercent, 100)}%"></div>
</div>
<div class="calorie-coach">{coachMessage(caloriesRemaining, caloriesPercent)}</div>
{#if caloriesRemaining > 0}
{@const hint = bestNextMove(totals, goal, caloriesRemaining)}
{#if hint}
<button class="calorie-next" onclick={jumpToTemplates}>{hint} <span class="next-arrow"></span></button>
{/if}
{/if}
</div>
<!-- AI food input -->
<div class="resolve-bar">
<input
type="text"
class="resolve-input"
placeholder="Describe your meal... (e.g. 2 eggs and toast)"
bind:value={resolveInput}
onkeydown={(e) => { if (e.key === 'Enter') submitResolve(); }}
disabled={resolving}
/>
<button class="resolve-submit" onclick={submitResolve} disabled={resolving || !resolveInput.trim()}>
{#if resolving}
<span class="resolve-spinner"></span>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
{/if}
</button>
</div>
{#if resolveError}
<div class="resolve-error">{resolveError}</div>
{/if}
<!-- Macros — inline row, not cards -->
<div class="macro-row">
<div class="macro-item">
<div class="macro-bar-wrap">
<div class="macro-bar-fill protein" style="width:{Math.min(totals.protein / goal.protein * 100, 100)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.protein}g</span>
<span class="macro-name">Protein</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{macroInstruction('protein', totals.protein, goal.protein, caloriesRemaining)}</div>
<div class="macro-left">{macroLeft(totals.protein, goal.protein)}</div>
</div>
</div>
<div class="macro-item">
<div class="macro-bar-wrap">
<div class="macro-bar-fill carbs" style="width:{Math.min(totals.carbs / goal.carbs * 100, 100)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.carbs}g</span>
<span class="macro-name">Carbs</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{macroInstruction('carbs', totals.carbs, goal.carbs, caloriesRemaining)}</div>
<div class="macro-left">{macroLeft(totals.carbs, goal.carbs)}</div>
</div>
</div>
<div class="macro-item">
<div class="macro-bar-wrap">
<div class="macro-bar-fill fat" style="width:{Math.min(totals.fat / goal.fat * 100, 100)}%"></div>
</div>
<div class="macro-label">
<span class="macro-current">{totals.fat}g</span>
<span class="macro-name">Fat</span>
</div>
<div class="macro-guidance">
<div class="macro-instruction">{macroInstruction('fat', totals.fat, goal.fat, caloriesRemaining)}</div>
<div class="macro-left">{macroLeft(totals.fat, goal.fat)}</div>
</div>
</div>
</div>
<!-- Meals label -->
<div class="meals-divider">
<span class="meals-label">Meals</span>
<span class="meals-count">{totals.count} entries</span>
</div>
<!-- Meal sections -->
{#each mealTypes as meal, i}
{@const mealEntries = entriesByMeal(meal)}
{@const mCal = mealCalories(meal)}
{@const mPro = mealProtein(meal)}
{@const expanded = expandedMeals.has(meal)}
{@const weight = mealWeight(mCal, goal.calories, meal)}
{@const mealPct = mCal > 0 ? Math.round((mCal / goal.calories) * 100) : 0}
<div class="meal-section">
<button class="meal-header" onclick={() => toggleMeal(meal)}>
<div class="meal-number" class:has-entries={mealEntries.length > 0}>{i + 1}</div>
<div class="meal-info">
<div class="meal-top">
<span class="meal-name">{meal}</span>
{#if weight}
<span class="meal-weight {weight.toLowerCase()}">{weight}</span>
{/if}
</div>
{#if mCal > 0}
<span class="meal-stats">{Math.round(mCal)} cal · {mealPct}% of daily · {Math.round(mPro)}g protein</span>
{:else}
<span class="meal-stats empty">No entries yet</span>
{/if}
</div>
<svg class="meal-chevron" class:expanded viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
</button>
{#if expanded}
<div class="meal-entries">
{#if mealEntries.length > 0}
{#each mealEntries as entry}
<div class="entry-wrap">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="entry-row" onclick={() => toggleEntry(entry.id)}>
<div class="entry-info">
<div class="entry-name">
{entry.name}
{#if entry.method === 'ai_plate' || entry.method === 'ai_label'}
<span class="entry-badge ai">AI</span>
{/if}
{#if entry.method === 'quick_add'}
<span class="entry-badge quick">quick</span>
{/if}
</div>
<div class="entry-serving">{entry.serving}</div>
{#if entry.note}
<div class="entry-note">{entry.note}</div>
{/if}
</div>
<div class="entry-cals">
<span class="entry-cal-value">{entry.calories}</span>
<span class="entry-cal-label">cal</span>
</div>
</div>
{#if expandedEntry === entry.id}
<div class="entry-actions">
<div class="entry-qty-control">
<input
type="number"
class="entry-qty-input"
bind:value={editQtyValue}
onkeydown={(e) => { if (e.key === 'Enter') updateEntryQty(entry.id); }}
step="0.5"
min="0.1"
/>
<span class="entry-qty-unit">{entry.rawUnit}</span>
<button class="entry-qty-save" onclick={() => updateEntryQty(entry.id)}>Save</button>
</div>
<button class="entry-delete-btn" onclick={() => deleteEntry(entry.id)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
</div>
{/if}
</div>
{/each}
{/if}
<button class="add-food-btn" onclick={() => { resolveMeal = meal; const input = document.querySelector('.resolve-input') as HTMLInputElement; if (input) { input.focus(); input.placeholder = `Add to ${meal}...`; } }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add to {meal}
</button>
</div>
{/if}
</div>
{/each}
<!-- ═══════════════════════════════ -->
<!-- TAB: Foods (neutral utility) -->
<!-- ═══════════════════════════════ -->
{:else if activeTab === 'foods'}
<div class="foods-nudge">
Looking for a quick option? <button class="nudge-link" onclick={() => activeTab = 'templates'}>Try Quick Meals →</button>
</div>
<div class="list-header">
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input type="text" class="search-input" placeholder="Search food library..." bind:value={foodSearch} />
{#if foodSearch}
<button class="search-clear" onclick={() => foodSearch = ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
</div>
<div class="list-card">
{#each filteredFoods as food (food.name)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="list-row" onclick={() => openFoodEdit(food)}>
<div class="list-row-info">
<div class="list-row-name">
{food.name}
{#if food.favorite}
<svg class="fav-icon" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{/if}
</div>
<div class="list-row-meta">{food.info}</div>
</div>
<div class="list-row-right">
<span class="list-row-value">{food.calories} cal</span>
<svg class="list-row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
{/each}
{#if filteredFoods.length === 0}
<div class="empty-state">No foods found for "{foodSearch}"</div>
{/if}
</div>
<!-- ═══════════════════════════════ -->
<!-- TAB: Templates -->
<!-- ═══════════════════════════════ -->
{:else if activeTab === 'templates'}
<div class="list-header">
<div class="list-count">{#if templatesLoading}Loading...{:else if templates.length === 0}No quick meals yet{:else}{templates.length} go-to meals · ranked for you{/if}</div>
</div>
<div class="list-card">
{#each rankedTemplates as tpl}
{@const hint = templateHintMap.get(tpl.name) || ''}
<div class="list-row" class:highlighted={highlightTemplate === tpl.name}>
<div class="template-badge">{tpl.meal.charAt(0).toUpperCase()}</div>
<div class="list-row-info">
<div class="list-row-name">{tpl.name}</div>
<div class="list-row-meta"><span class="template-meal-label">{tpl.meal}</span> · {tpl.calories} cal</div>
<div class="list-row-sub">{tpl.items} items</div>
{#if hint}
<div class="template-hint">{hint}</div>
{/if}
</div>
<div class="list-row-right">
<button class="log-btn" onclick={() => logTemplate(tpl)}>Log meal</button>
<svg class="list-row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- FAB — always visible -->
<button class="fab" class:open={fabOpen} aria-label="Add" onclick={() => fabOpen = !fabOpen}>
<svg class="fab-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<!-- FAB bottom sheet -->
{#if fabOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fab-overlay" onclick={() => fabOpen = false}></div>
<div class="fab-sheet">
<div class="fab-sheet-handle"></div>
<button class="fab-action" onclick={() => { fabOpen = false; activeTab = 'log'; }}>
<div class="fab-action-icon accent">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
<div class="fab-action-text">
<div class="fab-action-name">Log food</div>
<div class="fab-action-desc">Add an entry to your daily log</div>
</div>
</button>
<button class="fab-action" onclick={() => { fabOpen = false; activeTab = 'foods'; foodSearch = ''; }}>
<div class="fab-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</div>
<div class="fab-action-text">
<div class="fab-action-name">Search food</div>
<div class="fab-action-desc">Find a food in your library</div>
</div>
</button>
<button class="fab-action" onclick={() => { fabOpen = false; activeTab = 'foods'; }}>
<div class="fab-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v6m0 0l3-3m-3 3L9 5"/><rect x="3" y="10" width="18" height="12" rx="2"/></svg>
</div>
<div class="fab-action-text">
<div class="fab-action-name">Create food</div>
<div class="fab-action-desc">Add a new food to your library</div>
</div>
</button>
<button class="fab-action" onclick={() => { fabOpen = false; activeTab = 'templates'; }}>
<div class="fab-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
</div>
<div class="fab-action-text">
<div class="fab-action-name">Create quick meal</div>
<div class="fab-action-desc">Save a reusable meal preset</div>
</div>
</button>
</div>
{/if}
<!-- Food edit modal -->
{#if editingFood}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resolve-overlay" onclick={closeFoodEdit}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resolve-modal" onclick={(e) => e.stopPropagation()}>
<div class="resolve-modal-header">
<div class="resolve-modal-title">Edit Food</div>
<button class="resolve-modal-close" onclick={closeFoodEdit}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="resolve-modal-body">
<div class="food-edit-field">
<label class="food-edit-label">Name</label>
<input class="food-edit-input" type="text" bind:value={editFoodName} />
</div>
<div class="food-edit-row">
<div class="food-edit-field">
<label class="food-edit-label">Calories</label>
<input class="food-edit-input" type="number" bind:value={editFoodCal} />
</div>
<div class="food-edit-field">
<label class="food-edit-label">Protein (g)</label>
<input class="food-edit-input" type="number" bind:value={editFoodProtein} />
</div>
</div>
<div class="food-edit-row">
<div class="food-edit-field">
<label class="food-edit-label">Carbs (g)</label>
<input class="food-edit-input" type="number" bind:value={editFoodCarbs} />
</div>
<div class="food-edit-field">
<label class="food-edit-label">Fat (g)</label>
<input class="food-edit-input" type="number" bind:value={editFoodFat} />
</div>
</div>
<div class="food-edit-unit">Per 1 {editFoodUnit}</div>
</div>
<div class="resolve-modal-footer">
<button class="entry-delete-btn" onclick={deleteFood} disabled={savingFood}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
<div class="resolve-footer-actions">
<button class="btn-secondary" onclick={closeFoodEdit}>Cancel</button>
<button class="btn-primary" onclick={saveFoodEdit} disabled={savingFood}>
{savingFood ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- Resolve confirmation modal -->
{#if resolvedItems.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resolve-overlay" onclick={cancelResolve}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="resolve-modal" onclick={(e) => e.stopPropagation()}>
<div class="resolve-modal-header">
<div class="resolve-modal-title">
{resolvedItems.length === 1 ? 'Confirm entry' : `Confirm ${resolvedItems.length} items`}
</div>
<button class="resolve-modal-close" onclick={cancelResolve}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="resolve-modal-body">
{#each resolvedItems as item, idx}
<div class="resolve-item" class:resolve-item-border={idx > 0}>
<div class="resolve-item-top">
<div class="resolve-item-info">
<div class="resolve-food-name">{item.name}</div>
<div class="resolve-food-detail">{item.calories} cal · {item.protein}g P · {item.carbs}g C · {item.fat}g F</div>
</div>
{#if resolvedItems.length > 1}
<button class="resolve-item-remove" onclick={() => removeItem(idx)} aria-label="Remove">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
<div class="resolve-qty-row">
<button class="resolve-qty-btn" onclick={() => updateItemQty(idx, item.qty - 0.5)} disabled={item.qty <= 0.5}></button>
<span class="resolve-qty-value">{item.qty} {item.unit}</span>
<button class="resolve-qty-btn" onclick={() => updateItemQty(idx, item.qty + 0.5)}>+</button>
</div>
</div>
{/each}
<div class="resolve-meal-row">
<span class="resolve-meal-label">Meal</span>
<select class="resolve-meal-select" bind:value={resolveMeal}>
<option value="breakfast">Breakfast</option>
<option value="lunch">Lunch</option>
<option value="dinner">Dinner</option>
<option value="snack">Snack</option>
</select>
</div>
{#if resolvedItems.some(i => i.result.resolution_type === 'ai_estimated')}
<div class="resolve-note">Some items estimated by AI — values are approximate</div>
{/if}
</div>
<div class="resolve-modal-footer">
<div class="resolve-total">{resolveTotalCal} cal total</div>
<div class="resolve-footer-actions">
<button class="btn-secondary" onclick={cancelResolve}>Cancel</button>
<button class="btn-primary" onclick={confirmResolve} disabled={confirmLogging}>
{confirmLogging ? 'Logging...' : resolvedItems.length === 1 ? 'Log entry' : `Log ${resolvedItems.length} entries`}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
/* ── Top tabs (subtle segmented control) ── */
.top-tabs {
display: flex; gap: var(--sp-0.5); margin-bottom: var(--sp-5);
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.top-tab {
flex: 1; padding: 10px 8px 12px; font-size: var(--text-sm); font-weight: 500;
color: var(--text-3); background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; font-family: var(--font); transition: all var(--transition);
text-align: center; margin-bottom: -1px;
}
.top-tab:hover { color: var(--text-2); }
.top-tab.active { color: var(--text-1); border-bottom-color: var(--accent); font-weight: 600; }
/* ── Header ── */
.page-date { font-size: var(--text-2xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
/* ── Date nav ── */
.date-nav { display: flex; align-items: center; gap: var(--sp-1.5); margin-bottom: var(--sp-5); }
.btn-icon { width: 34px; height: 34px; border-radius: var(--radius-md); background: var(--card); border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-3); transition: all var(--transition); flex-shrink: 0; }
.btn-icon:hover { color: var(--text-1); background: var(--card-hover); }
.btn-icon svg { width: 16px; height: 16px; }
.date-input { padding: 6px 10px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-family: var(--font); }
.today-btn { padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-bg); color: var(--accent); border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.today-btn:hover { opacity: 0.8; }
/* ── Calorie hero ── */
.calorie-card {
background: var(--card); border-radius: var(--radius); padding: var(--sp-6);
border: 1px solid var(--border); box-shadow: var(--card-shadow);
margin-bottom: var(--sp-4);
}
.calorie-eaten { display: flex; align-items: baseline; gap: var(--sp-1.5); margin-bottom: 14px; }
.calorie-number { font-size: var(--text-3xl); font-weight: 300; font-family: var(--mono); color: var(--text-1); line-height: 1; letter-spacing: -0.02em; }
.calorie-of { font-size: var(--text-md); color: var(--text-3); font-family: var(--mono); }
.progress-bar { height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; margin-bottom: var(--sp-3); }
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.6s ease; }
.calorie-coach { font-size: var(--text-md); font-weight: 500; color: var(--text-2); line-height: 1.4; }
.calorie-next {
display: inline-flex; align-items: center; gap: var(--sp-1);
font-size: var(--text-sm); color: var(--accent); margin-top: var(--sp-1.5); font-weight: 500; opacity: 0.85;
background: none; border: none; padding: 0; cursor: pointer;
font-family: var(--font); text-align: left; transition: opacity var(--transition);
}
.calorie-next:hover { opacity: 1; }
.next-arrow { font-size: var(--text-sm); opacity: 0.6; transition: transform var(--transition); }
.calorie-next:hover .next-arrow { transform: translateX(2px); opacity: 1; }
/* ── Macro row (compact, not cards) ── */
.macro-row {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3);
margin-bottom: var(--section-gap);
}
.macro-item {
background: var(--card); border-radius: 10px; padding: 14px;
border: 1px solid var(--border);
}
.macro-bar-wrap { height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-bottom: 10px; }
.macro-bar-fill { height: 100%; border-radius: 2px; transition: width 0.6s ease; }
.macro-bar-fill.protein { background: #8B5CF6; }
.macro-bar-fill.carbs { background: #F59E0B; }
.macro-bar-fill.fat { background: #3B82F6; }
.macro-label { display: flex; align-items: baseline; gap: 5px; margin-bottom: 2px; }
.macro-current { font-size: var(--text-md); font-weight: 500; font-family: var(--mono); color: var(--text-1); letter-spacing: -0.01em; }
.macro-name { font-size: var(--text-sm); color: var(--text-3); }
.macro-guidance { margin-top: var(--sp-2); border-top: 1px solid var(--border); padding-top: var(--sp-2); }
.macro-instruction { font-size: var(--text-sm); font-weight: 500; color: var(--text-2); line-height: 1.3; }
.macro-left { font-size: var(--text-sm); color: var(--text-3); margin-top: 1px; font-family: var(--mono); }
/* ── Meals divider ── */
.meals-divider {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: var(--sp-2); padding-bottom: var(--sp-2);
}
.meals-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; }
.meals-count { font-size: var(--text-sm); color: var(--text-4); }
/* ── Meal sections ── */
.meal-section { margin-bottom: var(--sp-1); }
.meal-header {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: var(--sp-3) 0; background: none; border: none; cursor: pointer;
font-family: var(--font); color: inherit; text-align: left;
}
.meal-number {
width: 32px; height: 32px; border-radius: 50%;
background: var(--card-hover); display: flex; align-items: center; justify-content: center;
font-size: var(--text-sm); font-weight: 600; color: var(--text-4); flex-shrink: 0;
transition: all var(--transition);
}
.meal-number.has-entries { background: var(--accent-bg); color: var(--accent); }
.meal-info { flex: 1; min-width: 0; }
.meal-top { display: flex; align-items: center; gap: var(--sp-2); }
.meal-name { font-size: var(--text-md); font-weight: 600; color: var(--text-1); text-transform: capitalize; }
.meal-weight { font-size: var(--text-xs); font-weight: 600; padding: 1px 7px; border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
.meal-weight.heavy { background: rgba(239,68,68,0.1); color: #DC2626; }
.meal-weight.moderate { background: rgba(245,158,11,0.1); color: #B45309; }
.meal-weight.light { background: rgba(34,197,94,0.1); color: #15803D; }
.meal-weight.snack { background: var(--card-hover); color: var(--text-3); }
.meal-stats { font-size: var(--text-sm); color: var(--text-3); margin-top: 1px; }
.meal-stats.empty { color: var(--text-4); font-style: italic; }
.meal-chevron { width: 16px; height: 16px; color: var(--text-4); transition: transform var(--transition); flex-shrink: 0; }
.meal-chevron.expanded { transform: rotate(180deg); }
.meal-entries { padding-left: var(--sp-4); margin-left: var(--sp-4); border-left: 2px solid var(--border); padding-bottom: var(--sp-1.5); }
.entry-row {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
padding: 11px 14px; border-radius: var(--radius-md); background: var(--card);
border: 1px solid var(--border); margin-bottom: var(--sp-1); transition: all var(--transition);
}
.entry-row:hover { background: var(--card-hover); }
.entry-info { flex: 1; min-width: 0; }
.entry-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); display: flex; align-items: center; gap: var(--sp-1.5); flex-wrap: wrap; }
.entry-badge { font-size: var(--text-xs); font-weight: 600; padding: 1px 6px; border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
.entry-badge.ai { background: rgba(59,130,246,0.1); color: #3B82F6; }
.entry-badge.quick { background: var(--card-hover); color: var(--text-4); }
.entry-serving { font-size: var(--text-sm); color: var(--text-3); margin-top: 1px; }
.entry-note { font-size: var(--text-sm); color: var(--text-3); font-style: italic; margin-top: 1px; }
.entry-wrap { margin-bottom: var(--sp-1); }
.entry-row { cursor: pointer; }
.entry-actions {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-2) var(--sp-3); background: var(--surface-secondary);
border: 1px solid var(--border); border-top: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.entry-wrap:has(.entry-actions) .entry-row {
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.entry-qty-control { display: flex; align-items: center; gap: var(--sp-2); }
.entry-qty-input {
width: 64px; padding: var(--sp-1.5) var(--sp-2); border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--card); color: var(--text-1);
font-size: var(--text-md); font-family: var(--mono); text-align: center;
}
.entry-qty-input:focus { outline: none; border-color: var(--accent); }
.entry-qty-unit { font-size: var(--text-sm); color: var(--text-3); }
.entry-qty-save {
padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md);
background: var(--accent); color: white; border: none;
font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font);
transition: opacity var(--transition); white-space: nowrap;
}
.entry-qty-save:hover { opacity: 0.9; }
.entry-delete-btn {
display: flex; align-items: center; gap: var(--sp-1.5); padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-md); background: none; border: 1px solid var(--error);
color: var(--error); font-size: var(--text-sm); font-weight: 500; cursor: pointer;
font-family: var(--font); transition: all var(--transition); white-space: nowrap;
}
.entry-delete-btn:hover { background: var(--error-dim); }
.entry-delete-btn svg { width: 14px; height: 14px; }
.entry-cals { display: flex; align-items: baseline; gap: 3px; flex-shrink: 0; }
.entry-cal-value { font-size: var(--text-md); font-weight: 600; font-family: var(--mono); color: var(--text-1); }
.entry-cal-label { font-size: var(--text-xs); color: var(--text-3); }
.add-food-btn {
display: flex; align-items: center; gap: var(--sp-1.5);
padding: 10px 14px; margin-top: var(--sp-1); background: none; border: 1px dashed var(--border);
border-radius: var(--radius-md); width: 100%;
font-size: var(--text-sm); font-weight: 500; color: var(--accent); cursor: pointer;
font-family: var(--font); transition: all var(--transition);
}
.add-food-btn:hover { background: var(--accent-bg); border-color: var(--accent); }
.add-food-btn svg { width: 15px; height: 15px; }
/* ── Foods nudge ── */
.foods-nudge { font-size: var(--text-sm); color: var(--text-3); margin-bottom: 14px; }
.nudge-link { background: none; border: none; color: var(--accent); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); padding: 0; transition: opacity var(--transition); }
.nudge-link:hover { opacity: 0.7; }
/* ── Shared list styles (Foods + Templates) ── */
.list-header { display: flex; gap: 10px; align-items: center; margin-bottom: var(--sp-3); }
.list-count { flex: 1; font-size: var(--text-sm); color: var(--text-3); }
.search-wrap { flex: 1; position: relative; }
.search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.search-input { width: 100%; padding: 9px 36px 9px 36px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-family: var(--font); transition: all var(--transition); }
.search-input::placeholder { color: var(--text-4); }
.search-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.search-clear { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: 4px; }
.search-clear svg { width: 14px; height: 14px; }
.btn-primary-sm { padding: 8px 14px; border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); white-space: nowrap; flex-shrink: 0; }
.btn-primary-sm:hover { opacity: 0.9; }
.list-card {
background: var(--card); border-radius: var(--radius); border: 1px solid var(--border);
box-shadow: var(--card-shadow-sm); overflow: hidden;
}
.list-row {
display: flex; align-items: center; gap: 12px;
padding: 13px 16px; border-bottom: 1px solid var(--border); cursor: pointer;
transition: background var(--transition);
}
.list-row:last-child { border-bottom: none; }
.list-row:hover { background: var(--card-hover); }
.list-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.list-row:nth-child(even):hover { background: var(--card-hover); }
.list-row-info { flex: 1; min-width: 0; }
.list-row-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); display: flex; align-items: center; gap: var(--sp-1.5); }
.list-row-meta { font-size: var(--text-sm); color: var(--text-3); margin-top: 1px; }
.list-row-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.list-row-value { font-size: var(--text-sm); font-family: var(--mono); color: var(--text-2); }
.list-row-chevron { width: 14px; height: 14px; color: var(--text-4); opacity: 0.5; }
.list-row:hover .list-row-chevron { opacity: 1; }
.list-row.highlighted { background: var(--accent-bg); border-bottom-color: color-mix(in srgb, var(--accent) 12%, transparent); animation: highlight-pulse 2s ease-out; }
@keyframes highlight-pulse { 0% { box-shadow: 0 0 0 3px rgba(79,70,229,0.15); } 100% { box-shadow: none; } }
.fav-icon { width: 13px; height: 13px; color: #F59E0B; flex-shrink: 0; }
.empty-state { padding: var(--sp-8); text-align: center; color: var(--text-3); font-size: var(--text-base); }
.list-row-sub { font-size: var(--text-xs); color: var(--text-4); margin-top: 1px; }
.template-meal-label { text-transform: capitalize; }
.template-hint { font-size: var(--text-xs); color: var(--accent); margin-top: 3px; font-weight: 500; opacity: 0.75; }
/* Template-specific */
.template-badge {
width: 32px; height: 32px; border-radius: var(--radius-md);
background: var(--accent-bg); color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: var(--text-sm); font-weight: 700; flex-shrink: 0;
}
.log-btn {
padding: 5px 12px; border-radius: var(--radius-sm); background: var(--accent-bg);
color: var(--accent); border: none; font-size: var(--text-sm); font-weight: 600;
cursor: pointer; font-family: var(--font); transition: all var(--transition);
}
.log-btn:hover { background: var(--accent); color: white; }
/* ── FAB ── */
.fab {
position: fixed; bottom: 24px; right: 24px; z-index: 50;
width: 56px; height: 56px; border-radius: 50%;
background: var(--accent); color: white; border: none;
box-shadow: 0 8px 24px rgba(79,70,229,0.3); cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
}
.fab:hover { transform: scale(1.06); box-shadow: 0 12px 32px rgba(79,70,229,0.4); }
.fab-icon { width: 24px; height: 24px; transition: transform 200ms ease; }
.fab.open .fab-icon { transform: rotate(45deg); }
/* ── FAB bottom sheet ── */
.fab-overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 45; animation: fadeIn 150ms ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.fab-sheet {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 48;
background: var(--surface); border-top-left-radius: 20px; border-top-right-radius: 20px;
padding: 12px 16px 28px; box-shadow: 0 -8px 32px rgba(0,0,0,0.12);
animation: slideUp 200ms ease;
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.fab-sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: var(--border-strong); margin: 0 auto var(--sp-4); }
.fab-action {
display: flex; align-items: center; gap: 14px; width: 100%;
padding: 14px 12px; border-radius: var(--radius); background: none; border: none;
cursor: pointer; font-family: var(--font); text-align: left;
transition: background var(--transition);
}
.fab-action:hover { background: var(--card-hover); }
.fab-action-icon {
width: 40px; height: 40px; border-radius: 10px;
background: var(--card); border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: var(--text-3);
}
.fab-action-icon.accent { background: var(--accent); border-color: var(--accent); color: white; }
.fab-action-icon svg { width: 18px; height: 18px; }
.fab-action-text { flex: 1; min-width: 0; }
.fab-action-name { font-size: var(--text-md); font-weight: 500; color: var(--text-1); }
.fab-action-desc { font-size: var(--text-sm); color: var(--text-3); margin-top: 1px; }
/* ── AI Resolve bar ── */
.resolve-bar {
display: flex; gap: var(--sp-2); margin-bottom: var(--sp-4);
}
.resolve-input {
flex: 1; padding: 10px 14px; border-radius: var(--radius); border: 1.5px solid var(--border);
background: var(--card); color: var(--text-1); font-size: var(--text-md); font-family: var(--font);
transition: all var(--transition);
}
.resolve-input::placeholder { color: var(--text-4); }
.resolve-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.resolve-submit {
width: 42px; height: 42px; border-radius: var(--radius); background: var(--accent); color: white;
border: none; display: flex; align-items: center; justify-content: center; cursor: pointer;
transition: opacity var(--transition); flex-shrink: 0;
}
.resolve-submit:disabled { opacity: 0.4; cursor: default; }
.resolve-submit svg { width: 18px; height: 18px; }
.resolve-spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.resolve-error { font-size: var(--text-sm); color: var(--error); margin-top: calc(-1 * var(--sp-3)); margin-bottom: var(--sp-3); }
/* ── Resolve modal ── */
.resolve-overlay {
position: fixed; inset: 0; background: var(--overlay); z-index: 70;
display: flex; align-items: center; justify-content: center; animation: resolveFade 150ms ease;
}
@keyframes resolveFade { from { opacity: 0; } to { opacity: 1; } }
.resolve-modal {
width: 420px; max-width: 92vw; background: var(--surface); border-radius: var(--radius);
box-shadow: var(--shadow-xl); animation: resolveSlide 200ms ease;
}
@keyframes resolveSlide { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.resolve-modal-header {
display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
}
.resolve-modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.resolve-modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.resolve-modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.resolve-modal-close svg { width: 18px; height: 18px; }
.resolve-modal-body { padding: var(--sp-5); }
.resolve-item { padding-bottom: var(--sp-3); }
.resolve-item-border { padding-top: var(--sp-3); border-top: 1px solid var(--border); }
.resolve-item-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-2); }
.resolve-item-info { flex: 1; min-width: 0; }
.resolve-item-remove { background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); border-radius: var(--radius-xs); flex-shrink: 0; }
.resolve-item-remove:hover { color: var(--error); background: var(--error-dim); }
.resolve-item-remove svg { width: 14px; height: 14px; }
.resolve-food-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; }
.resolve-food-detail { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; font-family: var(--mono); }
.resolve-qty-row {
display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2);
}
.resolve-qty-btn {
width: 28px; height: 28px; border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--card); color: var(--text-2); font-size: var(--text-md); font-weight: 600;
display: flex; align-items: center; justify-content: center; cursor: pointer;
transition: all var(--transition);
}
.resolve-qty-btn:hover { background: var(--card-hover); color: var(--text-1); }
.resolve-qty-btn:disabled { opacity: 0.3; cursor: default; }
.resolve-qty-value { font-size: var(--text-sm); font-family: var(--mono); color: var(--text-2); min-width: 60px; text-align: center; }
.resolve-meal-row {
display: flex; align-items: center; justify-content: space-between; margin-top: var(--sp-4);
padding-top: var(--sp-3); border-top: 1px solid var(--border);
}
.resolve-meal-label { font-size: var(--text-sm); color: var(--text-3); }
.resolve-meal-select {
padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border);
background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-family: var(--font); cursor: pointer;
}
.resolve-note { font-size: var(--text-sm); color: var(--text-3); font-style: italic; margin-top: var(--sp-3); }
/* ── Food edit modal ── */
.food-edit-field { display: flex; flex-direction: column; gap: var(--sp-1); }
.food-edit-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); }
.food-edit-input {
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border);
background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-md); font-family: var(--font);
}
.food-edit-input:focus { outline: none; border-color: var(--accent); }
.food-edit-input[type="number"] { font-family: var(--mono); }
.food-edit-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); margin-top: var(--sp-3); }
.food-edit-field:first-child:not(:last-child) { margin-bottom: 0; }
.food-edit-unit { font-size: var(--text-sm); color: var(--text-4); margin-top: var(--sp-3); font-style: italic; }
.resolve-modal-footer {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border);
}
.resolve-total { font-size: var(--text-sm); font-weight: 600; font-family: var(--mono); color: var(--text-1); }
.resolve-footer-actions { display: flex; gap: var(--sp-2); }
/* ── Mobile ── */
@media (max-width: 768px) {
.page-date { font-size: var(--text-xl); }
.calorie-number { font-size: var(--text-3xl); }
.calorie-card { padding: 20px 16px; }
.macro-row { gap: var(--sp-2); }
.macro-item { padding: var(--sp-3); }
.macro-current { font-size: var(--text-base); }
.meal-entries { padding-left: var(--sp-3); margin-left: var(--sp-3); }
.entry-row { padding: 10px 12px; }
.top-tab { font-size: var(--text-sm); }
.list-header { flex-direction: column; }
.fab { bottom: 80px; right: 16px; width: 52px; height: 52px; }
.fab svg { width: 22px; height: 22px; }
}
</style>