Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'; | |
| import { Trash2, Dumbbell, Utensils, User, ChevronRight, ChevronDown, ChevronUp, Activity, X, Droplets, BookOpen, Info, PlusCircle, Sparkles, Camera, ClipboardList, RefreshCw, Copy, Settings, LogOut, ShoppingCart, Loader2, Phone, Plus, Heart, GripVertical, Upload, Video } from 'lucide-react'; | |
| import { GoogleGenerativeAI } from "@google/generative-ai"; | |
| import SignupScreen from './SignupScreen'; | |
| import { WORKOUT_ROUTINES, TRAINING_PLANS } from './workoutData'; | |
| import { getCurrentUser, signInWithGoogle, signOut } from './firebase'; | |
| import { isHealthKitAvailable, requestHealthKitAuthorization, getTodaySummary, getHealthSyncSettings, saveHealthSyncSettings, getAuthorizationStatus, getHealthDataForDate } from './healthKit'; | |
| // --- CONFIGURATION --- | |
| const GEMINI_API_KEY = import.meta.env.VITE_GEMINI_API_KEY; | |
| // Updated Component: Accepts onRemoveWater and splits click area, with drag support | |
| const GlassDashboard = ({ stats, onAddWater, onRemoveWater }) => { | |
| const dragRef = React.useRef({ isDragging: false, startY: 0, lastUpdate: 0, rafId: null }); | |
| const totalPct = Math.min((stats.food / stats.adjustedGoal) * 100, 100); | |
| const waterPct = Math.min((stats.dailyWater / stats.waterGoal) * 100, 100); | |
| const proteinCals = stats.proteinTotal * 4; | |
| const carbsCals = stats.carbsTotal * 4; | |
| const fatCals = stats.fatTotal * 9; | |
| const totalMacroCals = proteinCals + carbsCals + fatCals || 1; | |
| const pPct = (proteinCals / totalMacroCals) * 100; | |
| const cPct = (carbsCals / totalMacroCals) * 100; | |
| const fPct = (fatCals / totalMacroCals) * 100; | |
| // Drag handlers for water - highly optimized with RAF | |
| const handleDragStart = (e) => { | |
| const clientY = e.touches ? e.touches[0].clientY : e.clientY; | |
| dragRef.current.isDragging = true; | |
| dragRef.current.startY = clientY; | |
| dragRef.current.lastUpdate = clientY; | |
| e.preventDefault(); | |
| }; | |
| const handleDragMove = (e) => { | |
| if (!dragRef.current.isDragging) return; | |
| // Cancel any pending animation frame | |
| if (dragRef.current.rafId) { | |
| cancelAnimationFrame(dragRef.current.rafId); | |
| } | |
| // Use RAF for smooth updates | |
| dragRef.current.rafId = requestAnimationFrame(() => { | |
| const clientY = e.touches ? e.touches[0].clientY : e.clientY; | |
| const deltaFromLast = dragRef.current.lastUpdate - clientY; | |
| const threshold = 40; // pixels per water increment | |
| if (Math.abs(deltaFromLast) >= threshold) { | |
| if (deltaFromLast > 0) { | |
| onAddWater(); | |
| } else { | |
| onRemoveWater(); | |
| } | |
| dragRef.current.lastUpdate = clientY; | |
| } | |
| }); | |
| }; | |
| const handleDragEnd = () => { | |
| dragRef.current.isDragging = false; | |
| if (dragRef.current.rafId) { | |
| cancelAnimationFrame(dragRef.current.rafId); | |
| dragRef.current.rafId = null; | |
| } | |
| }; | |
| return ( | |
| <div style={{ | |
| background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)', | |
| borderRadius: '24px', | |
| padding: '3px', | |
| position: 'relative', | |
| marginBottom: '12px' | |
| }}> | |
| {/* Floating orbs */} | |
| <div style={{ position: 'absolute', top: '10%', left: '5%', width: '80px', height: '80px', background: 'rgba(255,255,255,0.2)', borderRadius: '50%', filter: 'blur(25px)' }} /> | |
| <div style={{ position: 'absolute', bottom: '10%', right: '10%', width: '60px', height: '60px', background: 'rgba(255,200,255,0.3)', borderRadius: '50%', filter: 'blur(20px)' }} /> | |
| <div style={{ | |
| background: 'rgba(255,255,255,0.15)', | |
| backdropFilter: 'blur(20px)', | |
| WebkitBackdropFilter: 'blur(20px)', | |
| borderRadius: '21px', | |
| padding: '16px', | |
| border: '1px solid rgba(255,255,255,0.2)', | |
| position: 'relative' | |
| }}> | |
| {/* TOP SECTION: Tighter marginBottom (4px) to reduce overall height */} | |
| <div style={{ | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| marginBottom: '4px', | |
| position: 'relative' | |
| }}> | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}> | |
| {/* Circular progress */} | |
| <div style={{ position: 'relative', width: '150px', height: '150px' }}> | |
| <svg style={{ position: 'absolute', transform: 'rotate(-90deg)' }} width="150" height="150"> | |
| <circle cx="75" cy="75" r="65" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="10" /> | |
| <circle | |
| cx="75" cy="75" r="65" fill="none" | |
| stroke="rgba(255,255,255,0.9)" | |
| strokeWidth="10" | |
| strokeLinecap="round" | |
| strokeDasharray={`${totalPct * 4.08} 408`} | |
| style={{ transition: 'stroke-dasharray 0.5s ease' }} | |
| /> | |
| </svg> | |
| <div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}> | |
| <div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '1px' }}>Calories</div> | |
| <div style={{ fontSize: '42px', fontWeight: '700', color: 'white', lineHeight: 1 }}>{stats.remaining}</div> | |
| <div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', marginTop: '2px' }}>remaining</div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Water Glass - Updated with Click Zones and Drag Support */} | |
| <div style={{ position: 'absolute', right: '8px', top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> | |
| {/* Label "WATER" above the glass */} | |
| <div style={{ fontSize: '10px', color: 'rgba(255,255,255,0.6)', textTransform: 'uppercase', letterSpacing: '1px', marginBottom: '4px', fontWeight: '700' }}>Water</div> | |
| <div | |
| style={{ width: '36px', height: '100px', position: 'relative', touchAction: 'none' }} | |
| onMouseDown={handleDragStart} | |
| onMouseMove={handleDragMove} | |
| onMouseUp={handleDragEnd} | |
| onMouseLeave={handleDragEnd} | |
| onTouchStart={handleDragStart} | |
| onTouchMove={handleDragMove} | |
| onTouchEnd={handleDragEnd} | |
| > | |
| {/* Top Click Zone (Add) - only works when not dragging */} | |
| <div | |
| onClick={(e) => { e.stopPropagation(); if (!dragRef.current.isDragging) onAddWater(); }} | |
| style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', zIndex: 10, cursor: 'pointer' }} | |
| /> | |
| {/* Bottom Click Zone (Remove) - only works when not dragging */} | |
| <div | |
| onClick={(e) => { e.stopPropagation(); if (!dragRef.current.isDragging) onRemoveWater(); }} | |
| style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%', zIndex: 10, cursor: 'pointer' }} | |
| /> | |
| <div style={{ position: 'absolute', inset: 0, background: 'rgba(255,255,255,0.05)', borderRadius: '5px 5px 12px 12px', border: '2px solid rgba(125, 211, 252, 0.4)', overflow: 'hidden', pointerEvents: 'none' }}> | |
| <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: `${waterPct}%`, background: 'linear-gradient(180deg, #7dd3fc 0%, #0ea5e9 50%, #0284c7 100%)', transition: 'height 0.5s cubic-bezier(0.4, 0, 0.2, 1)', borderRadius: '0 0 10px 10px' }} /> | |
| </div> | |
| </div> | |
| {/* Updated Text with 'cups' */} | |
| <div style={{ marginTop: '6px', textAlign: 'center', lineHeight: '1.1' }}> | |
| <div style={{ fontSize: '11px', fontWeight: '700', color: '#7dd3fc' }}> | |
| {stats.dailyWater}<span style={{ opacity: 0.6 }}>/{stats.waterGoal}</span> | |
| </div> | |
| <div style={{ fontSize: '9px', fontWeight: '500', color: '#7dd3fc', opacity: 0.8 }}>cups</div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* PIE CHART ROW - Tighter vertical gap (8px) and vertical labels */} | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, paddingLeft: 4 }}> | |
| <div style={{ position: 'relative', width: 56, height: 56 }}> | |
| <svg width="56" height="56" style={{ transform: 'rotate(-90deg)' }}> | |
| <circle cx="28" cy="28" r="20" fill="none" stroke="#60a5fa" strokeWidth="12" | |
| strokeDasharray={`${pPct * 1.256} 125.6`} strokeDashoffset="0"/> | |
| <circle cx="28" cy="28" r="20" fill="none" stroke="#fb923c" strokeWidth="12" | |
| strokeDasharray={`${cPct * 1.256} 125.6`} strokeDashoffset={`${-pPct * 1.256}`}/> | |
| <circle cx="28" cy="28" r="20" fill="none" stroke="#facc15" strokeWidth="12" | |
| strokeDasharray={`${fPct * 1.256} 125.6`} strokeDashoffset={`${-(pPct + cPct) * 1.256}`}/> | |
| </svg> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 11 }}> | |
| <span style={{ color: '#60a5fa', fontWeight: 800 }}>{Math.round(pPct)}% Protein</span> | |
| <span style={{ color: '#fb923c', fontWeight: 800 }}>{Math.round(cPct)}% Carbs</span> | |
| <span style={{ color: '#facc15', fontWeight: 800 }}>{Math.round(fPct)}% Fat</span> | |
| </div> | |
| </div> | |
| {/* Macro Bars */} | |
| <div style={{ display: 'flex', gap: '6px' }}> | |
| {[ | |
| { label: 'Protein', current: stats.proteinTotal, goal: stats.proteinGoal, color: '#60a5fa' }, | |
| { label: 'Carbs', current: stats.carbsTotal, goal: stats.carbsGoal, color: '#fb923c' }, | |
| { label: 'Fat', current: stats.fatTotal, goal: stats.fatGoal, color: '#facc15' } | |
| ].map((macro, i) => ( | |
| <div key={i} style={{ flex: 1, background: 'rgba(255,255,255,0.08)', borderRadius: '10px', padding: '8px 6px', textAlign: 'center' }}> | |
| <div style={{ fontSize: '9px', color: macro.color, marginBottom: '2px', fontWeight: '600' }}>{macro.label}</div> | |
| <div style={{ fontSize: '13px', fontWeight: '700', color: 'white' }}>{macro.current}<span style={{opacity: 0.5, fontSize: '9px'}}>/{macro.goal}g</span></div> | |
| <div style={{ height: '3px', background: 'rgba(255,255,255,0.1)', borderRadius: '2px', marginTop: '4px', overflow: 'hidden' }}> | |
| <div style={{ height: '100%', width: `${Math.min((macro.current / macro.goal) * 100, 100)}%`, background: macro.color, transition: 'width 0.3s' }} /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default function App() { | |
| // --- STATE: USER PROFILE --- | |
| const [user, setUser] = useState(() => { | |
| const saved = localStorage.getItem('my_profile'); | |
| const loaded = saved ? JSON.parse(saved) : null; | |
| if (loaded) { | |
| // Ensure activityHoursPerWeek defaults to 5 if not present | |
| if (loaded.activityHoursPerWeek === undefined) loaded.activityHoursPerWeek = 5; | |
| // Ensure trainingDifficulty defaults to 'Intermediate' if not present (for backward compatibility) | |
| if (!loaded.trainingDifficulty) loaded.trainingDifficulty = 'Intermediate'; | |
| } | |
| return loaded; | |
| }); | |
| // --- AUTH USER (Firebase) --- | |
| const [authUser, setAuthUser] = useState(() => getCurrentUser()); | |
| const [authLoading, setAuthLoading] = useState(false); | |
| const handleGoogleLogin = async () => { | |
| setAuthLoading(true); | |
| try { | |
| const user = await signInWithGoogle(); | |
| setAuthUser(user); | |
| } catch (err) { | |
| alert('Login failed: ' + err.message); | |
| } | |
| setAuthLoading(false); | |
| }; | |
| const handleLogout = async () => { | |
| await signOut(); | |
| setAuthUser(null); | |
| }; | |
| // --- STATE: DATA --- | |
| const [workouts, setWorkouts] = useState(() => { | |
| const saved = localStorage.getItem('my_workouts'); | |
| return saved ? JSON.parse(saved) : []; | |
| }); | |
| const [meals, setMeals] = useState(() => { | |
| const saved = localStorage.getItem('my_meals'); | |
| return saved ? JSON.parse(saved) : []; | |
| }); | |
| const [water, setWater] = useState(() => { | |
| const saved = localStorage.getItem('my_water'); | |
| if (saved) { | |
| const parsed = JSON.parse(saved); | |
| if (Array.isArray(parsed)) return parsed; | |
| if (parsed.date) return [parsed]; | |
| } | |
| return []; | |
| }); | |
| // --- STATE: HIDDEN HISTORY --- | |
| const [hiddenHistory, setHiddenHistory] = useState(() => { | |
| const saved = localStorage.getItem('my_hidden_history'); | |
| return saved ? JSON.parse(saved) : []; | |
| }); | |
| // --- STATE: UI & FORMS --- | |
| const [selectedDate, setSelectedDate] = useState(new Date().toLocaleDateString()); | |
| const [modal, setModal] = useState(null); | |
| // Default workout tab is now 'plan' | |
| const [workoutTab, setWorkoutTab] = useState('plan'); | |
| const [foodTab, setFoodTab] = useState('plan'); | |
| const [searchHistory, setSearchHistory] = useState(''); | |
| const [planTab, setPlanTab] = useState('Push'); | |
| const [selectedRoutineId, setSelectedRoutineId] = useState('train-right'); | |
| const [showRoutinePicker, setShowRoutinePicker] = useState(false); | |
| const [exerciseSearchResults, setExerciseSearchResults] = useState([]); | |
| const [exerciseSearching, setExerciseSearching] = useState(false); | |
| const [showDoctorsOrders, setShowDoctorsOrders] = useState(false); | |
| // NEW: Profile Modal State | |
| const [showProfileMenu, setShowProfileMenu] = useState(false); | |
| const [customCalInput, setCustomCalInput] = useState(null); | |
| const [showCallWebView, setShowCallWebView] = useState(false); | |
| // Health Sync State | |
| const [healthSyncAvailable, setHealthSyncAvailable] = useState(false); | |
| const [healthSyncSettings, setHealthSyncSettings] = useState(getHealthSyncSettings()); | |
| const [healthSyncLoading, setHealthSyncLoading] = useState(false); | |
| const [healthData, setHealthData] = useState(null); | |
| // NEW: Workout Mode State (Strength vs Cardio) | |
| const [workoutMode, setWorkoutMode] = useState('Strength'); | |
| // trainingDifficulty removed from here, now in user object | |
| const [activePlanExercises, setActivePlanExercises] = useState([]); | |
| const [aiLoading, setAiLoading] = useState(false); | |
| const [regeneratingIndex, setRegeneratingIndex] = useState(-1); | |
| // Workout Analysis State | |
| const [analysisVideo, setAnalysisVideo] = useState(null); | |
| const [analysisDescription, setAnalysisDescription] = useState(''); | |
| const [analysisResult, setAnalysisResult] = useState(null); | |
| const [analysisLoading, setAnalysisLoading] = useState(false); | |
| const videoInputRef = useRef(null); | |
| // Load meal plan from storage | |
| const [generatedMealPlan, setGeneratedMealPlan] = useState(() => { | |
| const saved = localStorage.getItem('my_ai_plan'); | |
| return saved ? JSON.parse(saved) : null; | |
| }); | |
| const [onboardForm, setOnboardForm] = useState({ name: '', age: '', weight: '', feet: '', inches: '', goal: 'lose' }); | |
| // UPDATED: Defaults set to 3 sets / 6 reps | |
| const [wForm, setWForm] = useState({ exercise: '', weight: '', sets: '3', reps: '6', cals: '', time: '', effort: 'High' }); | |
| const [mForm, setMForm] = useState({ item: '', cals: '', protein: '', carbs: '', fat: '', servings: 1 }); | |
| const fileInputRef = useRef(null); | |
| const scrollRef = useRef(null); | |
| // --- EFFECT: PERSIST DATA --- | |
| useEffect(() => { localStorage.setItem('my_profile', JSON.stringify(user)); }, [user]); | |
| useEffect(() => { localStorage.setItem('my_workouts', JSON.stringify(workouts)); }, [workouts]); | |
| useEffect(() => { localStorage.setItem('my_meals', JSON.stringify(meals)); }, [meals]); | |
| useEffect(() => { localStorage.setItem('my_water', JSON.stringify(water)); }, [water]); | |
| useEffect(() => { localStorage.setItem('my_hidden_history', JSON.stringify(hiddenHistory)); }, [hiddenHistory]); | |
| useEffect(() => { localStorage.setItem('my_ai_plan', JSON.stringify(generatedMealPlan)); }, [generatedMealPlan]); | |
| // --- EFFECT: HEALTH SYNC INITIALIZATION --- | |
| useEffect(() => { | |
| const initHealthSync = async () => { | |
| const available = await isHealthKitAvailable(); | |
| setHealthSyncAvailable(available); | |
| if (available && healthSyncSettings.enabled) { | |
| // Sync inline — can't call syncHealthDataNow() here because | |
| // healthSyncAvailable state hasn't re-rendered yet | |
| setHealthSyncLoading(true); | |
| try { | |
| const dataByDate = {}; | |
| const promises = []; | |
| for (let i = 0; i < 7; i++) { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - i); | |
| const dateStr = d.toLocaleDateString(); | |
| promises.push( | |
| getHealthDataForDate(d) | |
| .then(data => { dataByDate[dateStr] = data; }) | |
| ); | |
| } | |
| await Promise.all(promises); | |
| setHealthData(dataByDate); | |
| setHealthSyncSettings(prev => { | |
| const updated = { ...prev, lastSync: new Date().toISOString() }; | |
| saveHealthSyncSettings(updated); | |
| return updated; | |
| }); | |
| } catch (e) { | |
| console.error('Health auto-sync error:', e); | |
| } | |
| setHealthSyncLoading(false); | |
| } | |
| }; | |
| initHealthSync(); | |
| }, []); | |
| // --- EFFECT: LOAD PLAN --- | |
| useEffect(() => { | |
| const routine = WORKOUT_ROUTINES.find(r => r.id === selectedRoutineId) || WORKOUT_ROUTINES[0]; | |
| if (routine.hasSplits) { | |
| const activityHours = user?.activityHoursPerWeek || 0; | |
| const difficulty = getTrainingDifficulty(activityHours); | |
| const splits = routine.splits[difficulty] || routine.splits['Intermediate']; | |
| if (splits && splits[planTab]) { | |
| setActivePlanExercises(splits[planTab]); | |
| } else { | |
| setActivePlanExercises(splits?.Push || []); | |
| } | |
| } else { | |
| setActivePlanExercises(routine.exercises || []); | |
| } | |
| setExerciseSearchResults([]); | |
| }, [planTab, user, selectedRoutineId]); | |
| // --- EFFECT: SCROLL TO END (TODAY) --- | |
| useLayoutEffect(() => { | |
| if (scrollRef.current) { | |
| scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; | |
| setTimeout(() => { | |
| if(scrollRef.current) { | |
| scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; | |
| } | |
| }, 100); | |
| } | |
| }, []); | |
| // --- LOGIC: DATE & HISTORY --- | |
| const getItemsForDate = (list, dateStr) => { | |
| return list.filter(item => item.date === dateStr); | |
| }; | |
| const getWaterForDate = (dateStr) => { | |
| const entry = water.find(w => w.date === dateStr); | |
| return entry ? entry.count : 0; | |
| }; | |
| const getPastDays = () => { | |
| const days = []; | |
| for (let i = 0; i < 14; i++) { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - i); | |
| days.push(d); | |
| } | |
| return days.reverse(); | |
| }; | |
| // --- LOGIC: HELPER FUNCTIONS --- | |
| // Calculate TDEE multiplier based on activity hours per week (Katch-McArdle) | |
| // Linear interpolation for smooth, responsive changes | |
| const getTDEEMultiplier = (activityHours) => { | |
| if (activityHours === 0) return 1.15; // Sedentary | |
| if (activityHours <= 3) { | |
| // Linear from 1.2 to 1.35 over 1-3 hours | |
| return 1.2 + ((activityHours - 1) / 2) * 0.15; | |
| } | |
| if (activityHours <= 6) { | |
| // Linear from 1.4 to 1.55 over 4-6 hours | |
| return 1.4 + ((activityHours - 4) / 2) * 0.15; | |
| } | |
| if (activityHours <= 9) { | |
| // Linear from 1.6 to 1.75 over 7-9 hours | |
| return 1.6 + ((activityHours - 7) / 2) * 0.15; | |
| } | |
| // Linear from 1.8 to 1.95 for 10-15 hours, capped at 1.95 | |
| return Math.min(1.95, 1.8 + ((activityHours - 10) / 5) * 0.15); | |
| }; | |
| // Map activity hours to training difficulty for backward compatibility | |
| const getTrainingDifficulty = (activityHours) => { | |
| if (activityHours <= 3) return 'Beginner'; | |
| if (activityHours <= 6) return 'Intermediate'; | |
| return 'Advanced'; | |
| }; | |
| // --- HEALTH SYNC FUNCTIONS --- | |
| const syncHealthDataNow = async () => { | |
| if (!healthSyncAvailable) return; | |
| setHealthSyncLoading(true); | |
| try { | |
| // Fetch last 7 days of health data | |
| const dataByDate = {}; | |
| const promises = []; | |
| for (let i = 0; i < 7; i++) { | |
| const d = new Date(); | |
| d.setDate(d.getDate() - i); | |
| const dateStr = d.toLocaleDateString(); | |
| promises.push( | |
| getHealthDataForDate(d) | |
| .then(data => { dataByDate[dateStr] = data; }) | |
| ); | |
| } | |
| await Promise.all(promises); | |
| setHealthData(dataByDate); | |
| // Use functional update to avoid stale closure overwriting enabled flag | |
| setHealthSyncSettings(prev => { | |
| const updated = { ...prev, lastSync: new Date().toISOString() }; | |
| saveHealthSyncSettings(updated); | |
| return updated; | |
| }); | |
| } catch (e) { | |
| console.error('Health sync error:', e); | |
| } | |
| setHealthSyncLoading(false); | |
| }; | |
| const enableHealthSync = async () => { | |
| if (healthSyncLoading) return; // Prevent double-tap | |
| setHealthSyncLoading(true); | |
| try { | |
| const authorized = await requestHealthKitAuthorization(); | |
| if (authorized) { | |
| setHealthSyncSettings(prev => { | |
| const updated = { ...prev, enabled: true }; | |
| saveHealthSyncSettings(updated); | |
| return updated; | |
| }); | |
| await syncHealthDataNow(); | |
| } else { | |
| alert('Health permissions were not granted. Please enable them in Settings > Privacy > Health.'); | |
| } | |
| } catch (e) { | |
| console.error('Failed to enable health sync:', e); | |
| alert('Failed to connect to Apple Health. Please try again.'); | |
| } | |
| setHealthSyncLoading(false); | |
| }; | |
| const disableHealthSync = () => { | |
| setHealthSyncSettings(prev => { | |
| const updated = { ...prev, enabled: false }; | |
| saveHealthSyncSettings(updated); | |
| return updated; | |
| }); | |
| setHealthData(null); | |
| }; | |
| // --- LOGIC: CALCULATIONS --- | |
| const calculateStats = () => { | |
| if (!user) return { | |
| baseGoal: 2000, adjustedGoal: 2000, food: 0, exercise: 0, remaining: 2000, waterGoal: 8, | |
| proteinTotal: 0, carbsTotal: 0, fatTotal: 0, proteinGoal: 0, carbsGoal: 0, fatGoal: 0, | |
| dietLabel: "Maintenance", dailyWater: 0, bmi: 0, dailyTotalBurned: 0, | |
| tdeeActivityBudget: 0, activityBudgetFilled: 0, bonusWork: 0, sedentaryBurn: 0, dailyDeficit: 0, weeklyPounds: 0, hasHealthCalories: false | |
| }; | |
| const totalInches = (parseInt(user.feet) * 12) + parseInt(user.inches); | |
| const bmi = totalInches > 0 ? ((parseInt(user.weight) / (totalInches * totalInches)) * 703).toFixed(1) : 0; | |
| // Calculate BMR and TDEE using activity-based multiplier | |
| const bmr = Math.round(parseInt(user.weight) * 10); // Simplified BMR approximation | |
| const activityHours = user.activityHoursPerWeek || 0; | |
| const activityMultiplier = getTDEEMultiplier(activityHours); | |
| const sedentaryBurn = Math.round(bmr * 1.15); // True sedentary TDEE (no formal exercise) | |
| const expectedActivityTDEE = Math.round(bmr * activityMultiplier); // TDEE with user's activity level | |
| const tdeeActivityBudget = expectedActivityTDEE - sedentaryBurn; // Expected activity calories | |
| let baseGoal; | |
| if (user.customCalories) { | |
| baseGoal = parseInt(user.customCalories); | |
| } else { | |
| const maintenance = Math.round(parseInt(user.weight) * 15); | |
| baseGoal = maintenance; | |
| if (user.goal === 'lose' || user.plan === 'lose') baseGoal -= 500; | |
| if (user.goal === 'gain' || user.plan === 'gain') baseGoal += 300; | |
| } | |
| let proteinRatio = 0.3, carbRatio = 0.45, fatRatio = 0.25, dietLabel = "Maintenance"; | |
| if (user.goal === 'lose' || user.plan === 'lose') { | |
| proteinRatio = 0.40; carbRatio = 0.40; fatRatio = 0.20; dietLabel = "Cutting (40/40/20)"; | |
| } else if (user.goal === 'gain' || user.plan === 'gain') { | |
| proteinRatio = 0.25; carbRatio = 0.55; fatRatio = 0.20; dietLabel = "Bulking (25/55/20)"; | |
| } | |
| const todaysMeals = getItemsForDate(meals, selectedDate); | |
| const todaysWorkouts = getItemsForDate(workouts, selectedDate); | |
| const dailyWater = getWaterForDate(selectedDate); | |
| const waterGoal = Math.ceil((parseInt(user.weight) * 0.5) / 8); | |
| const foodTotal = todaysMeals.reduce((sum, m) => sum + Number(m.cals || 0), 0); | |
| const proteinTotal = todaysMeals.reduce((sum, m) => sum + Number(m.protein || 0), 0); | |
| const carbsTotal = todaysMeals.reduce((sum, m) => sum + Number(m.carbs || 0), 0); | |
| const fatTotal = todaysMeals.reduce((sum, m) => sum + Number(m.fat || 0), 0); | |
| const exerciseTotal = todaysWorkouts.reduce((sum, w) => sum + Number(w.cals || 0), 0); | |
| // Use Apple Health active calories when available for any synced date | |
| const healthDayData = healthData && healthSyncSettings.enabled ? healthData[selectedDate] : null; | |
| const healthActiveCalories = healthDayData ? (healthDayData.activeCalories || 0) : 0; | |
| const hasHealthCalories = healthActiveCalories > 0; | |
| const realActivityCalories = hasHealthCalories ? healthActiveCalories : exerciseTotal; | |
| const activityBudgetFilled = Math.min(tdeeActivityBudget, realActivityCalories); | |
| const bonusWork = Math.max(0, realActivityCalories - tdeeActivityBudget); | |
| // Daily burn: if Apple Health connected, use real calories; otherwise use TDEE activity estimate | |
| const dailyTotalBurned = sedentaryBurn + (hasHealthCalories ? realActivityCalories : Math.max(tdeeActivityBudget, realActivityCalories)); | |
| const adjustedGoal = baseGoal; | |
| const remaining = adjustedGoal - foodTotal; | |
| const proteinGoal = Math.round((adjustedGoal * proteinRatio) / 4); | |
| const carbsGoal = Math.round((adjustedGoal * carbRatio) / 4); | |
| const fatGoal = Math.round((adjustedGoal * fatRatio) / 9); | |
| const dailyDeficit = dailyTotalBurned - adjustedGoal; | |
| const weeklyPounds = (dailyDeficit / 500).toFixed(1); | |
| return { | |
| bmi, dailyTotalBurned, sedentaryBurn, adjustedGoal: baseGoal, | |
| tdeeActivityBudget, activityBudgetFilled, bonusWork, hasHealthCalories, | |
| healthDayData, realActivityCalories, | |
| food: foodTotal, remaining, waterGoal, dailyWater, | |
| proteinTotal, carbsTotal, fatTotal, proteinGoal, carbsGoal, fatGoal, | |
| dietLabel, dailyDeficit, weeklyPounds | |
| }; | |
| }; | |
| const stats = calculateStats(); | |
| // --- AI LOGIC: GENERATORS --- | |
| const handleAILookup = async () => { | |
| if (!GEMINI_API_KEY) return alert("API Key missing! Add VITE_GEMINI_API_KEY to HF Secrets."); | |
| if (!mForm.item) return alert("Please type a food name first!"); | |
| setAiLoading(true); | |
| try { | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const prompt = `Estimate the nutrition facts for 1 standard serving of: "${mForm.item}". | |
| Return ONLY a JSON object (no markdown) with these keys: cals, protein, carbs, fat. All values should be numbers (integers).`; | |
| const result = await model.generateContent(prompt); | |
| const response = await result.response; | |
| const text = response.text(); | |
| const jsonStr = text.replace(/```json|```/g, '').trim(); | |
| const data = JSON.parse(jsonStr); | |
| // Update state with base values stored | |
| setMForm(prev => ({ | |
| ...prev, | |
| ...data, | |
| baseCals: data.cals, | |
| baseProtein: data.protein, | |
| baseCarbs: data.carbs, | |
| baseFat: data.fat | |
| })); | |
| setAiLoading(false); | |
| return data; | |
| } catch (error) { | |
| console.error("AI Error:", error); | |
| alert("AI Error. Check console."); | |
| setAiLoading(false); | |
| return null; | |
| } | |
| }; | |
| // --- NEW: REGENERATE SINGLE EXERCISE --- | |
| const handleRegenerateExercise = async (index, exercise) => { | |
| if (!GEMINI_API_KEY) return alert("API Key missing!"); | |
| setRegeneratingIndex(index); | |
| try { | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const activityHours = user?.activityHoursPerWeek || 0; | |
| const difficulty = getTrainingDifficulty(activityHours); | |
| const prompt = `Suggest ONE alternative ${difficulty} level exercise to replace "${exercise.name}" for a ${planTab} workout. | |
| Return ONLY a JSON object with keys: name, sets, reps, notes.`; | |
| const result = await model.generateContent(prompt); | |
| const response = await result.response; | |
| const text = response.text(); | |
| const jsonStr = text.replace(/```json|```/g, '').trim(); | |
| const newData = JSON.parse(jsonStr); | |
| const newPlan = [...activePlanExercises]; | |
| newPlan[index] = newData; | |
| setActivePlanExercises(newPlan); | |
| } catch (error) { | |
| console.error("Regen Error", error); | |
| alert("Failed to swap exercise."); | |
| } | |
| setRegeneratingIndex(-1); | |
| }; | |
| const removeExerciseFromPlan = (index) => { | |
| const newPlan = [...activePlanExercises]; | |
| newPlan.splice(index, 1); | |
| setActivePlanExercises(newPlan); | |
| }; | |
| const handleGenerateMealPlan = async () => { | |
| if (!GEMINI_API_KEY) return alert("API Key missing!"); | |
| setAiLoading(true); | |
| try { | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const prompt = `Create a 1-day meal plan for a person with these targets: | |
| ${stats.adjustedGoal} Calories, ${stats.proteinGoal}g Protein, ${stats.carbsGoal}g Carbs, ${stats.fatGoal}g Fat. | |
| CRITICAL RULES: | |
| 1. Keep it "EASY AND SIMPLE". Use convenience foods. | |
| 2. NO complex cooking instructions (e.g. "8 scrambled egg whites"). | |
| 3. YES to: "2 Starbucks Egg Bites", "1 Cup Liquid Egg Whites", "Rotisserie Chicken", "Pre-cooked Rice", "Greek Yogurt". | |
| 4. "Item" field must be the simple food name + quantity (e.g. "2 Hard Boiled Eggs"). | |
| Return ONLY a JSON array of 4 objects (Breakfast, Lunch, Dinner, Snack). | |
| Each object must have: label, item, cals, protein, carbs, fat.`; | |
| const result = await model.generateContent(prompt); | |
| const response = await result.response; | |
| const text = response.text(); | |
| const jsonStr = text.replace(/```json|```/g, '').trim(); | |
| const data = JSON.parse(jsonStr); | |
| setGeneratedMealPlan(data); | |
| } catch (error) { | |
| console.error("Meal Plan Error", error); | |
| alert("Could not generate plan."); | |
| } | |
| setAiLoading(false); | |
| }; | |
| const handleRegenerateSingleMeal = async (index, currentMeal) => { | |
| if (!GEMINI_API_KEY) return alert("API Key missing!"); | |
| setRegeneratingIndex(index); | |
| try { | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const prompt = `Suggest ONE alternative meal option for ${currentMeal.label} that replaces "${currentMeal.item}". | |
| Targets: ${currentMeal.cals} calories, ${currentMeal.protein}g protein, ${currentMeal.carbs}g carbs, ${currentMeal.fat}g fat. | |
| KEEP IT SIMPLE/CONVENIENCE FOOD (e.g. "Protein Shake", "Pre-made Wrap"). No cooking. | |
| IMPORTANT: The 'item' field must be a comma-separated list of ingredients. | |
| Return ONLY a JSON object with keys: label, item, cals, protein, carbs, fat.`; | |
| const result = await model.generateContent(prompt); | |
| const response = await result.response; | |
| const text = response.text(); | |
| const jsonStr = text.replace(/```json|```/g, '').trim(); | |
| const newData = JSON.parse(jsonStr); | |
| const newPlan = [...generatedMealPlan]; | |
| newPlan[index] = { ...newData, label: currentMeal.label }; | |
| setGeneratedMealPlan(newPlan); | |
| } catch (error) { | |
| console.error("Regen Error", error); | |
| alert("Failed to refresh meal."); | |
| } | |
| setRegeneratingIndex(-1); | |
| }; | |
| const handleWorkoutAILookup = async () => { | |
| // REMOVED: setAiLoading(true) and API call logic from here | |
| let targetExercise = wForm.exercise; | |
| let targetSets = wForm.sets; | |
| let targetReps = wForm.reps; | |
| let targetWeight = wForm.weight; | |
| if (!targetExercise) { | |
| const todaysLogs = getItemsForDate(workouts, selectedDate); | |
| // FIX: Use activePlanExercises instead of trainingPlan | |
| const dailyPlan = activePlanExercises; | |
| const nextUp = dailyPlan.find(ex => | |
| !todaysLogs.some(log => log.exercise === ex.name) | |
| ); | |
| if (nextUp) { | |
| targetExercise = nextUp.name; | |
| targetSets = nextUp.sets; | |
| targetReps = nextUp.reps; | |
| const history = getLastLog(nextUp.name); | |
| if (history) targetWeight = history.weight; | |
| // ONLY update the text fields, do not trigger AI yet | |
| setWForm(prev => ({ | |
| ...prev, | |
| exercise: targetExercise, | |
| sets: targetSets, | |
| reps: targetReps, | |
| weight: targetWeight, | |
| cals: '' // Keep cals empty so submit triggers the AI | |
| })); | |
| updateLastLog(targetExercise); | |
| } else { | |
| alert(`All ${planTab} exercises completed for today!`); | |
| } | |
| } | |
| // Note: We don't return AI data here anymore because we aren't calling the AI here | |
| }; | |
| const calculateWorkoutCalories = async (data) => { | |
| if (!GEMINI_API_KEY) return { cals: 0 }; | |
| setAiLoading(true); | |
| try { | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const userWeight = user?.weight || 150; | |
| // ROBUST AI CALORIE CALCULATOR | |
| const prompt = `Estimate calories burned for a person weighing ${userWeight} lbs performing: "${data.exercise}". | |
| Details: | |
| ${data.sets ? `- ${data.sets} sets of ${data.reps} reps at ${data.weight} lbs` : ''} | |
| ${data.time ? `- ${data.time} minutes at ${data.effort} intensity` : ''} | |
| CRITICAL: Return ONLY a valid JSON object. No other text. | |
| Format: { "cals": 120 }`; | |
| const result = await model.generateContent(prompt); | |
| const response = await result.response; | |
| const text = response.text(); | |
| // Improved Parsing: Find the JSON object inside potential extra text | |
| const jsonMatch = text.match(/\{[\s\S]*\}/); | |
| if (jsonMatch) { | |
| const jsonStr = jsonMatch[0]; | |
| return JSON.parse(jsonStr); | |
| } else { | |
| throw new Error("No JSON found in response"); | |
| } | |
| } catch (error) { | |
| console.error("AI Error:", error); | |
| // FALLBACK FORMULA IF AI FAILS | |
| const MET = data.effort === 'High' ? 8 : 4; | |
| const duration = data.time ? parseInt(data.time) : (parseInt(data.sets) * 3); // Est 3 mins per set | |
| const weightKg = (user?.weight || 150) / 2.2; | |
| const fallbackCals = Math.round((MET * 3.5 * weightKg / 200) * duration); | |
| return { cals: fallbackCals }; | |
| } finally { | |
| setAiLoading(false); | |
| } | |
| }; | |
| const handleImageUpload = async (e) => { | |
| if (!GEMINI_API_KEY) return alert("API Key missing! Add VITE_GEMINI_API_KEY to HF Secrets."); | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| setAiLoading(true); | |
| try { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(file); | |
| reader.onloadend = async () => { | |
| const base64Data = reader.result.split(',')[1]; | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const imagePart = { | |
| inlineData: { data: base64Data, mimeType: file.type }, | |
| }; | |
| const prompt = "Identify this food and estimate nutrition for 1 standard serving. Return ONLY a JSON object with keys: item (short name), cals, protein, carbs, fat."; | |
| const result = await model.generateContent([prompt, imagePart]); | |
| const response = await result.response; | |
| const text = response.text(); | |
| const jsonStr = text.replace(/```json|```/g, '').trim(); | |
| const data = JSON.parse(jsonStr); | |
| setMForm(data); | |
| setAiLoading(false); | |
| } | |
| } catch (error) { | |
| console.error("Image AI Error:", error); | |
| alert("Could not analyze image."); | |
| setAiLoading(false); | |
| } | |
| }; | |
| // --- WORKOUT VIDEO ANALYSIS --- | |
| const handleWorkoutAnalysis = async () => { | |
| if (!analysisVideo) return alert("Please upload a video first."); | |
| if (!analysisDescription.trim()) return alert("Please describe what sport or workout is being performed."); | |
| if (!GEMINI_API_KEY) return alert("API Key missing! Add VITE_GEMINI_API_KEY to HF Secrets."); | |
| setAnalysisLoading(true); | |
| setAnalysisResult(null); | |
| try { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(analysisVideo); | |
| reader.onloadend = async () => { | |
| try { | |
| const base64Data = reader.result.split(',')[1]; | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const videoPart = { | |
| inlineData: { data: base64Data, mimeType: analysisVideo.type }, | |
| }; | |
| const prompt = `You are an expert sports and fitness coach. Analyze this workout/sports video. | |
| The user describes the activity as: "${analysisDescription}" | |
| Analyze the person's form, technique, and overall performance. Return ONLY a JSON object with these keys: | |
| { | |
| "score": <number 1-5>, | |
| "summary": "<1-2 sentence overall assessment>", | |
| "strengths": ["<specific thing done well>", "<another strength>", "<another strength>"], | |
| "improvements": ["<specific actionable improvement>", "<another improvement>", "<another improvement>"], | |
| "tips": "<one key coaching tip to focus on next session>" | |
| } | |
| SCORING GUIDE: | |
| 1 = Needs significant work on fundamentals | |
| 2 = Below average, several form issues | |
| 3 = Average, decent form with room to improve | |
| 4 = Good form, minor adjustments needed | |
| 5 = Excellent technique and execution | |
| Be specific and reference what you actually see in the video. Focus on form, technique, range of motion, tempo, and safety.`; | |
| const result = await model.generateContent([prompt, videoPart]); | |
| const response = await result.response; | |
| const text = response.text(); | |
| const jsonStr = text.replace(/```json|```/g, '').trim(); | |
| const data = JSON.parse(jsonStr); | |
| setAnalysisResult(data); | |
| } catch (err) { | |
| console.error("Gemini analysis error:", err); | |
| alert("Analysis failed. The video may be too large — try a shorter clip (under 30 seconds)."); | |
| } | |
| setAnalysisLoading(false); | |
| }; | |
| } catch (error) { | |
| console.error("Video read error:", error); | |
| alert("Could not read video file."); | |
| setAnalysisLoading(false); | |
| } | |
| }; | |
| // --- HELPER FOR UPDATING SERVINGS --- | |
| const updateServings = (delta) => { | |
| setMForm(prev => { | |
| const newServings = Math.max(0.5, prev.servings + delta); | |
| if (newServings === prev.servings) return prev; | |
| // Determine base values (per 1 serving) | |
| // If we haven't stored base values yet, derive them from current visible values | |
| const bCals = prev.baseCals ?? (Number(prev.cals) / prev.servings); | |
| const bP = prev.baseProtein ?? (Number(prev.protein) / prev.servings); | |
| const bC = prev.baseCarbs ?? (Number(prev.carbs) / prev.servings); | |
| const bF = prev.baseFat ?? (Number(prev.fat) / prev.servings); | |
| return { | |
| ...prev, | |
| servings: newServings, | |
| baseCals: bCals, | |
| baseProtein: bP, | |
| baseCarbs: bC, | |
| baseFat: bF, | |
| cals: Math.round(bCals * newServings) || '', | |
| protein: Math.round(bP * newServings) || '', | |
| carbs: Math.round(bC * newServings) || '', | |
| fat: Math.round(bF * newServings) || '' | |
| }; | |
| }); | |
| }; | |
| // --- ACTIONS --- | |
| const handleOnboardSubmit = (e) => { | |
| e.preventDefault(); | |
| if(!onboardForm.name || !onboardForm.weight) return; | |
| setUser(onboardForm); | |
| }; | |
| // NEW: Profile Actions | |
| const handleSaveCalories = () => { | |
| const newCals = parseInt(customCalInput); | |
| if(newCals > 500 && newCals < 10000) { | |
| setUser({...user, customCalories: newCals}); | |
| setCustomCalInput(null); | |
| alert("Base calorie goal updated!"); | |
| } else { | |
| alert("Please enter a valid calorie number."); | |
| } | |
| }; | |
| const handleResetCalories = () => { | |
| const { customCalories, ...rest } = user; | |
| setUser(rest); | |
| alert("Reverted to automatic calculation based on weight."); | |
| }; | |
| // 2. UPDATE ADD FUNCTION (Include 'sets') | |
| const addWorkout = async (e) => { | |
| e.preventDefault(); | |
| if (!wForm.exercise) return; | |
| let finalCals = wForm.cals; | |
| // 1. Trigger AI calculation if calories are missing | |
| if (finalCals === '' || finalCals === 0) { | |
| const aiResult = await calculateWorkoutCalories(wForm); | |
| finalCals = aiResult.cals; | |
| } | |
| // 2. Prepare the workout object | |
| const workoutEntry = { | |
| id: wForm.id || Date.now(), // Use existing ID if editing | |
| date: selectedDate, | |
| exercise: wForm.exercise, | |
| weight: wForm.weight, | |
| sets: wForm.sets, | |
| reps: wForm.reps, | |
| cals: Number(finalCals || 0), | |
| time: wForm.time, | |
| effort: wForm.effort, | |
| type: workoutMode // Saving the mode (Strength/Cardio) | |
| }; | |
| // 3. Update or Add | |
| if (wForm.id) { | |
| setWorkouts(workouts.map(w => w.id === wForm.id ? workoutEntry : w)); | |
| } else { | |
| setWorkouts([workoutEntry, ...workouts]); | |
| } | |
| // 4. Reset form and close modal | |
| setWForm({ exercise: '', weight: '', sets: '3', reps: '6', cals: '', time: '', effort: 'High' }); | |
| setLastLog(null); | |
| setModal(null); | |
| }; | |
| // --- HELPER: GET LAST WORKOUT STATS --- | |
| const getLastLog = (exerciseName) => { | |
| if (!exerciseName) return null; | |
| // Filter history for this exercise, sort by newest (descending ID) | |
| const history = workouts | |
| .filter(w => w.exercise && w.exercise.toLowerCase().trim() === exerciseName.toLowerCase().trim()) | |
| .sort((a, b) => b.id - a.id); | |
| return history.length > 0 ? history[0] : null; | |
| }; | |
| // derived state for the UI — only update on blur to prevent layout jitter while typing | |
| const [lastLog, setLastLog] = useState(null); | |
| // Update lastLog when exercise name is finalized (blur or programmatic set) | |
| const updateLastLog = (name) => setLastLog(getLastLog(name)); | |
| // 3. UPDATE LOG FROM PLAN (Accept full exercise object) | |
| const logFromPlan = (ex) => { | |
| setWForm({ | |
| exercise: ex.name, | |
| weight: '', | |
| sets: ex.sets, // Auto-fills sets | |
| reps: ex.reps, // Auto-fills reps | |
| cals: '' | |
| }); | |
| updateLastLog(ex.name); | |
| setWorkoutTab('log'); | |
| }; | |
| // LOG ALL: Add every exercise in the current plan to today's workouts | |
| const logAllFromPlan = async () => { | |
| if (activePlanExercises.length === 0) return; | |
| setAiLoading(true); | |
| const entries = activePlanExercises.map((ex, idx) => ({ | |
| id: Date.now() + idx, | |
| date: selectedDate, | |
| exercise: ex.name, | |
| weight: '', | |
| sets: ex.sets, | |
| reps: ex.reps, | |
| cals: 0, | |
| time: '', | |
| effort: 'High', | |
| type: 'Strength' | |
| })); | |
| setWorkouts([...entries, ...workouts]); | |
| setAiLoading(false); | |
| setModal(null); | |
| }; | |
| const logMealFromPlan = (meal) => { | |
| setMForm({ | |
| item: meal.item, | |
| cals: meal.cals, | |
| protein: meal.protein, | |
| carbs: meal.carbs, | |
| fat: meal.fat | |
| }); | |
| setFoodTab('log'); | |
| }; | |
| const editItem = (item) => { | |
| if (item.exercise) { | |
| // Fill Workout Form | |
| setWForm({ | |
| id: item.id, // Store ID so we know which one to update | |
| exercise: item.exercise, | |
| weight: item.weight, | |
| sets: item.sets, | |
| reps: item.reps, | |
| cals: item.cals | |
| }); | |
| setModal('workout'); | |
| setWorkoutTab('log'); | |
| } else { | |
| // Fill Food Form | |
| setMForm({ | |
| id: item.id, // Store ID so we know which one to update | |
| item: item.item, | |
| cals: item.cals, | |
| protein: item.protein, | |
| carbs: item.carbs, | |
| fat: item.fat, | |
| servings: item.servings || 1 | |
| }); | |
| setModal('food'); | |
| setFoodTab('log'); | |
| } | |
| }; | |
| const addMeal = async (e) => { | |
| e.preventDefault(); | |
| if (!mForm.item) return; | |
| let finalData = { ...mForm }; | |
| const servingMultiplier = parseFloat(mForm.servings) || 1; | |
| // 1. If calories are empty, fetch from AI first | |
| if (mForm.cals === '') { | |
| const aiData = await handleAILookup(); | |
| if (!aiData) return; | |
| // Note: aiData is per 1 serving. Since we are auto-fetching right before add, | |
| // we must scale it here. If cals were not empty, they would already be scaled in UI. | |
| finalData = { | |
| ...finalData, | |
| ...aiData, | |
| cals: Math.round(aiData.cals * servingMultiplier), | |
| protein: Math.round(aiData.protein * servingMultiplier), | |
| carbs: Math.round(aiData.carbs * servingMultiplier), | |
| fat: Math.round(aiData.fat * servingMultiplier) | |
| }; | |
| } | |
| // 2. Prepare the meal object with scaled values | |
| // UPDATE: We now assume 'finalData' (from mForm) ALREADY contains the scaled totals | |
| // (unless we just fetched it above). So we remove the '* servingMultiplier' here. | |
| const mealEntry = { | |
| id: mForm.id || Date.now(), // Use existing ID if editing | |
| date: selectedDate, | |
| item: servingMultiplier > 1 ? `${finalData.item} (x${servingMultiplier})` : finalData.item, | |
| cals: Math.round(Number(finalData.cals || 0)), | |
| protein: Math.round(Number(finalData.protein || 0)), | |
| carbs: Math.round(Number(finalData.carbs || 0)), | |
| fat: Math.round(Number(finalData.fat || 0)), | |
| servings: servingMultiplier | |
| }; | |
| // 3. Update or Add | |
| if (mForm.id) { | |
| setMeals(meals.map(m => m.id === mForm.id ? mealEntry : m)); | |
| } else { | |
| setMeals([mealEntry, ...meals]); | |
| } | |
| // 4. Reset form and close modal | |
| setMForm({ item: '', cals: '', protein: '', carbs: '', fat: '', servings: 1 }); | |
| setModal(null); | |
| }; | |
| // Add meal from history directly to today | |
| const addMealFromHistory = (meal) => { | |
| const mealEntry = { | |
| id: Date.now(), | |
| date: selectedDate, | |
| item: meal.item, | |
| cals: Math.round(Number(meal.cals || 0)), | |
| protein: Math.round(Number(meal.protein || 0)), | |
| carbs: Math.round(Number(meal.carbs || 0)), | |
| fat: Math.round(Number(meal.fat || 0)), | |
| servings: meal.servings || 1 | |
| }; | |
| setMeals([mealEntry, ...meals]); | |
| }; | |
| // Add all meals from a date in history | |
| const addAllMealsFromHistory = (mealsToAdd) => { | |
| const newEntries = mealsToAdd.map((meal, idx) => ({ | |
| id: Date.now() + idx, | |
| date: selectedDate, | |
| item: meal.item, | |
| cals: Math.round(Number(meal.cals || 0)), | |
| protein: Math.round(Number(meal.protein || 0)), | |
| carbs: Math.round(Number(meal.carbs || 0)), | |
| fat: Math.round(Number(meal.fat || 0)), | |
| servings: meal.servings || 1 | |
| })); | |
| setMeals([...newEntries, ...meals]); | |
| }; | |
| const addWorkoutFromHistory = (workout) => { | |
| const workoutEntry = { | |
| id: Date.now(), | |
| date: selectedDate, | |
| exercise: workout.exercise, | |
| type: workout.type, | |
| sets: workout.sets, | |
| reps: workout.reps, | |
| weight: workout.weight, | |
| time: workout.time, | |
| effort: workout.effort | |
| }; | |
| setWorkouts([workoutEntry, ...workouts]); | |
| }; | |
| // Add all workouts from a date in history | |
| const addAllWorkoutsFromHistory = (workoutsToAdd) => { | |
| const newEntries = workoutsToAdd.map((workout, idx) => ({ | |
| id: Date.now() + idx, | |
| date: selectedDate, | |
| exercise: workout.exercise, | |
| type: workout.type, | |
| sets: workout.sets, | |
| reps: workout.reps, | |
| weight: workout.weight, | |
| time: workout.time, | |
| effort: workout.effort | |
| })); | |
| setWorkouts([...newEntries, ...workouts]); | |
| }; | |
| const addWater = () => { | |
| const existingIndex = water.findIndex(w => w.date === selectedDate); | |
| if (existingIndex >= 0) { | |
| const newWater = [...water]; | |
| newWater[existingIndex].count += 1; | |
| setWater(newWater); | |
| } else { | |
| setWater([...water, { date: selectedDate, count: 1 }]); | |
| } | |
| }; | |
| const removeWater = () => { | |
| const existingIndex = water.findIndex(w => w.date === selectedDate); | |
| if (existingIndex >= 0) { | |
| const newWater = [...water]; | |
| if (newWater[existingIndex].count > 0) { | |
| newWater[existingIndex].count -= 1; | |
| setWater(newWater); | |
| } | |
| } | |
| }; | |
| const deleteItem = (id, type) => { | |
| if (type === 'workout') setWorkouts(workouts.filter(w => w.id !== id)); | |
| if (type === 'meal') setMeals(meals.filter(m => m.id !== id)); | |
| }; | |
| // --- NEW: DELETE HISTORY ITEM (HIDES FROM VIEW) --- | |
| const deleteHistoryItem = (itemName, type) => { | |
| if(confirm(`Hide "${itemName}" from your history list? This will NOT delete past logs.`)) { | |
| setHiddenHistory([...hiddenHistory, itemName]); | |
| } | |
| } | |
| const logout = () => { | |
| if(confirm("Are you sure you want to delete all profile data and logs?")) { | |
| setUser(null); | |
| setMeals([]); | |
| setWorkouts([]); | |
| setWater([]); | |
| setHiddenHistory([]); // Clear hidden history on logout | |
| setOnboardForm({ name: '', age: '', weight: '', feet: '', inches: '', goal: 'lose' }); | |
| setShowProfileMenu(false); | |
| } | |
| }; | |
| // --- UI HELPERS --- | |
| const TabButton = ({ active, label, onClick }) => ( | |
| <button onClick={onClick} style={{flex: 1, padding: '10px', background: active ? '#2563eb' : '#f3f4f6', color: active ? 'white' : '#4b5563', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: '600', transition: 'all 0.2s', fontSize: '13px'}}> | |
| {label} | |
| </button> | |
| ); | |
| // --- NEW COMPONENT: SINGLE SHOPPING LIST ROW --- | |
| const ShoppingListRow = ({ item, onLog, onDelete }) => { | |
| // REMOVE ANYTHING IN BRACKETS FOR SEARCH QUERY | |
| const cleanItem = item.replace(/\s*\(.*?\)\s*/g, '').trim(); | |
| const instacartUrl = `https://www.instacart.com/store/s?k=${encodeURIComponent(cleanItem)}`; | |
| return ( | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid #f9fafb'}}> | |
| <span style={{fontSize: '13px', color: '#374151', fontWeight: '500', textAlign: 'left', flex: 1, paddingRight: '8px'}}>{item.trim()}</span> | |
| <div style={{display: 'flex', gap: '4px', flexShrink: 0}}> | |
| <button onClick={() => onLog(item)} style={{background: 'none', border: '1px solid #e5e7eb', borderRadius: '6px', cursor: 'pointer', color: '#2563eb', padding: '6px'}} title="Log Item"> | |
| <PlusCircle size={14} /> | |
| </button> | |
| <a href={instacartUrl} target="_blank" rel="noopener noreferrer" style={{background: '#ecfdf5', border: '1px solid #a7f3d0', borderRadius: '6px', color: '#059669', display: 'flex', alignItems: 'center', padding: '6px'}} title="Search on Instacart"> | |
| <ShoppingCart size={14} /> | |
| </a> | |
| <button onClick={onDelete} style={{background: 'none', border: '1px solid #fee2e2', borderRadius: '6px', cursor: 'pointer', color: '#ef4444', padding: '6px'}} title="Remove Item"> | |
| <Trash2 size={14} /> | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // --- STYLES --- | |
| const container = { maxWidth: '480px', margin: '0 auto', fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif', padding: '16px', paddingBottom: '100px', minHeight: 'auto', background: '#f5f5f7', position: 'relative' }; | |
| const card = { background: 'white', borderRadius: '14px', padding: '14px', boxShadow: '0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.06)', marginBottom: '10px' }; | |
| const input = { width: '100%', padding: '12px', borderRadius: '10px', border: '1px solid #e5e7eb', marginBottom: '10px', fontSize: '16px', boxSizing: 'border-box' }; | |
| const btnPrimary = { width: '100%', padding: '14px', borderRadius: '12px', border: 'none', background: '#2563eb', color: 'white', fontSize: '16px', fontWeight: '600', cursor: 'pointer', display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px' }; | |
| const btnAI = { padding: '10px', borderRadius: '10px', border: 'none', background: '#eff6ff', color: '#2563eb', fontSize: '13px', fontWeight: '600', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '5px', whiteSpace: 'nowrap' }; | |
| // NEW: Add exercise to plan via AI search | |
| const [addExerciseInput, setAddExerciseInput] = useState(''); | |
| const [addingExercise, setAddingExercise] = useState(false); | |
| const handleSearchExercises = async () => { | |
| if (!addExerciseInput.trim()) return; | |
| if (!GEMINI_API_KEY) { | |
| // Fallback: just add it directly | |
| setActivePlanExercises([...activePlanExercises, { name: addExerciseInput, sets: '3', reps: '8-10', notes: '' }]); | |
| setAddExerciseInput(''); | |
| return; | |
| } | |
| setExerciseSearching(true); | |
| setExerciseSearchResults([]); | |
| try { | |
| const genAI = new GoogleGenerativeAI(GEMINI_API_KEY); | |
| const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" }); | |
| const difficulty = getTrainingDifficulty(user?.activityHoursPerWeek || 0); | |
| const prompt = `Suggest 4 exercises matching "${addExerciseInput}" for a ${difficulty} level trainee. Include variations and alternatives. Return ONLY a JSON array of objects with keys: name, sets, reps, notes. Keep notes to 1 short tip each.`; | |
| const result = await model.generateContent(prompt); | |
| const text = result.response.text().replace(/```json|```/g, '').trim(); | |
| const suggestions = JSON.parse(text); | |
| setExerciseSearchResults(Array.isArray(suggestions) ? suggestions : [suggestions]); | |
| } catch (e) { | |
| console.error('Exercise search error:', e); | |
| setActivePlanExercises([...activePlanExercises, { name: addExerciseInput, sets: '3', reps: '8-10', notes: '' }]); | |
| setAddExerciseInput(''); | |
| } | |
| setExerciseSearching(false); | |
| }; | |
| const addSearchResultToPlan = (exercise) => { | |
| setActivePlanExercises([...activePlanExercises, exercise]); | |
| setExerciseSearchResults([]); | |
| setAddExerciseInput(''); | |
| }; | |
| const bottomNav = { position: 'fixed', bottom: 0, left: '50%', transform: 'translateX(-50%)', width: '100%', maxWidth: '480px', background: 'rgba(255,255,255,0.92)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', padding: '8px 16px 20px', display: 'flex', justifyContent: 'space-around', borderTop: '1px solid rgba(0,0,0,0.06)', zIndex: 10 }; | |
| const navBtn = (isActive) => ({ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px', background: 'none', border: 'none', cursor: 'pointer', fontSize: '10px', fontWeight: '600', color: isActive ? '#2563eb' : '#8e8e93', transition: 'color 0.2s', letterSpacing: '0.2px' }); | |
| const navIconBox = (color, isActive) => ({ background: isActive ? color : '#e5e7eb', padding: '8px', borderRadius: '12px', color: 'white', transition: 'all 0.2s', transform: isActive ? 'scale(1.05)' : 'scale(1)' }); | |
| const modalOverlay = { position: 'fixed', top: 0, left: 0, right: 0, height: '100%', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', WebkitBackdropFilter: 'blur(4px)', zIndex: 50, display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden' }; | |
| const modalContent = { background: 'white', width: '100%', maxWidth: '480px', borderRadius: '0', padding: '20px', paddingTop: '20px', animation: 'slideUp 0.3s cubic-bezier(0.32, 0.72, 0, 1)', flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }; | |
| // --- RENDER: ONBOARDING --- | |
| if (!user) { | |
| return <SignupScreen onComplete={(data) => setUser(data)} />; | |
| } | |
| // --- RENDER: MAIN APP --- | |
| return ( | |
| <div style={container}> | |
| {/* HEADER: Profile Avatar + Welcome + Date Scroller */} | |
| <header style={{display:'flex', alignItems:'center', gap: '12px', marginBottom:'16px'}}> | |
| {/* Profile Avatar Button */} | |
| <button | |
| onClick={() => setShowProfileMenu(true)} | |
| style={{ | |
| background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', | |
| border: 'none', | |
| cursor: 'pointer', | |
| width: '42px', | |
| height: '42px', | |
| borderRadius: '12px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| color: 'white', | |
| fontSize: '18px', | |
| fontWeight: '800', | |
| flexShrink: 0, | |
| boxShadow: '0 4px 6px -1px rgba(37, 99, 235, 0.2)' | |
| }} | |
| > | |
| {user.name.charAt(0).toUpperCase()} | |
| </button> | |
| {/* Welcome Text - Updated to split name at space */} | |
| <div style={{flexShrink: 0, marginRight: '4px'}}> | |
| <h1 style={{fontSize:'16px', margin:0, fontWeight: '700', lineHeight: 1.2}}> | |
| Hello, {user.name.split(' ')[0]} | |
| </h1> | |
| <p style={{color:'#6b7280', margin:0, fontSize:'10px', fontWeight: '500', textTransform: 'uppercase', letterSpacing: '0.5px'}}> | |
| Today's Overview | |
| </p> | |
| </div> | |
| {/* DATE SCROLLER (Pushed to Right) */} | |
| <div | |
| ref={scrollRef} | |
| style={{flex: 1, display: 'flex', overflowX: 'auto', gap: '6px', scrollbarWidth: 'none', alignItems: 'center', paddingLeft: '4px'}} | |
| > | |
| {getPastDays().map((dateObj) => { | |
| const dateStr = dateObj.toLocaleDateString(); | |
| const isSelected = selectedDate === dateStr; | |
| const isToday = dateStr === new Date().toLocaleDateString(); | |
| const dayName = dateObj.toLocaleDateString('en-US', { weekday: 'short' }); | |
| const dayNum = dateObj.getDate(); | |
| return ( | |
| <button | |
| key={dateStr} | |
| onClick={() => setSelectedDate(dateStr)} | |
| style={{ | |
| minWidth: '36px', | |
| padding: '6px 0', | |
| borderRadius: '8px', | |
| border: isSelected ? '2px solid #2563eb' : '1px solid #e5e7eb', | |
| background: isSelected ? '#eff6ff' : 'white', | |
| color: isSelected ? '#2563eb' : '#6b7280', | |
| display: 'flex', flexDirection: 'column', alignItems: 'center', | |
| cursor: 'pointer', flexShrink: 0, | |
| transition: 'all 0.2s' | |
| }} | |
| > | |
| <span style={{fontSize: '9px', fontWeight: '600', textTransform: 'uppercase'}}>{isToday ? 'Today' : dayName}</span> | |
| <span style={{fontSize: '13px', fontWeight: '700'}}>{dayNum}</span> | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| </header> | |
| {/* --- PROFILE MODAL --- */} | |
| {showProfileMenu && ( | |
| <div style={modalOverlay} onClick={(e) => { if(e.target === e.currentTarget) setShowProfileMenu(false) }}> | |
| <div style={{...modalContent, paddingBottom: '30px', overflowY: 'auto'}}> | |
| <div style={{display: 'flex', justifyContent: 'space-between', marginBottom: '20px', alignItems: 'center'}}> | |
| <div style={{display: 'flex', alignItems: 'center', gap: '12px'}}> | |
| <div style={{ | |
| background: 'linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%)', | |
| width: '40px', height: '40px', borderRadius: '10px', | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| color: 'white', fontWeight: '800', fontSize: '18px' | |
| }}> | |
| {user.name.charAt(0).toUpperCase()} | |
| </div> | |
| <h2 style={{margin: 0, fontSize: '18px', fontWeight: '700'}}>Profile Settings</h2> | |
| </div> | |
| <button onClick={() => setShowProfileMenu(false)} style={{background: '#f3f4f6', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '50%', display: 'flex'}}><X size={20} color="#374151" /></button> | |
| </div> | |
| {/* STATS HIGHLIGHTS - 3 COLUMN BREAKDOWN */} | |
| <div style={{display: 'flex', gap: '8px', marginBottom: '20px'}}> | |
| {/* Column 1: BMI */} | |
| <div style={{flex: 1, background: '#f8fafc', padding: '12px 4px', borderRadius: '12px', border: '1px solid #e2e8f0', textAlign: 'center'}}> | |
| <div style={{fontSize: '9px', color: '#64748b', textTransform: 'uppercase', fontWeight: 'bold', marginBottom: '4px'}}>Current BMI</div> | |
| <div style={{fontSize: '16px', fontWeight: '800', color: '#2563eb'}}>{stats.bmi}</div> | |
| </div> | |
| {/* Column 2: DAILY BURN BREAKDOWN */} | |
| <div style={{flex: 1.5, background: '#f8fafc', padding: '12px 4px', borderRadius: '12px', border: '1px solid #e2e8f0', textAlign: 'center'}}> | |
| <div style={{fontSize: '9px', color: '#64748b', textTransform: 'uppercase', fontWeight: 'bold', marginBottom: '4px'}}>Daily Burn</div> | |
| <div style={{fontSize: '18px', fontWeight: '800', color: '#10b981'}}>{stats.dailyTotalBurned}</div> | |
| <div style={{display: 'flex', flexDirection: 'column', gap: '1px', marginTop: '4px'}}> | |
| <div style={{fontSize: '8px', color: '#64748b'}}> | |
| <span style={{fontWeight: '700'}}>{stats.sedentaryBurn}</span> Sedentary | |
| </div> | |
| <div style={{fontSize: '8px', color: stats.hasHealthCalories ? '#10b981' : '#2563eb'}}> | |
| <span style={{fontWeight: '700'}}>{stats.hasHealthCalories ? stats.realActivityCalories : stats.tdeeActivityBudget}</span> {stats.hasHealthCalories ? 'Active (Health)' : 'TDEE Activity'} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Column 3: WEEKLY TRACK */} | |
| <div style={{flex: 1, background: '#f8fafc', padding: '12px 4px', borderRadius: '12px', border: '1px solid #e2e8f0', textAlign: 'center'}}> | |
| <div style={{fontSize: '9px', color: '#64748b', textTransform: 'uppercase', fontWeight: 'bold', marginBottom: '4px'}}>Weekly Track</div> | |
| <div style={{fontSize: '16px', fontWeight: '800', color: stats.dailyDeficit >= 0 ? '#10b981' : '#ef4444'}}> | |
| {stats.dailyDeficit >= 0 ? `-${stats.weeklyPounds}` : `+${Math.abs(stats.weeklyPounds)}`} <span style={{fontSize: '10px'}}>lbs</span> | |
| </div> | |
| <div style={{fontSize: '8px', color: '#94a3b8', marginTop: '2px'}}>vs TDEE Avg</div> | |
| </div> | |
| </div> | |
| {/* UPDATE WEIGHT */} | |
| <div style={{marginBottom: '20px'}}> | |
| <label style={{display:'block', marginBottom:'8px', fontWeight:'600', fontSize: '14px'}}>Current Weight (lbs)</label> | |
| <input | |
| style={{...input, marginBottom: '4px'}} | |
| type="number" | |
| value={user.weight} | |
| onChange={(e) => setUser({...user, weight: e.target.value})} | |
| placeholder="Weight in lbs" | |
| /> | |
| <p style={{fontSize: '11px', color: '#6b7280'}}>Recalculates BMI and daily calorie targets automatically.</p> | |
| </div> | |
| {/* CALORIE BUDGET */} | |
| <div style={{marginBottom: '20px'}}> | |
| <label style={{display:'block', marginBottom:'8px', fontWeight:'600', fontSize: '14px'}}>Calorie Budget (Base)</label> | |
| <div style={{display: 'flex', gap: '8px'}}> | |
| <input | |
| style={{...input, marginBottom: 0}} | |
| type="number" | |
| placeholder={stats.adjustedGoal} | |
| value={customCalInput !== null ? customCalInput : (user.customCalories ? user.customCalories : stats.adjustedGoal)} | |
| onChange={(e) => setCustomCalInput(e.target.value)} | |
| onFocus={(e) => { if(customCalInput === null) setCustomCalInput(e.target.value); }} | |
| /> | |
| <button onClick={handleSaveCalories} style={{...btnPrimary, width: 'auto', padding: '0 20px'}}>Save</button> | |
| </div> | |
| {user.customCalories && ( | |
| <button onClick={handleResetCalories} style={{marginTop: '8px', fontSize: '12px', color: '#2563eb', background: 'none', border: 'none', cursor: 'pointer', padding: 0}}> | |
| Reset to Automatic Formula | |
| </button> | |
| )} | |
| </div> | |
| {/* ACTIVITY PER WEEK */} | |
| <div style={{marginBottom: '20px'}}> | |
| <label style={{display:'block', marginBottom:'8px', fontWeight:'600', fontSize: '14px'}}>Activity Per Week (hours)</label> | |
| <input | |
| type="number" | |
| min="0" | |
| max="40" | |
| step="0.5" | |
| value={user.activityHoursPerWeek ?? ''} | |
| onChange={(e) => setUser({...user, activityHoursPerWeek: e.target.value === '' ? '' : parseFloat(e.target.value)})} | |
| style={{ | |
| width: '100%', | |
| padding: '12px', | |
| borderRadius: '10px', | |
| border: '1px solid #e5e7eb', | |
| fontSize: '14px', | |
| fontWeight: '600', | |
| color: '#1f2937' | |
| }} | |
| placeholder="Enter hours (e.g., 5)" | |
| /> | |
| </div> | |
| {/* APPLE HEALTH SYNC */} | |
| {healthSyncAvailable && ( | |
| <div style={{marginBottom: '20px'}}> | |
| <label style={{display:'block', marginBottom:'8px', fontWeight:'600', fontSize: '14px'}}> | |
| <Heart size={16} style={{display: 'inline', marginRight: '6px', verticalAlign: 'text-bottom'}} /> | |
| Apple Health Sync | |
| </label> | |
| {!healthSyncSettings.enabled ? ( | |
| <button | |
| onClick={enableHealthSync} | |
| disabled={healthSyncLoading} | |
| style={{ | |
| width: '100%', | |
| padding: '12px', | |
| borderRadius: '10px', | |
| border: '1px solid #fecaca', | |
| background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)', | |
| color: '#dc2626', | |
| fontSize: '14px', | |
| fontWeight: '600', | |
| cursor: healthSyncLoading ? 'wait' : 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '8px' | |
| }} | |
| > | |
| {healthSyncLoading ? ( | |
| <><Loader2 size={16} style={{animation: 'spin 1s linear infinite'}} /> Connecting...</> | |
| ) : ( | |
| <><Heart size={16} /> Connect to Apple Health</> | |
| )} | |
| </button> | |
| ) : ( | |
| <div style={{ | |
| background: '#f0fdf4', | |
| border: '1px solid #bbf7d0', | |
| borderRadius: '12px', | |
| padding: '12px' | |
| }}> | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px'}}> | |
| <span style={{fontSize: '13px', fontWeight: '600', color: '#16a34a'}}> | |
| Connected | |
| </span> | |
| <button | |
| onClick={disableHealthSync} | |
| style={{ | |
| fontSize: '12px', | |
| color: '#6b7280', | |
| background: 'none', | |
| border: 'none', | |
| cursor: 'pointer', | |
| textDecoration: 'underline' | |
| }} | |
| > | |
| Disconnect | |
| </button> | |
| </div> | |
| {healthData && healthData[new Date().toLocaleDateString()] && (() => { | |
| const todayHealth = healthData[new Date().toLocaleDateString()]; | |
| return ( | |
| <> | |
| <div style={{fontSize: '11px', fontWeight: '600', color: '#374151', marginBottom: '6px'}}>Today</div> | |
| <div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px', marginBottom: '10px'}}> | |
| <div style={{background: 'white', padding: '8px', borderRadius: '8px', textAlign: 'center'}}> | |
| <div style={{fontSize: '18px', fontWeight: '700', color: '#2563eb'}}>{todayHealth.steps?.toLocaleString() || 0}</div> | |
| <div style={{fontSize: '10px', color: '#6b7280', textTransform: 'uppercase'}}>Steps</div> | |
| </div> | |
| <div style={{background: 'white', padding: '8px', borderRadius: '8px', textAlign: 'center'}}> | |
| <div style={{fontSize: '18px', fontWeight: '700', color: '#dc2626'}}>{todayHealth.activeCalories || 0}</div> | |
| <div style={{fontSize: '10px', color: '#6b7280', textTransform: 'uppercase'}}>Active Cal</div> | |
| </div> | |
| <div style={{background: 'white', padding: '8px', borderRadius: '8px', textAlign: 'center'}}> | |
| <div style={{fontSize: '18px', fontWeight: '700', color: '#10b981'}}>{todayHealth.totalCalories || 0}</div> | |
| <div style={{fontSize: '10px', color: '#6b7280', textTransform: 'uppercase'}}>Total Burn</div> | |
| </div> | |
| <div style={{background: 'white', padding: '8px', borderRadius: '8px', textAlign: 'center'}}> | |
| <div style={{fontSize: '18px', fontWeight: '700', color: '#8b5cf6'}}>{todayHealth.distance || 0}</div> | |
| <div style={{fontSize: '10px', color: '#6b7280', textTransform: 'uppercase'}}>Miles</div> | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| })()} | |
| <button | |
| onClick={syncHealthDataNow} | |
| disabled={healthSyncLoading} | |
| style={{ | |
| width: '100%', | |
| padding: '8px', | |
| borderRadius: '8px', | |
| border: '1px solid #bbf7d0', | |
| background: 'white', | |
| color: '#16a34a', | |
| fontSize: '12px', | |
| fontWeight: '600', | |
| cursor: healthSyncLoading ? 'wait' : 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '6px' | |
| }} | |
| > | |
| {healthSyncLoading ? ( | |
| <><Loader2 size={14} style={{animation: 'spin 1s linear infinite'}} /> Syncing...</> | |
| ) : ( | |
| <><RefreshCw size={14} /> Sync Now</> | |
| )} | |
| </button> | |
| {healthSyncSettings.lastSync && ( | |
| <div style={{fontSize: '10px', color: '#6b7280', textAlign: 'center', marginTop: '6px'}}> | |
| Last synced: {new Date(healthSyncSettings.lastSync).toLocaleTimeString()} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <p style={{fontSize: '11px', color: '#6b7280', marginTop: '6px'}}> | |
| Syncs steps, calories burned, and workouts from Apple Health for the last 7 days. | |
| </p> | |
| </div> | |
| )} | |
| <hr style={{border: 'none', borderTop: '1px solid #e5e7eb', margin: '20px 0'}} /> | |
| <button onClick={logout} style={{width: '100%', padding: '12px', borderRadius: '10px', border: '1px solid #fee2e2', background: '#fef2f2', color: '#ef4444', fontSize: '14px', fontWeight: '600', cursor: 'pointer', display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '8px'}}> | |
| <LogOut size={16}/> Logout / Reset All Data | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* --- NEW GLASS MORPHISM DASHBOARD --- */} | |
| <GlassDashboard stats={stats} onAddWater={addWater} onRemoveWater={removeWater} /> | |
| <h3 style={{fontSize:'16px', marginBottom:'10px', fontWeight: '600'}}> | |
| {selectedDate === new Date().toLocaleDateString() ? "Today's Activity" : `Log for ${selectedDate}`} | |
| </h3> | |
| {[...getItemsForDate(workouts, selectedDate), ...getItemsForDate(meals, selectedDate)].length === 0 && !stats.hasHealthCalories && ( | |
| <div style={{textAlign: 'center', color: '#9ca3af', padding: '20px', fontSize: '14px'}}> | |
| No activity logged for this day. Tap below to log! | |
| </div> | |
| )} | |
| {/* Apple Health individual workout entries */} | |
| {stats.hasHealthCalories && stats.healthDayData && stats.healthDayData.workouts && stats.healthDayData.workouts.map((w, i) => { | |
| const typeLabel = (w.type || 'workout').replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim(); | |
| const duration = w.duration ? Math.round(w.duration / 60) : (w.startDate && w.endDate ? Math.round((new Date(w.endDate) - new Date(w.startDate)) / 60000) : null); | |
| const timeStr = w.startDate ? new Date(w.startDate).toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'}) : ''; | |
| return ( | |
| <div key={`health-workout-${i}`} style={{...card, padding:'12px', display:'flex', alignItems:'center', marginBottom:'8px', background: 'linear-gradient(135deg, #eff6ff 0%, #f0f9ff 100%)', border: '1px solid #bfdbfe'}}> | |
| <div style={{display:'flex', gap:'12px', alignItems:'center', flex: 1}}> | |
| <div style={{background: '#dbeafe', padding: '8px', borderRadius: '10px'}}> | |
| <Activity size={18} color="#2563eb"/> | |
| </div> | |
| <div> | |
| <div style={{fontWeight: 600, fontSize: '14px', color: '#1e3a5f', display: 'flex', alignItems: 'center', gap: '6px'}}> | |
| {typeLabel} | |
| <span style={{fontSize: '9px', fontWeight: 600, color: '#10b981', background: '#dcfce7', padding: '1px 5px', borderRadius: '4px'}}>Health</span> | |
| </div> | |
| <div style={{fontSize: '12px', color: '#3b82f6', marginTop: '2px'}}> | |
| {w.calories ? <strong>{Math.round(w.calories)} cal</strong> : null} | |
| {duration ? `${w.calories ? ' • ' : ''}${duration} min` : ''} | |
| {timeStr ? ` • ${timeStr}` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| {/* Apple Health steps/movement entry (non-workout activity) */} | |
| {stats.hasHealthCalories && stats.healthDayData && (stats.healthDayData.steps > 0 || stats.healthDayData.movementCalories > 0) && ( | |
| <div style={{...card, padding:'12px', display:'flex', alignItems:'center', marginBottom:'8px', background: 'linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%)', border: '1px solid #bbf7d0'}}> | |
| <div style={{display:'flex', gap:'12px', alignItems:'center', flex: 1}}> | |
| <div style={{background: '#dcfce7', padding: '8px', borderRadius: '10px'}}> | |
| <Heart size={18} color="#10b981"/> | |
| </div> | |
| <div> | |
| <div style={{fontWeight: 600, fontSize: '14px', color: '#065f46', display: 'flex', alignItems: 'center', gap: '6px'}}> | |
| Steps & Movement | |
| <span style={{fontSize: '9px', fontWeight: 600, color: '#10b981', background: '#dcfce7', padding: '1px 5px', borderRadius: '4px'}}>Health</span> | |
| </div> | |
| <div style={{fontSize: '12px', color: '#047857', marginTop: '2px'}}> | |
| {(stats.healthDayData.steps || 0).toLocaleString()} steps | |
| {stats.healthDayData.movementCalories > 0 ? <> • <strong>{Math.round(stats.healthDayData.movementCalories)} cal</strong></> : null} | |
| {stats.healthDayData.distance ? ` • ${stats.healthDayData.distance} mi` : ''} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {[...getItemsForDate(workouts, selectedDate), ...getItemsForDate(meals, selectedDate)] | |
| .sort((a,b) => b.id - a.id) | |
| .map(item => ( | |
| <div key={item.id} style={{...card, padding:'12px', display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:'8px'}}> | |
| {/* This clickable div allows you to edit the item */} | |
| <div | |
| onClick={() => editItem(item)} | |
| style={{display:'flex', gap:'12px', alignItems:'center', flex: 1, cursor: 'pointer'}} | |
| > | |
| <div style={{background: item.exercise ? '#eff6ff' : '#ecfdf5', padding: '8px', borderRadius: '10px'}}> | |
| {item.exercise ? <Dumbbell size={18} color="#2563eb"/> : <Utensils size={18} color="#10b981"/>} | |
| </div> | |
| <div> | |
| <div style={{fontWeight:600, fontSize: '14px'}}>{item.exercise || item.item}</div> | |
| <div style={{fontSize:'12px', color:'#6b7280', marginTop: '2px'}}> | |
| {item.exercise | |
| ? (item.type === 'Cardio' || item.time ? ( | |
| <span>{item.cals ? <strong>{item.cals} cal</strong> : '0 cal'} • {item.time} mins ({item.effort})</span> | |
| ) : ( | |
| <span>{item.cals ? <strong>{item.cals} cal</strong> : '0 cal'} • {item.weight}lbs × {item.reps}</span> | |
| )) | |
| : <span><strong>{item.cals} cal</strong> <span style={{fontSize: '11px', opacity: 0.8}}> (P:{item.protein || 0} C:{item.carbs || 0} F:{item.fat || 0})</span></span> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); // Prevents clicking the trash from opening the edit modal | |
| deleteItem(item.id, item.exercise ? 'workout' : 'meal'); | |
| }} | |
| style={{border:'none', background:'none', color:'#ef4444', cursor:'pointer', padding: '4px'}} | |
| > | |
| <Trash2 size={16}/> | |
| </button> | |
| </div> | |
| ))} | |
| <div style={bottomNav}> | |
| <button style={navBtn(modal === 'food')} onClick={() => {setModal('food'); setFoodTab('plan');}}><div style={navIconBox('#10b981', modal === 'food')}><Utensils size={18} /></div>Food</button> | |
| <button style={navBtn(modal === 'workout')} onClick={() => {setModal('workout'); setWorkoutTab('plan');}}><div style={navIconBox('#2563eb', modal === 'workout')}><Dumbbell size={18} /></div>Workout</button> | |
| <button style={navBtn(showCallWebView)} onClick={() => setShowCallWebView(true)}><div style={navIconBox('#a855f7', showCallWebView)}><Phone size={18} /></div>Call</button> | |
| </div> | |
| {modal && ( | |
| <div style={modalOverlay} onClick={(e) => { if(e.target === e.currentTarget) setModal(null) }}> | |
| <div style={modalContent}> | |
| <div style={{flexShrink: 0}}> | |
| <div style={{display: 'flex', justifyContent: 'space-between', marginBottom: '20px', alignItems: 'center'}}> | |
| <h2 style={{margin: 0, fontSize: '18px', fontWeight: '700'}}> | |
| {modal === 'food' ? 'Food & Diet' : 'Workout & Training'} | |
| </h2> | |
| <button onClick={() => setModal(null)} style={{background: '#f3f4f6', border: 'none', cursor: 'pointer', padding: '6px', borderRadius: '50%', display: 'flex'}}><X size={20} color="#374151" /></button> | |
| </div> | |
| {/* MODAL TABS */} | |
| <div style={{display: 'flex', gap: '10px', marginBottom: '20px'}}> | |
| {modal === 'workout' ? ( | |
| <> | |
| <TabButton active={workoutTab === 'plan'} label="Plan" onClick={() => setWorkoutTab('plan')} /> | |
| <TabButton active={workoutTab === 'log'} label="Log" onClick={() => setWorkoutTab('log')} /> | |
| <TabButton active={workoutTab === 'history'} label="History" onClick={() => setWorkoutTab('history')} /> | |
| <TabButton active={workoutTab === 'analyze'} label="Analyze" onClick={() => setWorkoutTab('analyze')} /> | |
| </> | |
| ) : ( | |
| <> | |
| {/* UPDATED TAB ORDER: PLAN, LOG, HISTORY */} | |
| <TabButton active={foodTab === 'plan'} label="Plan" onClick={() => setFoodTab('plan')} /> | |
| <TabButton active={foodTab === 'log'} label="Log" onClick={() => setFoodTab('log')} /> | |
| <TabButton active={foodTab === 'history'} label="History" onClick={() => setFoodTab('history')} /> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {/* --- CONTENT AREA (SCROLLABLE) --- */} | |
| <div style={{flex: 1, overflowY: 'auto', minHeight: 0}}> | |
| {/* --- FOOD MODAL CONTENT --- */} | |
| {modal === 'food' && foodTab === 'log' && ( | |
| <form onSubmit={addMeal}> | |
| {/* NUTRITION LABEL CONTAINER */} | |
| <div style={{ | |
| border: '2px solid black', | |
| padding: '12px', | |
| background: 'white', | |
| color: 'black', | |
| fontFamily: 'Helvetica, Arial, sans-serif', | |
| marginBottom: '16px' | |
| }}> | |
| {/* HEADER: TITLE INPUT + ACTIONS */} | |
| <div style={{display: 'flex', alignItems: 'stretch', justifyContent: 'space-between', marginBottom: '4px'}}> | |
| <div style={{flex: 1}}> | |
| <input | |
| placeholder="Food Name" | |
| autoFocus | |
| value={mForm.item} | |
| onChange={e => setMForm({...mForm, item: e.target.value})} | |
| style={{ | |
| fontSize: '28px', | |
| fontWeight: '900', | |
| border: 'none', | |
| outline: 'none', | |
| width: '100%', | |
| padding: 0, | |
| margin: 0, | |
| color: 'black' | |
| }} | |
| /> | |
| </div> | |
| {/* ACTION BUTTONS (SCAN ONLY) */} | |
| <div style={{display: 'flex', flexDirection: 'column', gap: '4px', height: 'auto'}}> | |
| <button type="button" onClick={() => fileInputRef.current.click()} style={{ | |
| background: 'black', color: 'white', border: 'none', padding: '0 12px', | |
| height: '100%', // Fill height | |
| borderRadius: '4px', fontWeight: 'bold', fontSize: '11px', cursor: 'pointer', | |
| display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'center' | |
| }}> | |
| <Camera size={14}/> SCAN | |
| </button> | |
| </div> | |
| </div> | |
| <input type="file" ref={fileInputRef} onChange={handleImageUpload} style={{display: 'none'}} accept="image/*" /> | |
| {/* THICK BAR 1 */} | |
| <div style={{height: '10px', background: 'black', marginBottom: '8px'}}></div> | |
| {/* CALORIES ROW */} | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px'}}> | |
| <span style={{fontSize: '28px', fontWeight: '900'}}>Calories</span> | |
| <input | |
| type="number" | |
| placeholder="0" | |
| value={mForm.cals} | |
| onChange={e => setMForm({...mForm, cals: e.target.value})} | |
| style={{ | |
| fontSize: '32px', | |
| fontWeight: '900', | |
| textAlign: 'right', | |
| border: 'none', | |
| borderBottom: '2px solid black', | |
| width: '120px', | |
| outline: 'none', | |
| padding: 0 | |
| }} | |
| /> | |
| </div> | |
| {/* MEDIUM BAR */} | |
| <div style={{height: '5px', background: 'black', marginBottom: '12px'}}></div> | |
| {/* MACRO ROWS */} | |
| {[ | |
| { label: 'Total Fat', key: 'fat', unit: 'g' }, | |
| { label: 'Total Carbohydrate', key: 'carbs', unit: 'g' }, | |
| { label: 'Protein', key: 'protein', unit: 'g' } | |
| ].map((macro, i) => ( | |
| <div key={macro.key} style={{ | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| alignItems: 'center', | |
| padding: '8px 0', | |
| borderBottom: '1px solid #a3a3a3', | |
| fontSize: '15px' | |
| }}> | |
| <span style={{fontWeight: '700'}}>{macro.label}</span> | |
| <div style={{display: 'flex', alignItems: 'center', gap: '4px'}}> | |
| <input | |
| type="number" | |
| placeholder="0" | |
| value={mForm[macro.key]} | |
| onChange={e => setMForm({...mForm, [macro.key]: e.target.value})} | |
| style={{ | |
| fontSize: '16px', | |
| fontWeight: 'bold', | |
| textAlign: 'right', | |
| border: 'none', | |
| background: '#f3f4f6', | |
| padding: '4px', | |
| borderRadius: '4px', | |
| width: '60px' | |
| }} | |
| /> | |
| <span style={{fontWeight: '600', fontSize: '12px'}}>{macro.unit}</span> | |
| </div> | |
| </div> | |
| ))} | |
| <div style={{fontSize: '10px', marginTop: '10px', color: '#666'}}> | |
| * Percent Daily Values are based on your {stats.adjustedGoal} calorie diet. | |
| </div> | |
| </div> | |
| <div style={{display: 'flex', gap: '10px', marginTop: '12px', alignItems: 'flex-end'}}> | |
| {/* Update Log Button */} | |
| <button | |
| type="submit" | |
| disabled={aiLoading} | |
| style={{ | |
| ...btnPrimary, | |
| flex: 3, | |
| background: aiLoading ? '#525252' : 'black', | |
| color: 'white', | |
| cursor: aiLoading ? 'not-allowed' : 'pointer', | |
| justifyContent: 'center', | |
| marginTop: 0, | |
| height: '56px', | |
| fontSize: '18px' | |
| }} | |
| > | |
| {aiLoading ? ( | |
| <> | |
| <Loader2 size={20} style={{animation: 'spin 1s linear infinite'}} /> | |
| <span style={{marginLeft: '8px'}}>Analyzing...</span> | |
| </> | |
| ) : ( | |
| "Update Log" | |
| )} | |
| </button> | |
| {/* Servings Control (Bottom Right) */} | |
| <div style={{flex: 1, display: 'flex', flexDirection: 'column', gap: '4px'}}> | |
| {/* +/- Buttons Stacked */} | |
| <div style={{display: 'flex', gap: '4px', height: '40px'}}> | |
| <button | |
| type="button" | |
| onClick={() => updateServings(-0.5)} | |
| style={{ flex: 1, border: 'none', background: '#e5e7eb', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer', fontSize: '18px', color: '#374151' }} | |
| >-</button> | |
| <button | |
| type="button" | |
| onClick={() => updateServings(0.5)} | |
| style={{ flex: 1, border: 'none', background: '#e5e7eb', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer', fontSize: '18px', color: '#374151' }} | |
| >+</button> | |
| </div> | |
| {/* Number Box (Same height as Add Log button) */} | |
| <div style={{ | |
| background: '#f3f4f6', | |
| borderRadius: '12px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| fontWeight: '800', | |
| fontSize: '20px', | |
| height: '56px', | |
| color: '#111827', | |
| border: '1px solid #e5e7eb' | |
| }}> | |
| {mForm.servings}x | |
| </div> | |
| </div> | |
| </div> | |
| </form> | |
| )} | |
| {modal === 'food' && foodTab === 'plan' && ( | |
| <div> | |
| {/* --- DIET SECTION (Top) --- */} | |
| <div style={{background: '#f9fafb', padding: '16px', borderRadius: '12px', marginBottom: '20px', position: 'relative'}}> | |
| <div style={{display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '12px'}}> | |
| <h3 style={{margin: 0, fontSize: '16px', color: '#111827'}}>{stats.dietLabel}</h3> | |
| <button | |
| onClick={() => setShowDoctorsOrders(!showDoctorsOrders)} | |
| style={{background: 'none', border: 'none', cursor: 'pointer', color: '#6b7280', padding: '4px', display: 'flex'}} | |
| > | |
| <Info size={16} /> | |
| </button> | |
| {/* POPOVER */} | |
| {showDoctorsOrders && ( | |
| <div style={{ | |
| position: 'absolute', top: '40px', left: '16px', right: '16px', | |
| background: '#1f2937', color: 'white', padding: '16px', borderRadius: '12px', | |
| zIndex: 20, boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', | |
| animation: 'fadeIn 0.2s' | |
| }}> | |
| <div style={{display: 'flex', justifyContent: 'space-between', marginBottom: '8px'}}> | |
| <h4 style={{fontSize: '14px', margin: 0, color: '#f3f4f6'}}>Doctor's Orders</h4> | |
| <button onClick={() => setShowDoctorsOrders(false)} style={{background: 'none', border: 'none', color: '#9ca3af', cursor: 'pointer'}}><X size={14}/></button> | |
| </div> | |
| <ul style={{fontSize: '12px', paddingLeft: '16px', margin: 0, lineHeight: '1.6', color: '#d1d5db'}}> | |
| <li><strong>Pre-Workout:</strong> 30-40g Protein + 30-40g Carbs (30-60 mins before).</li> | |
| <li><strong>Post-Workout:</strong> 30-40g Protein + High Carbs (within 2 hours).</li> | |
| <li><strong>Hydration:</strong> Drink 1:1 ratio of Water to Electrolytes if sweating heavily.</li> | |
| <li><strong>Focus:</strong> Unprocessed foods (80% rule). Mediterranean sources preferred.</li> | |
| </ul> | |
| </div> | |
| )} | |
| </div> | |
| <div style={{display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '0'}}> | |
| <div style={{background: 'white', padding: '10px', borderRadius: '8px', border: '1px solid #e5e7eb'}}> | |
| <div style={{fontSize: '11px', color: '#6b7280'}}>Calories</div> | |
| <div style={{fontWeight: '700', fontSize: '16px'}}>{stats.adjustedGoal}</div> | |
| </div> | |
| <div style={{background: 'white', padding: '10px', borderRadius: '8px', border: '1px solid #e5e7eb'}}> | |
| <div style={{fontSize: '11px', color: '#6b7280'}}>Protein (g)</div> | |
| <div style={{fontWeight: '700', fontSize: '16px', color: '#3b82f6'}}>{stats.proteinGoal}</div> | |
| </div> | |
| <div style={{background: 'white', padding: '10px', borderRadius: '8px', border: '1px solid #e5e7eb'}}> | |
| <div style={{fontSize: '11px', color: '#6b7280'}}>Carbs (g)</div> | |
| <div style={{fontWeight: '700', fontSize: '16px', color: '#f97316'}}>{stats.carbsGoal}</div> | |
| </div> | |
| <div style={{background: 'white', padding: '10px', borderRadius: '8px', border: '1px solid #e5e7eb'}}> | |
| <div style={{fontSize: '11px', color: '#6b7280'}}>Fat (g)</div> | |
| <div style={{fontWeight: '700', fontSize: '16px', color: '#eab308'}}>{stats.fatGoal}</div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* --- MEAL PLAN SECTION (Bottom) --- */} | |
| <div style={{padding: '0 4px'}}> | |
| <div style={{textAlign: 'center', marginBottom: '16px'}}> | |
| <button onClick={handleGenerateMealPlan} disabled={aiLoading} style={{...btnAI, width: '100%', justifyContent: 'center', padding: '12px', background: '#8b5cf6', color: 'white'}}> | |
| {aiLoading ? 'Creating Plan...' : <><Sparkles size={16}/> Generate Meal Plan</>} | |
| </button> | |
| <div style={{display: 'flex', justifyContent: 'space-between', marginTop: '8px', alignItems: 'center'}}> | |
| <p style={{fontSize: '11px', color: '#6b7280', margin: 0}}>Based on {stats.adjustedGoal} cal target.</p> | |
| </div> | |
| </div> | |
| {generatedMealPlan && ( | |
| <div style={{display: 'flex', flexDirection: 'column', gap: '12px'}}> | |
| {generatedMealPlan.map((planItem, idx) => ( | |
| <div key={idx} style={{background: 'white', padding: '12px', borderRadius: '12px', border: '1px solid #e5e7eb', boxShadow: '0 1px 2px rgba(0,0,0,0.05)', position: 'relative'}}> | |
| {regeneratingIndex === idx && ( | |
| <div style={{position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(255,255,255,0.8)', zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '12px'}}> | |
| <Loader2 size={24} className="animate-spin text-blue-600" color="#2563eb" /> | |
| </div> | |
| )} | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '8px'}}> | |
| <div> | |
| <span style={{fontSize: '12px', fontWeight: '800', color: '#8b5cf6', textTransform: 'uppercase'}}>{planItem.label}</span> | |
| <div style={{fontSize: '11px', color: '#6b7280', marginTop: '2px'}}> | |
| {planItem.cals} cal <span style={{opacity: 0.5}}>|</span> P:{planItem.protein} C:{planItem.carbs} F:{planItem.fat} | |
| </div> | |
| </div> | |
| <div style={{display: 'flex', gap: '4px'}}> | |
| <button onClick={() => handleRegenerateSingleMeal(idx, planItem)} disabled={regeneratingIndex === idx} style={{border: 'none', background: '#f3f4f6', color: '#6b7280', borderRadius: '20px', padding: '6px', cursor: 'pointer', display: 'flex', alignItems: 'center'}}> | |
| <RefreshCw size={14}/> | |
| </button> | |
| <button onClick={() => logMealFromPlan(planItem)} style={{border: 'none', background: '#eff6ff', color: '#2563eb', borderRadius: '20px', padding: '4px 10px', fontSize: '11px', fontWeight: '600', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '4px'}}> | |
| <PlusCircle size={14}/> Log | |
| </button> | |
| </div> | |
| </div> | |
| <div style={{borderTop: '1px solid #f3f4f6', paddingTop: '8px'}}> | |
| {planItem.item.split(',').map((foodStr, i) => ( | |
| <ShoppingListRow | |
| key={i} | |
| item={foodStr} | |
| onLog={() => { setMForm({ item: foodStr.trim(), cals: '', protein: '', carbs: '', fat: '', servings: 1 }); setFoodTab('log'); }} | |
| onDelete={() => { | |
| const newPlan = [...generatedMealPlan]; | |
| const ingredients = newPlan[idx].item.split(',').map(s => s.trim()); | |
| ingredients.splice(i, 1); | |
| newPlan[idx].item = ingredients.join(', '); | |
| setGeneratedMealPlan(newPlan); | |
| }} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* --- NEW FOOD HISTORY SECTION - GROUPED BY DAY --- */} | |
| {modal === 'food' && foodTab === 'history' && ( | |
| <div> | |
| <input | |
| style={input} | |
| placeholder="Search history..." | |
| value={searchHistory} | |
| onChange={e => setSearchHistory(e.target.value)} | |
| /> | |
| <div style={{maxHeight: '400px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px', paddingTop: '8px'}}> | |
| {(() => { | |
| // Filter meals | |
| const filteredMeals = meals | |
| .filter(m => m && m.item) | |
| .filter(m => !hiddenHistory.includes(m.item)) | |
| .filter(m => searchHistory === '' || m.item.toLowerCase().includes(searchHistory.toLowerCase())); | |
| // Group by date | |
| const mealsByDate = filteredMeals.reduce((acc, meal) => { | |
| if (!acc[meal.date]) acc[meal.date] = []; | |
| acc[meal.date].push(meal); | |
| return acc; | |
| }, {}); | |
| // Sort dates descending (newest first) - parse as actual dates | |
| const sortedDates = Object.keys(mealsByDate).sort((a, b) => { | |
| const dateA = new Date(a); | |
| const dateB = new Date(b); | |
| return dateB - dateA; // descending | |
| }); | |
| if (sortedDates.length === 0) { | |
| return <div style={{textAlign: 'center', padding: '40px', color: '#9ca3af', fontSize: '14px'}}> | |
| {searchHistory ? 'No matching meals found' : 'No food history yet'} | |
| </div>; | |
| } | |
| return sortedDates.map((date, dateIdx) => { | |
| const mealsForDate = mealsByDate[date]; | |
| return ( | |
| <div key={dateIdx} style={{borderRadius: '12px', border: '2px solid #e5e7eb', overflow: 'visible', background: 'white'}}> | |
| {/* Date Header */} | |
| <div style={{background: '#f3f4f6', padding: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #e5e7eb'}}> | |
| <div style={{fontWeight: '700', color: '#374151', fontSize: '14px'}}> | |
| {date === selectedDate ? 'Today' : date} | |
| </div> | |
| <button | |
| onClick={() => addAllMealsFromHistory(mealsForDate)} | |
| style={{ | |
| background: '#10b981', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '8px', | |
| padding: '6px 12px', | |
| fontSize: '12px', | |
| fontWeight: '600', | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '4px' | |
| }} | |
| > | |
| <Plus size={14}/> Add All ({mealsForDate.length}) | |
| </button> | |
| </div> | |
| {/* ALL MEALS - NO DEDUPLICATION */} | |
| <div style={{display: 'flex', flexDirection: 'column', background: 'white'}}> | |
| {mealsForDate.map((m, i) => ( | |
| <div | |
| key={m.id || i} | |
| onClick={() => addMealFromHistory(m)} | |
| style={{ | |
| padding: '14px 12px', | |
| cursor: 'pointer', | |
| borderBottom: i < mealsForDate.length - 1 ? '1px solid #e5e7eb' : 'none', | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| alignItems: 'center', | |
| background: 'white', | |
| transition: 'background 0.15s', | |
| minHeight: '68px' | |
| }} | |
| onMouseEnter={(e) => e.currentTarget.style.background = '#f9fafb'} | |
| onMouseLeave={(e) => e.currentTarget.style.background = 'white'} | |
| > | |
| <div style={{flex: 1, minWidth: 0}}> | |
| <div style={{fontWeight: '600', color: '#111827', marginBottom: '6px', fontSize: '15px'}}>{m.item}</div> | |
| <div style={{fontSize: '13px', color: '#6b7280', fontWeight: '500'}}> | |
| {m.cals || 0} cal • P:{m.protein || 0} C:{m.carbs || 0} F:{m.fat || 0} | |
| </div> | |
| </div> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); deleteHistoryItem(m.item, 'meal'); }} | |
| style={{border: 'none', background: 'none', color: '#ef4444', padding: '8px', cursor: 'pointer', flexShrink: 0, marginLeft: '8px'}} | |
| > | |
| <Trash2 size={18}/> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }); | |
| })()} | |
| </div> | |
| </div> | |
| )} | |
| {/* --- WORKOUT MODAL CONTENT --- */} | |
| {modal === 'workout' && workoutTab === 'log' && ( | |
| <form onSubmit={addWorkout}> | |
| {/* EXERCISE NAME */} | |
| <div style={{marginBottom: '10px'}}> | |
| <label style={{display:'block', marginBottom:'5px', fontWeight:'500', fontSize: '14px'}}>Exercise</label> | |
| <div style={{display: 'flex', gap: '8px'}}> | |
| <input | |
| style={input} | |
| placeholder="e.g. Bench Press" | |
| autoFocus | |
| value={wForm.exercise} | |
| onChange={e => setWForm({...wForm, exercise: e.target.value})} | |
| onBlur={e => updateLastLog(e.target.value)} | |
| /> | |
| <button type="button" onClick={handleWorkoutAILookup} disabled={aiLoading} style={btnAI}> | |
| {aiLoading ? 'Thinking...' : <><Sparkles size={16}/> Next-Up!</>} | |
| </button> | |
| </div> | |
| </div> | |
| {/* SMART TARGET CARD (TONAL STYLE) */} | |
| {lastLog && lastLog.weight && lastLog.sets && lastLog.reps && ( | |
| <div style={{ | |
| background: '#1f2937', | |
| color: 'white', | |
| borderRadius: '12px', | |
| padding: '12px', | |
| marginBottom: '16px', | |
| border: '1px solid #374151', | |
| animation: 'fadeIn 0.3s' | |
| }}> | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px'}}> | |
| <span style={{fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1px', color: '#9ca3af', fontWeight: '600'}}> | |
| Last Session ({lastLog.date}) | |
| </span> | |
| <button | |
| type="button" | |
| onClick={() => setWForm({...wForm, weight: lastLog.weight, sets: lastLog.sets, reps: lastLog.reps})} | |
| style={{background: '#374151', border: 'none', color: '#e5e7eb', fontSize: '10px', padding: '4px 8px', borderRadius: '4px', cursor: 'pointer'}} | |
| > | |
| Use These Stats | |
| </button> | |
| </div> | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end'}}> | |
| <div> | |
| <div style={{fontSize: '24px', fontWeight: '700', lineHeight: '1'}}> | |
| {lastLog.weight}<span style={{fontSize: '14px', fontWeight: '400', color: '#d1d5db'}}>lbs</span> | |
| </div> | |
| <div style={{fontSize: '13px', color: '#9ca3af', marginTop: '4px'}}> | |
| {lastLog.sets} sets × {lastLog.reps} reps | |
| </div> | |
| </div> | |
| <div style={{textAlign: 'right'}}> | |
| <div style={{fontSize: '11px', color: '#10b981', fontWeight: '700', marginBottom: '2px'}}> | |
| BEAT IT | |
| </div> | |
| <div style={{fontSize: '18px', fontWeight: '700', color: '#34d399'}}> | |
| {parseInt(lastLog.weight) + 5}<span style={{fontSize: '12px', fontWeight: '400'}}>lbs</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* --- UNIFIED DETAILS SECTION --- */} | |
| <div style={{marginBottom: '16px', background: '#f9fafb', padding: '12px', borderRadius: '12px', border: '1px solid #e5e7eb', animation: 'fadeIn 0.2s'}}> | |
| {/* Header Toggle */} | |
| <div style={{display: 'flex', justifyContent: 'center', marginBottom: '12px'}}> | |
| <div style={{display: 'flex', background: '#e5e7eb', borderRadius: '8px', padding: '2px', width: '100%'}}> | |
| <button | |
| type="button" | |
| onClick={() => setWorkoutMode('Strength')} | |
| style={{flex: 1, padding: '8px', borderRadius: '6px', border: 'none', background: workoutMode === 'Strength' ? 'white' : 'transparent', color: workoutMode === 'Strength' ? '#2563eb' : '#6b7280', fontWeight: '700', fontSize: '12px', cursor: 'pointer', boxShadow: workoutMode === 'Strength' ? '0 1px 2px rgba(0,0,0,0.1)' : 'none', transition: 'all 0.2s'}} | |
| >Strength Details</button> | |
| <button | |
| type="button" | |
| onClick={() => setWorkoutMode('Cardio')} | |
| style={{flex: 1, padding: '8px', borderRadius: '6px', border: 'none', background: workoutMode === 'Cardio' ? 'white' : 'transparent', color: workoutMode === 'Cardio' ? '#2563eb' : '#6b7280', fontWeight: '700', fontSize: '12px', cursor: 'pointer', boxShadow: workoutMode === 'Cardio' ? '0 1px 2px rgba(0,0,0,0.1)' : 'none', transition: 'all 0.2s'}} | |
| >Cardio Details</button> | |
| </div> | |
| </div> | |
| {/* Inputs */} | |
| {workoutMode === 'Strength' ? ( | |
| <div style={{display:'flex', gap:'10px'}}> | |
| <div style={{flex: 1}}> | |
| <label style={{fontSize: '11px', color: '#6b7280'}}>Lbs</label> | |
| <input style={{...input, marginBottom: 0}} type="number" placeholder="0" value={wForm.weight} onChange={e => setWForm({...wForm, weight: e.target.value})} /> | |
| </div> | |
| <div style={{flex: 1}}> | |
| <label style={{fontSize: '11px', color: '#6b7280'}}>Sets</label> | |
| <input style={{...input, marginBottom: 0}} type="text" placeholder="3" value={wForm.sets} onChange={e => setWForm({...wForm, sets: e.target.value})} /> | |
| </div> | |
| <div style={{flex: 1}}> | |
| <label style={{fontSize: '11px', color: '#6b7280'}}>Reps</label> | |
| <input style={{...input, marginBottom: 0}} type="text" placeholder="6" value={wForm.reps} onChange={e => setWForm({...wForm, reps: e.target.value})} /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div style={{display:'flex', gap:'10px', alignItems: 'flex-end'}}> | |
| <div style={{flex: 1}}> | |
| <label style={{fontSize: '11px', color: '#6b7280'}}>Time (Mins)</label> | |
| <input style={{...input, marginBottom: 0}} type="number" placeholder="0" value={wForm.time} onChange={e => setWForm({...wForm, time: e.target.value})} /> | |
| </div> | |
| <div style={{flex: 1}}> | |
| <label style={{fontSize: '11px', color: '#6b7280'}}>Intensity</label> | |
| <div style={{display: 'flex', background: 'white', borderRadius: '10px', border: '1px solid #e5e7eb', overflow: 'hidden'}}> | |
| <button | |
| type="button" | |
| onClick={() => setWForm({...wForm, effort: 'Low'})} | |
| style={{flex: 1, border: 'none', background: wForm.effort === 'Low' ? '#eff6ff' : 'white', color: wForm.effort === 'Low' ? '#2563eb' : '#6b7280', padding: '10px', fontSize: '12px', fontWeight: '600', cursor: 'pointer'}} | |
| >Low</button> | |
| <div style={{width: '1px', background: '#e5e7eb'}}></div> | |
| <button | |
| type="button" | |
| onClick={() => setWForm({...wForm, effort: 'High'})} | |
| style={{flex: 1, border: 'none', background: wForm.effort === 'High' ? '#fef2f2' : 'white', color: wForm.effort === 'High' ? '#ef4444' : '#6b7280', padding: '10px', fontSize: '12px', fontWeight: '600', cursor: 'pointer'}} | |
| >High</button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| type="submit" | |
| disabled={aiLoading} | |
| style={{ | |
| ...btnPrimary, | |
| marginTop: '10px', | |
| background: aiLoading ? '#525252' : '#2563eb' | |
| }} | |
| > | |
| {aiLoading ? ( | |
| <> | |
| <Loader2 size={20} style={{animation: 'spin 1s linear infinite'}} /> | |
| <span style={{marginLeft: '8px'}}>Calculating...</span> | |
| </> | |
| ) : ( | |
| "Add Workout" | |
| )} | |
| </button> | |
| </form> | |
| )} | |
| {modal === 'workout' && workoutTab === 'plan' && ( | |
| <div> | |
| {/* --- ROUTINE SELECTOR --- */} | |
| <div style={{position: 'relative', marginBottom: '14px'}}> | |
| <button | |
| onClick={() => setShowRoutinePicker(!showRoutinePicker)} | |
| style={{ | |
| width: '100%', | |
| padding: '12px 14px', | |
| borderRadius: '12px', | |
| border: '2px solid #2563eb', | |
| background: '#eff6ff', | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'space-between' | |
| }} | |
| > | |
| <div style={{display: 'flex', alignItems: 'center', gap: '10px'}}> | |
| <span style={{fontSize: '20px'}}>{(WORKOUT_ROUTINES.find(r => r.id === selectedRoutineId) || WORKOUT_ROUTINES[0]).emoji}</span> | |
| <div style={{textAlign: 'left'}}> | |
| <div style={{fontWeight: '700', fontSize: '15px', color: '#1e40af'}}>{(WORKOUT_ROUTINES.find(r => r.id === selectedRoutineId) || WORKOUT_ROUTINES[0]).name}</div> | |
| <div style={{fontSize: '11px', color: '#6b7280'}}>{(WORKOUT_ROUTINES.find(r => r.id === selectedRoutineId) || WORKOUT_ROUTINES[0]).description}</div> | |
| </div> | |
| </div> | |
| <ChevronDown size={18} color="#2563eb" style={{transform: showRoutinePicker ? 'rotate(180deg)' : 'rotate(0)', transition: 'transform 0.2s'}} /> | |
| </button> | |
| {/* Routine Picker Dropdown - Overlay */} | |
| {showRoutinePicker && ( | |
| <div style={{ | |
| position: 'absolute', | |
| top: '100%', | |
| left: 0, | |
| right: 0, | |
| zIndex: 20, | |
| border: '2px solid #e5e7eb', | |
| borderTop: '1px solid #e5e7eb', | |
| borderRadius: '0 0 12px 12px', | |
| maxHeight: '260px', | |
| overflowY: 'auto', | |
| background: 'white', | |
| boxShadow: '0 8px 24px rgba(0,0,0,0.15)' | |
| }}> | |
| {WORKOUT_ROUTINES.map(routine => ( | |
| <button | |
| key={routine.id} | |
| onClick={() => { setSelectedRoutineId(routine.id); setShowRoutinePicker(false); if (routine.hasSplits) setPlanTab('Push'); }} | |
| style={{ | |
| width: '100%', | |
| padding: '10px 14px', | |
| border: 'none', | |
| borderBottom: '1px solid #f3f4f6', | |
| background: selectedRoutineId === routine.id ? '#eff6ff' : 'white', | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '10px', | |
| textAlign: 'left' | |
| }} | |
| > | |
| <span style={{fontSize: '18px', flexShrink: 0}}>{routine.emoji}</span> | |
| <div style={{flex: 1, minWidth: 0}}> | |
| <div style={{fontWeight: '600', fontSize: '13px', color: selectedRoutineId === routine.id ? '#1e40af' : '#374151'}}>{routine.name}</div> | |
| <div style={{fontSize: '11px', color: '#9ca3af'}}>{routine.description}</div> | |
| </div> | |
| {selectedRoutineId === routine.id && <div style={{width: '8px', height: '8px', borderRadius: '50%', background: '#2563eb', flexShrink: 0}} />} | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Split pills for Train Right only */} | |
| {selectedRoutineId === 'train-right' && ( | |
| <div style={{display: 'flex', gap: '6px', marginBottom: '14px'}}> | |
| {['Push', 'Pull', 'Legs', 'Cardio'].map(split => ( | |
| <button | |
| key={split} | |
| onClick={() => setPlanTab(split)} | |
| style={{ | |
| flex: 1, | |
| padding: '8px 4px', | |
| borderRadius: '8px', | |
| border: planTab === split ? '2px solid #2563eb' : '1px solid #e5e7eb', | |
| background: planTab === split ? '#eff6ff' : 'white', | |
| color: planTab === split ? '#2563eb' : '#6b7280', | |
| fontWeight: '700', | |
| fontSize: '12px', | |
| cursor: 'pointer', | |
| transition: 'all 0.15s' | |
| }} | |
| >{split}</button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Header + Log All */} | |
| <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px'}}> | |
| <div> | |
| <h3 style={{margin: 0, fontSize: '15px', fontWeight: '700', color: '#111827'}}> | |
| {selectedRoutineId === 'train-right' ? `${planTab} Day` : 'Exercises'} | |
| </h3> | |
| <span style={{fontSize: '11px', color: '#9ca3af', fontWeight: '500'}}> | |
| {getTrainingDifficulty(user?.activityHoursPerWeek || 0)} · {activePlanExercises.length} exercises | |
| </span> | |
| </div> | |
| <button | |
| onClick={logAllFromPlan} | |
| style={{ | |
| background: '#10b981', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '8px', | |
| padding: '8px 14px', | |
| fontSize: '12px', | |
| fontWeight: '700', | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '5px' | |
| }} | |
| > | |
| <PlusCircle size={14}/> Log All | |
| </button> | |
| </div> | |
| {/* Exercise List - Compact */} | |
| {activePlanExercises.map((ex, i) => ( | |
| <div key={i} style={{ | |
| background: 'white', | |
| border: '1px solid #e5e7eb', | |
| borderRadius: '10px', | |
| padding: '10px 12px', | |
| marginBottom: '6px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '10px' | |
| }}> | |
| <div style={{flex: 1, minWidth: 0}}> | |
| <div style={{display: 'flex', alignItems: 'center', gap: '8px'}}> | |
| <span style={{fontWeight: '600', fontSize: '13px', color: '#111827'}}>{ex.name}</span> | |
| <span style={{fontSize: '11px', color: '#8b5cf6', fontWeight: '700', background: '#f5f3ff', padding: '1px 6px', borderRadius: '4px', flexShrink: 0}}>{ex.sets}×{ex.reps}</span> | |
| </div> | |
| {ex.notes && <div style={{fontSize: '11px', color: '#9ca3af', marginTop: '2px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis'}}>{ex.notes}</div>} | |
| </div> | |
| <div style={{display: 'flex', gap: '4px', flexShrink: 0}}> | |
| <button onClick={() => handleRegenerateExercise(i, ex)} disabled={regeneratingIndex === i} style={{border: 'none', background: '#f3f4f6', color: '#6b7280', borderRadius: '6px', padding: '6px', cursor: 'pointer', display: 'flex'}} title="Swap"> | |
| <RefreshCw size={13} style={regeneratingIndex === i ? {animation: 'spin 1s linear infinite'} : {}}/> | |
| </button> | |
| <button onClick={() => logFromPlan(ex)} style={{border: 'none', background: '#eff6ff', color: '#2563eb', borderRadius: '6px', padding: '6px', cursor: 'pointer', display: 'flex'}} title="Log"> | |
| <PlusCircle size={13}/> | |
| </button> | |
| <button onClick={() => removeExerciseFromPlan(i)} style={{border: 'none', background: '#fef2f2', color: '#ef4444', borderRadius: '6px', padding: '6px', cursor: 'pointer', display: 'flex'}} title="Remove"> | |
| <Trash2 size={13}/> | |
| </button> | |
| </div> | |
| </div> | |
| ))} | |
| {/* Gemini Exercise Search */} | |
| <div style={{marginTop: '12px', position: 'relative', background: '#f9fafb', borderRadius: '12px', padding: '12px', border: '1px solid #e5e7eb'}}> | |
| <div style={{display: 'flex', gap: '8px', alignItems: 'center'}}> | |
| <Sparkles size={16} color="#8b5cf6" /> | |
| <span style={{fontSize: '12px', fontWeight: '700', color: '#6b7280', textTransform: 'uppercase', letterSpacing: '0.5px'}}>Find Exercises with AI</span> | |
| </div> | |
| <div style={{display: 'flex', gap: '8px', marginTop: '8px'}}> | |
| <input | |
| type="text" | |
| placeholder="e.g. chest exercises, bicep curls..." | |
| value={addExerciseInput} | |
| onChange={e => setAddExerciseInput(e.target.value)} | |
| onKeyDown={e => e.key === 'Enter' && handleSearchExercises()} | |
| style={{ | |
| flex: 1, padding: '10px 12px', borderRadius: '8px', | |
| border: '1px solid #e5e7eb', fontSize: '14px', outline: 'none', background: 'white' | |
| }} | |
| /> | |
| <button | |
| onClick={handleSearchExercises} | |
| disabled={exerciseSearching || !addExerciseInput.trim()} | |
| style={{ | |
| background: addExerciseInput.trim() ? '#8b5cf6' : '#e5e7eb', | |
| color: 'white', border: 'none', borderRadius: '8px', | |
| padding: '10px 14px', cursor: addExerciseInput.trim() ? 'pointer' : 'default', | |
| display: 'flex', alignItems: 'center', fontWeight: '600', fontSize: '13px' | |
| }} | |
| > | |
| {exerciseSearching ? <Loader2 size={16} style={{animation: 'spin 1s linear infinite'}}/> : 'Search'} | |
| </button> | |
| </div> | |
| {/* Search Results - Popover */} | |
| {exerciseSearchResults.length > 0 && ( | |
| <div style={{ | |
| position: 'absolute', | |
| bottom: '100%', | |
| left: 0, | |
| right: 0, | |
| zIndex: 20, | |
| background: 'white', | |
| borderRadius: '12px', | |
| border: '2px solid #8b5cf6', | |
| boxShadow: '0 -8px 24px rgba(0,0,0,0.15)', | |
| marginBottom: '4px', | |
| overflow: 'hidden' | |
| }}> | |
| <div style={{padding: '10px 12px', background: '#f5f3ff', borderBottom: '1px solid #e5e7eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}> | |
| <span style={{fontSize: '12px', fontWeight: '700', color: '#7c3aed'}}>Tap to add</span> | |
| <button onClick={() => setExerciseSearchResults([])} style={{border: 'none', background: 'none', cursor: 'pointer', color: '#9ca3af', padding: '2px', display: 'flex'}}><X size={14}/></button> | |
| </div> | |
| {exerciseSearchResults.map((ex, i) => ( | |
| <button | |
| key={i} | |
| onClick={() => addSearchResultToPlan(ex)} | |
| style={{ | |
| width: '100%', padding: '10px 12px', | |
| border: 'none', borderBottom: i < exerciseSearchResults.length - 1 ? '1px solid #f3f4f6' : 'none', | |
| background: 'white', cursor: 'pointer', | |
| display: 'flex', justifyContent: 'space-between', alignItems: 'center', | |
| textAlign: 'left' | |
| }} | |
| > | |
| <div> | |
| <div style={{fontWeight: '600', fontSize: '13px', color: '#111827'}}>{ex.name}</div> | |
| <div style={{fontSize: '11px', color: '#6b7280'}}>{ex.sets}×{ex.reps} {ex.notes ? `· ${ex.notes}` : ''}</div> | |
| </div> | |
| <Plus size={16} color="#2563eb" /> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* --- NEW WORKOUT HISTORY SECTION --- */} | |
| {/* --- WORKOUT ANALYZE TAB --- */} | |
| {modal === 'workout' && workoutTab === 'analyze' && ( | |
| <div style={{display: 'flex', flexDirection: 'column', gap: '16px'}}> | |
| {/* Upload Area */} | |
| <div | |
| onClick={() => videoInputRef.current?.click()} | |
| style={{ | |
| border: '2px dashed #d1d5db', | |
| borderRadius: '16px', | |
| padding: analysisVideo ? '12px' : '32px', | |
| textAlign: 'center', | |
| cursor: 'pointer', | |
| background: analysisVideo ? '#f0fdf4' : '#f9fafb', | |
| transition: 'all 0.2s' | |
| }} | |
| > | |
| {analysisVideo ? ( | |
| <div> | |
| <video | |
| src={URL.createObjectURL(analysisVideo)} | |
| controls | |
| style={{width: '100%', borderRadius: '12px', maxHeight: '200px'}} | |
| onClick={e => e.stopPropagation()} | |
| /> | |
| <div style={{fontSize: '12px', color: '#6b7280', marginTop: '8px'}}> | |
| {analysisVideo.name} — Tap to change | |
| </div> | |
| </div> | |
| ) : ( | |
| <div> | |
| <Upload size={32} color="#9ca3af" style={{margin: '0 auto 8px'}} /> | |
| <div style={{fontWeight: '600', color: '#374151', fontSize: '15px'}}>Upload Workout Video</div> | |
| <div style={{fontSize: '12px', color: '#9ca3af', marginTop: '4px'}}>Any sport or exercise — keep it under 30s for best results</div> | |
| </div> | |
| )} | |
| </div> | |
| <input | |
| type="file" | |
| ref={videoInputRef} | |
| onChange={e => { if (e.target.files[0]) { setAnalysisVideo(e.target.files[0]); setAnalysisResult(null); } }} | |
| style={{display: 'none'}} | |
| accept="video/*" | |
| /> | |
| {/* Description Input */} | |
| <textarea | |
| placeholder="Describe the workout or sport (e.g. 'barbell back squat', 'basketball free throw', 'deadlift form check')..." | |
| value={analysisDescription} | |
| onChange={e => setAnalysisDescription(e.target.value)} | |
| rows={2} | |
| style={{ | |
| width: '100%', | |
| padding: '12px', | |
| borderRadius: '12px', | |
| border: '2px solid #e5e7eb', | |
| fontSize: '14px', | |
| fontFamily: 'inherit', | |
| resize: 'none', | |
| outline: 'none', | |
| boxSizing: 'border-box' | |
| }} | |
| /> | |
| {/* Analyze Button */} | |
| <button | |
| onClick={handleWorkoutAnalysis} | |
| disabled={analysisLoading || !analysisVideo || !analysisDescription.trim()} | |
| style={{ | |
| width: '100%', | |
| padding: '14px', | |
| background: analysisLoading || !analysisVideo || !analysisDescription.trim() ? '#d1d5db' : 'linear-gradient(135deg, #2563eb, #7c3aed)', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '12px', | |
| fontSize: '15px', | |
| fontWeight: '700', | |
| cursor: analysisLoading ? 'wait' : 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '8px' | |
| }} | |
| > | |
| {analysisLoading ? <><Loader2 size={18} style={{animation: 'spin 1s linear infinite'}} /> Analyzing...</> : <><Sparkles size={18} /> Analyze My Form</>} | |
| </button> | |
| {/* Results */} | |
| {analysisResult && ( | |
| <div style={{background: 'white', borderRadius: '16px', border: '2px solid #e5e7eb', overflow: 'hidden'}}> | |
| {/* Score Header */} | |
| <div style={{ | |
| background: analysisResult.score >= 4 ? 'linear-gradient(135deg, #10b981, #059669)' : analysisResult.score >= 3 ? 'linear-gradient(135deg, #f59e0b, #d97706)' : 'linear-gradient(135deg, #ef4444, #dc2626)', | |
| padding: '20px', | |
| textAlign: 'center', | |
| color: 'white' | |
| }}> | |
| <div style={{fontSize: '42px', fontWeight: '800'}}>{analysisResult.score}<span style={{fontSize: '20px', opacity: 0.8}}>/5</span></div> | |
| <div style={{fontSize: '13px', opacity: 0.9, marginTop: '4px'}}>{analysisResult.summary}</div> | |
| </div> | |
| {/* Strengths */} | |
| <div style={{padding: '16px', borderBottom: '1px solid #f3f4f6'}}> | |
| <div style={{fontWeight: '700', fontSize: '14px', color: '#10b981', marginBottom: '8px'}}>What You Did Well</div> | |
| {analysisResult.strengths?.map((s, i) => ( | |
| <div key={i} style={{fontSize: '13px', color: '#374151', padding: '6px 0', display: 'flex', gap: '8px', alignItems: 'flex-start'}}> | |
| <span style={{color: '#10b981', flexShrink: 0}}>+</span> {s} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Improvements */} | |
| <div style={{padding: '16px', borderBottom: '1px solid #f3f4f6'}}> | |
| <div style={{fontWeight: '700', fontSize: '14px', color: '#f59e0b', marginBottom: '8px'}}>Things to Improve</div> | |
| {analysisResult.improvements?.map((s, i) => ( | |
| <div key={i} style={{fontSize: '13px', color: '#374151', padding: '6px 0', display: 'flex', gap: '8px', alignItems: 'flex-start'}}> | |
| <span style={{color: '#f59e0b', flexShrink: 0}}>!</span> {s} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Coaching Tip */} | |
| {analysisResult.tips && ( | |
| <div style={{padding: '16px', background: '#f0f9ff'}}> | |
| <div style={{fontWeight: '700', fontSize: '14px', color: '#2563eb', marginBottom: '6px'}}>Coach's Tip</div> | |
| <div style={{fontSize: '13px', color: '#374151'}}>{analysisResult.tips}</div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {modal === 'workout' && workoutTab === 'history' && ( | |
| <div> | |
| <input | |
| style={input} | |
| placeholder="Search history..." | |
| value={searchHistory} | |
| onChange={e => setSearchHistory(e.target.value)} | |
| /> | |
| <div style={{maxHeight: '400px', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '12px', paddingTop: '8px'}}> | |
| {(() => { | |
| // Filter workouts | |
| const filteredWorkouts = workouts | |
| .filter(w => w && w.exercise) | |
| .filter(w => !hiddenHistory.includes(w.exercise)) | |
| .filter(w => searchHistory === '' || w.exercise.toLowerCase().includes(searchHistory.toLowerCase())); | |
| // Group by date | |
| const workoutsByDate = filteredWorkouts.reduce((acc, workout) => { | |
| if (!acc[workout.date]) acc[workout.date] = []; | |
| acc[workout.date].push(workout); | |
| return acc; | |
| }, {}); | |
| // Sort dates descending (newest first) - parse as actual dates | |
| const sortedDates = Object.keys(workoutsByDate).sort((a, b) => { | |
| const dateA = new Date(a); | |
| const dateB = new Date(b); | |
| return dateB - dateA; // descending | |
| }); | |
| if (sortedDates.length === 0) { | |
| return <div style={{textAlign: 'center', padding: '40px', color: '#9ca3af', fontSize: '14px'}}> | |
| {searchHistory ? 'No matching exercises found' : 'No workout history yet'} | |
| </div>; | |
| } | |
| return sortedDates.map((date, dateIdx) => { | |
| const workoutsForDate = workoutsByDate[date]; | |
| return ( | |
| <div key={dateIdx} style={{borderRadius: '12px', border: '2px solid #e5e7eb', overflow: 'visible', background: 'white'}}> | |
| {/* Date Header */} | |
| <div style={{background: '#f3f4f6', padding: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #e5e7eb'}}> | |
| <div style={{fontWeight: '700', color: '#374151', fontSize: '14px'}}> | |
| {date === selectedDate ? 'Today' : date} | |
| </div> | |
| <button | |
| onClick={() => addAllWorkoutsFromHistory(workoutsForDate)} | |
| style={{ | |
| background: '#10b981', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '8px', | |
| padding: '6px 12px', | |
| fontSize: '12px', | |
| fontWeight: '600', | |
| cursor: 'pointer', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '4px' | |
| }} | |
| > | |
| <Plus size={14}/> Add All ({workoutsForDate.length}) | |
| </button> | |
| </div> | |
| {/* ALL WORKOUTS - NO DEDUPLICATION */} | |
| <div style={{display: 'flex', flexDirection: 'column', background: 'white'}}> | |
| {workoutsForDate.map((w, i) => ( | |
| <div | |
| key={w.id || i} | |
| onClick={() => addWorkoutFromHistory(w)} | |
| style={{ | |
| padding: '14px 12px', | |
| cursor: 'pointer', | |
| borderBottom: i < workoutsForDate.length - 1 ? '1px solid #e5e7eb' : 'none', | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| alignItems: 'center', | |
| background: 'white', | |
| transition: 'background 0.15s', | |
| minHeight: '68px' | |
| }} | |
| onMouseEnter={(e) => e.currentTarget.style.background = '#f9fafb'} | |
| onMouseLeave={(e) => e.currentTarget.style.background = 'white'} | |
| > | |
| <div style={{flex: 1, minWidth: 0}}> | |
| <div style={{fontWeight: '600', color: '#111827', marginBottom: '6px', fontSize: '15px'}}>{w.exercise}</div> | |
| <div style={{fontSize: '13px', color: '#6b7280', fontWeight: '500'}}> | |
| {w.type === 'Cardio' || w.time | |
| ? `${w.time} mins (${w.effort})` | |
| : `${w.weight}lbs • ${w.sets} sets × ${w.reps} reps` | |
| } | |
| </div> | |
| </div> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); deleteHistoryItem(w.exercise, 'workout'); }} | |
| style={{border: 'none', background: 'none', color: '#ef4444', padding: '8px', cursor: 'pointer', flexShrink: 0, marginLeft: '8px'}} | |
| > | |
| <Trash2 size={18}/> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }); | |
| })()} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* --- CALL WEBVIEW MODAL --- */} | |
| {showCallWebView && ( | |
| <div style={{ | |
| position: 'fixed', | |
| top: 0, | |
| left: 0, | |
| right: 0, | |
| bottom: 0, | |
| background: '#000', | |
| zIndex: 100, | |
| display: 'flex', | |
| flexDirection: 'column' | |
| }}> | |
| {/* Header with close button */} | |
| <div style={{ | |
| display: 'flex', | |
| justifyContent: 'space-between', | |
| alignItems: 'center', | |
| padding: '12px 16px', | |
| background: '#1a1a2e', | |
| borderBottom: '1px solid #333' | |
| }}> | |
| <div style={{display: 'flex', alignItems: 'center', gap: '10px'}}> | |
| <div style={{background: '#a855f7', padding: '8px', borderRadius: '10px'}}> | |
| <Phone size={18} color="white" /> | |
| </div> | |
| <span style={{color: 'white', fontWeight: '600', fontSize: '16px'}}>dubltalk</span> | |
| {authUser && ( | |
| <span | |
| onClick={() => { if(confirm('Logout?')) handleLogout(); }} | |
| style={{color: '#a855f7', fontSize: '12px', cursor: 'pointer'}} | |
| >{authUser.email}</span> | |
| )} | |
| </div> | |
| <button | |
| onClick={() => setShowCallWebView(false)} | |
| style={{ | |
| background: '#ef4444', | |
| border: 'none', | |
| cursor: 'pointer', | |
| padding: '8px 16px', | |
| borderRadius: '20px', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '6px', | |
| color: 'white', | |
| fontWeight: '600', | |
| fontSize: '14px' | |
| }} | |
| > | |
| <X size={18} color="white" /> | |
| Close | |
| </button> | |
| </div> | |
| <div style={{ flex: 1, position: 'relative' }}> | |
| <iframe | |
| key={`call-${authUser?.uid || 'anon'}`} | |
| src={`https://dubltalk.onrender.com/avatar-selected?avatar=dr.ryan&mode=audio&embed=true${authUser?.uid ? `&userId=${authUser.uid}` : ''}`} | |
| style={{ | |
| width: '100%', | |
| height: '100%', | |
| border: 'none', | |
| background: '#000' | |
| }} | |
| allow="microphone; camera; autoplay" | |
| title="dubltalk" | |
| /> | |
| {/* Login overlay - covers the call button when not logged in */} | |
| {!authUser && ( | |
| <div style={{ | |
| position: 'absolute', | |
| bottom: 0, | |
| left: 0, | |
| right: 0, | |
| height: '220px', | |
| background: 'linear-gradient(transparent 0%, rgba(0,0,0,1) 30%)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| paddingTop: '40px' | |
| }}> | |
| <button | |
| onClick={handleGoogleLogin} | |
| disabled={authLoading} | |
| style={{ | |
| background: '#4285f4', | |
| color: 'white', | |
| border: 'none', | |
| borderRadius: '30px', | |
| padding: '16px 40px', | |
| fontSize: '16px', | |
| fontWeight: '600', | |
| cursor: 'pointer', | |
| boxShadow: '0 4px 15px rgba(66,133,244,0.4)' | |
| }} | |
| > | |
| {authLoading ? 'Signing in...' : 'Sign in to Start Call'} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <style>{` | |
| @keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } | |
| @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } | |
| @keyframes wave { | |
| 0%, 100% { transform: translateX(-5%) rotate(-2deg); } | |
| 50% { transform: translateX(5%) rotate(2deg); } | |
| } | |
| @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } | |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; /* Prevents the 'pull' on the whole screen */ | |
| position: fixed; /* Hard-locks the background */ | |
| } | |
| #root { | |
| width: 100%; | |
| height: 100%; | |
| overflow-y: auto; /* Only allows scrolling if content is actually taller than screen */ | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| body { | |
| -webkit-tap-highlight-color: transparent; | |
| overscroll-behavior-y: none; | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| } |