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
1606 lines
70 KiB
Svelte
1606 lines
70 KiB
Svelte
<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>
|