From 0de757edbe884102c305d7c60d189c618bddcf66 Mon Sep 17 00:00:00 2001 From: averyjennings Date: Mon, 11 Aug 2025 00:18:15 -0700 Subject: [PATCH] Implement phase 3 --- package-lock.json | 269 ++++++++++ package.json | 3 + src/app/api/nutrition/dashboard/route.ts | 140 +++++ src/app/api/nutrition/goals/route.ts | 144 ++++++ src/app/calorie-tracker/page.tsx | 53 +- .../calorie-tracker/AIAnalysisDisplay.tsx | 363 +++++++++++++ .../CalorieTrackerDashboard.tsx | 354 +++++++++++++ .../calorie-tracker/DailyCalorieSummary.tsx | 245 +++++++++ .../calorie-tracker/FoodLogManager.tsx | 484 ++++++++++++++++++ .../calorie-tracker/WeeklyTrendsChart.tsx | 297 +++++++++++ src/components/ui/progress.tsx | 28 + src/components/ui/tabs.tsx | 55 ++ .../007_create_daily_nutrition_summaries.sql | 101 ++++ .../008_create_user_nutrition_goals.sql | 52 ++ 14 files changed, 2557 insertions(+), 31 deletions(-) create mode 100644 src/app/api/nutrition/dashboard/route.ts create mode 100644 src/app/api/nutrition/goals/route.ts create mode 100644 src/components/calorie-tracker/AIAnalysisDisplay.tsx create mode 100644 src/components/calorie-tracker/CalorieTrackerDashboard.tsx create mode 100644 src/components/calorie-tracker/DailyCalorieSummary.tsx create mode 100644 src/components/calorie-tracker/FoodLogManager.tsx create mode 100644 src/components/calorie-tracker/WeeklyTrendsChart.tsx create mode 100644 src/components/ui/progress.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 supabase/migrations/007_create_daily_nutrition_summaries.sql create mode 100644 supabase/migrations/008_create_user_nutrition_goals.sql diff --git a/package-lock.json b/package-lock.json index b7a5c03..964f3d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,14 @@ "dependencies": { "@google-cloud/vision": "^5.3.0", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.50.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "inngest": "^3.40.1", "lucide-react": "^0.523.0", "next": "15.3.3", @@ -3481,6 +3484,38 @@ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -3495,6 +3530,54 @@ } } }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", @@ -3517,6 +3600,30 @@ } } }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", @@ -3539,6 +3646,61 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -3556,6 +3718,103 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", @@ -6062,6 +6321,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/package.json b/package.json index 56eacc9..79416ba 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,14 @@ "dependencies": { "@google-cloud/vision": "^5.3.0", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.12", "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.50.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "inngest": "^3.40.1", "lucide-react": "^0.523.0", "next": "15.3.3", diff --git a/src/app/api/nutrition/dashboard/route.ts b/src/app/api/nutrition/dashboard/route.ts new file mode 100644 index 0000000..9b02576 --- /dev/null +++ b/src/app/api/nutrition/dashboard/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { format, subDays } from 'date-fns'; +import logger from '@/lib/logger'; + +/** + * Handles retrieval of nutrition dashboard data for authenticated users. + * + * Fetches daily nutrition summaries, user goals, recent meals, and analytics + * for a specified number of days (default: 7). Returns aggregated data including + * total calories, average daily intake, meal counts, and progress metrics. + * + * @param request - NextRequest containing optional 'days' query parameter + * @returns JSON response with dashboard data or error message + */ +export async function GET(request: NextRequest) { + try { + const supabase = await createClient(); + const { searchParams } = new URL(request.url); + const days = parseInt(searchParams.get('days') || '7'); + + // Check authentication + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const endDate = new Date(); + const startDate = subDays(endDate, days - 1); + + // Fetch daily summaries + const { data: dailySummaries, error: summariesError } = await supabase + .from('daily_nutrition_summaries') + .select('*') + .eq('user_id', user.id) + .gte('date', format(startDate, 'yyyy-MM-dd')) + .lte('date', format(endDate, 'yyyy-MM-dd')) + .order('date', { ascending: true }); + + if (summariesError) { + logger.error('Failed to fetch daily summaries', { error: summariesError, userId: user.id }); + throw summariesError; + } + + // Fetch user goals + const { data: userGoals, error: goalsError } = await supabase + .from('user_nutrition_goals') + .select('*') + .eq('user_id', user.id) + .single(); + + if (goalsError && goalsError.code !== 'PGRST116') { + logger.error('Failed to fetch user goals', { error: goalsError, userId: user.id }); + throw goalsError; + } + + // Fetch recent meal logs + const { data: recentMeals, error: mealsError } = await supabase + .from('nutrition_logs') + .select('*') + .eq('user_id', user.id) + .eq('processing_status', 'completed') + .order('created_at', { ascending: false }) + .limit(10); + + if (mealsError) { + logger.error('Failed to fetch recent meals', { error: mealsError, userId: user.id }); + throw mealsError; + } + + // Fetch today's summary specifically + const today = format(new Date(), 'yyyy-MM-dd'); + const todaySummary = dailySummaries?.find(s => s.date === today) || null; + + // Calculate analytics + const totalCalories = dailySummaries?.reduce((sum, day) => sum + (day.total_calories || 0), 0) || 0; + const avgCalories = dailySummaries?.length ? totalCalories / dailySummaries.length : 0; + const totalMeals = dailySummaries?.reduce((sum, day) => sum + (day.meal_count || 0), 0) || 0; + + // Calculate goal progress for today + const goalProgress = userGoals && todaySummary ? { + calories: { + current: todaySummary.total_calories || 0, + goal: userGoals.daily_calorie_goal || 2000, + percentage: ((todaySummary.total_calories || 0) / (userGoals.daily_calorie_goal || 2000)) * 100 + }, + protein: { + current: todaySummary.total_protein_g || 0, + goal: userGoals.daily_protein_goal_g || 150, + percentage: ((todaySummary.total_protein_g || 0) / (userGoals.daily_protein_goal_g || 150)) * 100 + }, + carbs: { + current: todaySummary.total_carbs_g || 0, + goal: userGoals.daily_carbs_goal_g || 200, + percentage: ((todaySummary.total_carbs_g || 0) / (userGoals.daily_carbs_goal_g || 200)) * 100 + }, + fat: { + current: todaySummary.total_fat_g || 0, + goal: userGoals.daily_fat_goal_g || 70, + percentage: ((todaySummary.total_fat_g || 0) / (userGoals.daily_fat_goal_g || 70)) * 100 + } + } : null; + + logger.info('Dashboard data fetched successfully', { + userId: user.id, + daysRequested: days, + summariesCount: dailySummaries?.length || 0, + mealsCount: recentMeals?.length || 0 + }); + + return NextResponse.json({ + success: true, + data: { + dailySummaries, + todaySummary, + userGoals, + recentMeals, + goalProgress, + analytics: { + totalCalories: Math.round(totalCalories), + avgCalories: Math.round(avgCalories), + totalMeals, + daysTracked: dailySummaries?.length || 0, + period: { + startDate: format(startDate, 'yyyy-MM-dd'), + endDate: format(endDate, 'yyyy-MM-dd'), + days + } + } + } + }); + + } catch (error) { + logger.error('Dashboard API error', { error, stack: error instanceof Error ? error.stack : undefined }); + return NextResponse.json({ + error: 'Failed to fetch dashboard data', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/nutrition/goals/route.ts b/src/app/api/nutrition/goals/route.ts new file mode 100644 index 0000000..43c2e8f --- /dev/null +++ b/src/app/api/nutrition/goals/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import logger from '@/lib/logger'; + +/** + * Handles retrieval and management of user nutrition goals. + * + * GET: Fetches the user's current nutrition goals + * POST: Creates or updates nutrition goals for the authenticated user + * + * @param request - NextRequest for goal management operations + * @returns JSON response with goals data or error message + */ +export async function GET(request: NextRequest) { + try { + const supabase = await createClient(); + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { data: goals, error } = await supabase + .from('user_nutrition_goals') + .select('*') + .eq('user_id', user.id) + .single(); + + if (error && error.code !== 'PGRST116') { + logger.error('Failed to fetch user goals', { error, userId: user.id }); + throw error; + } + + // If no goals exist, return default values + const defaultGoals = { + daily_calorie_goal: 2000, + daily_protein_goal_g: 150, + daily_carbs_goal_g: 200, + daily_fat_goal_g: 70, + daily_fiber_goal_g: 25, + activity_level: 'moderate', + weight_goal: 'maintain' + }; + + logger.info('User goals fetched', { userId: user.id, hasGoals: !!goals }); + + return NextResponse.json({ + success: true, + goals: goals || defaultGoals + }); + + } catch (error) { + logger.error('Get goals API error', { error }); + return NextResponse.json({ + error: 'Failed to fetch goals', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { + daily_calorie_goal, + daily_protein_goal_g, + daily_carbs_goal_g, + daily_fat_goal_g, + daily_fiber_goal_g, + activity_level, + weight_goal, + } = body; + + // Validate required fields + if (!daily_calorie_goal || daily_calorie_goal < 500 || daily_calorie_goal > 5000) { + return NextResponse.json({ + error: 'Invalid daily calorie goal. Must be between 500-5000.' + }, { status: 400 }); + } + + // Validate activity level + const validActivityLevels = ['sedentary', 'light', 'moderate', 'active', 'very_active']; + if (activity_level && !validActivityLevels.includes(activity_level)) { + return NextResponse.json({ + error: 'Invalid activity level. Must be one of: ' + validActivityLevels.join(', ') + }, { status: 400 }); + } + + // Validate weight goal + const validWeightGoals = ['lose', 'maintain', 'gain']; + if (weight_goal && !validWeightGoals.includes(weight_goal)) { + return NextResponse.json({ + error: 'Invalid weight goal. Must be one of: ' + validWeightGoals.join(', ') + }, { status: 400 }); + } + + const { data: goals, error } = await supabase + .from('user_nutrition_goals') + .upsert({ + user_id: user.id, + daily_calorie_goal: parseInt(daily_calorie_goal), + daily_protein_goal_g: parseFloat(daily_protein_goal_g) || 150, + daily_carbs_goal_g: parseFloat(daily_carbs_goal_g) || 200, + daily_fat_goal_g: parseFloat(daily_fat_goal_g) || 70, + daily_fiber_goal_g: parseFloat(daily_fiber_goal_g) || 25, + activity_level: activity_level || 'moderate', + weight_goal: weight_goal || 'maintain', + updated_at: new Date().toISOString(), + }) + .select() + .single(); + + if (error) { + logger.error('Failed to update user goals', { error, userId: user.id }); + throw error; + } + + logger.info('User goals updated successfully', { + userId: user.id, + calorieGoal: daily_calorie_goal, + activityLevel: activity_level, + weightGoal: weight_goal + }); + + return NextResponse.json({ + success: true, + goals + }); + + } catch (error) { + logger.error('Update goals API error', { error }); + return NextResponse.json({ + error: 'Failed to update goals', + details: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/calorie-tracker/page.tsx b/src/app/calorie-tracker/page.tsx index 4515300..6d61513 100644 --- a/src/app/calorie-tracker/page.tsx +++ b/src/app/calorie-tracker/page.tsx @@ -1,8 +1,7 @@ import { Suspense } from 'react' import { createClient } from '@/utils/supabase/server' import { redirect } from 'next/navigation' -import PhotoUpload from '@/components/calorie-tracker/PhotoUpload' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { CalorieTrackerDashboard } from '@/components/calorie-tracker/CalorieTrackerDashboard' import { Alert, AlertDescription } from '@/components/ui/alert' import { CheckCircle } from 'lucide-react' @@ -11,11 +10,12 @@ interface CalorieTrackerPageProps { } /** - * Renders the calorie tracker page, displaying a meal logging interface for authenticated users. + * Renders the comprehensive calorie tracker dashboard for authenticated users. * - * Redirects unauthenticated users to the login page. Conditionally shows a success alert if a meal has been logged, and provides a photo upload component for meal analysis. + * Displays daily nutrition summary, weekly trends, recent meals, and meal logging interface. + * Redirects unauthenticated users to the login page. Shows success messages for completed actions. * - * @param searchParams - Optional search parameters, including a `success` flag to indicate if a meal was logged successfully + * @param searchParams - Optional search parameters for success messages and navigation state */ export default async function CalorieTrackerPage({ searchParams }: CalorieTrackerPageProps) { const supabase = await createClient() @@ -30,33 +30,24 @@ export default async function CalorieTrackerPage({ searchParams }: CalorieTracke const showSuccessMessage = resolvedSearchParams.success === 'true' return ( -
-
- {showSuccessMessage && ( - - - - Your meal has been successfully logged! - - - )} +
+ {showSuccessMessage && ( + + + + Your meal has been successfully logged! + + + )} - - - Calorie Tracker - - -

- Take a photo of your meal and let AI analyze the nutritional content, - or enter the information manually. -

-
-
- - Loading...
}> - - -
+ +
+ Loading your nutrition dashboard... +
+ }> + + ) } \ No newline at end of file diff --git a/src/components/calorie-tracker/AIAnalysisDisplay.tsx b/src/components/calorie-tracker/AIAnalysisDisplay.tsx new file mode 100644 index 0000000..5638b8f --- /dev/null +++ b/src/components/calorie-tracker/AIAnalysisDisplay.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { + Brain, + CheckCircle, + AlertTriangle, + RefreshCw, + Eye, + Info, + Lightbulb, + Camera, + Zap +} from 'lucide-react'; + +interface FoodItem { + name: string; + quantity: string; + calories: number; + protein_g: number; + carbs_g: number; + fat_g: number; +} + +interface AIAnalysisLog { + id: string; + confidence_score: number; + processing_status: string; + food_items: FoodItem[]; + total_calories: number; + notes: string; + image_url: string; + error_message?: string; + created_at: string; +} + +interface AIAnalysisDisplayProps { + log: AIAnalysisLog; + isCompact?: boolean; + onReprocess?: (logId: string) => void; + onCorrect?: (logId: string) => void; + className?: string; +} + +/** + * Displays AI analysis results with confidence scoring and feedback options. + * + * Shows processing status, confidence indicators, detected food items, + * error handling, and improvement suggestions. Provides options for + * reprocessing failed analyses and manual corrections. + * + * @param log - The nutrition log with AI analysis data + * @param isCompact - Whether to show a compact version + * @param onReprocess - Callback to reprocess the analysis + * @param onCorrect - Callback to manually correct the analysis + * @param className - Optional CSS class for styling + */ +export function AIAnalysisDisplay({ + log, + isCompact = false, + onReprocess, + onCorrect, + className +}: AIAnalysisDisplayProps) { + const [isReprocessing, setIsReprocessing] = useState(false); + + const getConfidenceColor = (score: number) => { + if (score >= 0.8) return 'bg-green-500'; + if (score >= 0.6) return 'bg-yellow-500'; + return 'bg-red-500'; + }; + + const getConfidenceText = (score: number) => { + if (score >= 0.8) return 'High Confidence'; + if (score >= 0.6) return 'Medium Confidence'; + return 'Low Confidence'; + }; + + const getConfidenceDescription = (score: number) => { + if (score >= 0.8) return 'The AI is very confident in this analysis'; + if (score >= 0.6) return 'The AI has moderate confidence - consider reviewing'; + return 'The AI has low confidence - manual review recommended'; + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'completed': + return ; + case 'processing': + return ; + case 'failed': + return ; + default: + return ; + } + }; + + const handleReprocess = async () => { + if (!onReprocess) return; + + setIsReprocessing(true); + try { + await onReprocess(log.id); + } catch (error) { + console.error('Reprocess failed:', error); + } finally { + setIsReprocessing(false); + } + }; + + const getTips = (score: number, hasError: boolean) => { + if (hasError) return []; + + if (score < 0.7) { + return [ + 'Ensure good lighting when taking photos', + 'Include the entire meal in the frame', + 'Avoid heavily processed or mixed foods', + 'Take photos from directly above the food', + 'Remove packaging or plates that obscure the food' + ]; + } + return []; + }; + + // Compact version for dashboard views + if (isCompact) { + return ( +
+ + {log.processing_status === 'completed' && ( + <> + + {(log.confidence_score * 100).toFixed(0)}% + + AI Confidence + + )} + {log.processing_status === 'processing' && ( + + + Processing + + )} + {log.processing_status === 'failed' && ( + + + Failed + + )} +
+ ); + } + + const tips = getTips(log.confidence_score, !!log.error_message); + + return ( + + + + + AI Analysis + {getStatusIcon(log.processing_status)} + + + + + {/* Processing Status */} + {log.processing_status === 'processing' && ( + + + +
+ + Analyzing your meal photo with AI... +
+
+ This usually takes 10-30 seconds +
+
+
+ )} + + {/* Failed Status */} + {log.processing_status === 'failed' && ( + + + +
Analysis Failed
+ {log.error_message && ( +

{log.error_message}

+ )} + +
+
+ )} + + {/* Completed Analysis */} + {log.processing_status === 'completed' && ( + <> + {/* Confidence Score */} +
+
+ Analysis Confidence + + {getConfidenceText(log.confidence_score)} + +
+ + + +
+ + {getConfidenceDescription(log.confidence_score)} + + + {(log.confidence_score * 100).toFixed(1)}% + +
+
+ + {/* Food Items Analysis */} + {log.food_items && log.food_items.length > 0 && ( +
+

+ + Detected Food Items ({log.food_items.length}) +

+ +
+ {log.food_items.map((item, index) => ( +
+
+
+ {item.name} + ({item.quantity}) +
+
+ {item.calories} cal +
+
+
+
Protein: {item.protein_g}g
+
Carbs: {item.carbs_g}g
+
Fat: {item.fat_g}g
+
+
+ ))} +
+ + {/* Total Summary */} +
+
+ Total Estimated + + {Math.round(log.total_calories)} calories + +
+
+
+ )} + + {/* Analysis Notes */} + {log.notes && ( +
+
+ + AI Notes +
+

{log.notes}

+
+ )} + + {/* Image Reference */} + {log.image_url && ( +
+ + Analysis based on uploaded image + +
+ )} + + )} + + {/* Action Buttons */} + {log.processing_status === 'completed' && ( +
+ {onCorrect && ( + + )} + {onReprocess && ( + + )} +
+ )} + + {/* Improvement Tips */} + {tips.length > 0 && ( +
+
+ + Tips for Better AI Analysis +
+
    + {tips.map((tip, index) => ( +
  • • {tip}
  • + ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/calorie-tracker/CalorieTrackerDashboard.tsx b/src/components/calorie-tracker/CalorieTrackerDashboard.tsx new file mode 100644 index 0000000..8f15999 --- /dev/null +++ b/src/components/calorie-tracker/CalorieTrackerDashboard.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { DailyCalorieSummary } from './DailyCalorieSummary'; +import { WeeklyTrendsChart } from './WeeklyTrendsChart'; +import { FoodLogManager } from './FoodLogManager'; +import { AIAnalysisDisplay } from './AIAnalysisDisplay'; +import PhotoUpload from './PhotoUpload'; +import { + LayoutDashboard, + Plus, + TrendingUp, + History, + RefreshCw, + AlertCircle +} from 'lucide-react'; + +interface DashboardData { + dailySummaries: any[]; + todaySummary: any; + userGoals: any; + recentMeals: any[]; + goalProgress: any; + analytics: any; +} + +interface CalorieTrackerDashboardProps { + userId: string; +} + +/** + * Main calorie tracker dashboard component that orchestrates all Phase 3 functionality. + * + * Provides a tabbed interface with dashboard overview, meal logging, trends analysis, + * and meal history. Fetches dashboard data from API and manages state for all child + * components. Handles data refresh when meals are added or updated. + * + * @param userId - ID of the authenticated user + */ +export function CalorieTrackerDashboard({ userId }: CalorieTrackerDashboardProps) { + const [dashboardData, setDashboardData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('dashboard'); + const [refreshKey, setRefreshKey] = useState(0); + + // Fetch dashboard data + const fetchDashboardData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch('/api/nutrition/dashboard?days=7', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch dashboard data: ${response.statusText}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch dashboard data'); + } + + setDashboardData(result.data); + } catch (err) { + console.error('Dashboard fetch error:', err); + setError(err instanceof Error ? err.message : 'Unknown error occurred'); + } finally { + setLoading(false); + } + }, []); + + // Initial data fetch + useEffect(() => { + fetchDashboardData(); + }, [fetchDashboardData, refreshKey]); + + // Handle data refresh when meals are updated + const handleDataRefresh = useCallback(() => { + setRefreshKey(prev => prev + 1); + }, []); + + // Handle navigation to add meal tab + const handleAddMeal = useCallback(() => { + setActiveTab('add-meal'); + }, []); + + // Handle goal editing (placeholder for future implementation) + const handleEditGoals = useCallback(() => { + // TODO: Implement goal editing modal/dialog + alert('Goal editing will be implemented in a future update'); + }, []); + + // Loading state + if (loading) { + return ( +
+
+
+

Loading Dashboard

+

Fetching your nutrition data...

+
+
+ ); + } + + // Error state + if (error) { + return ( + + + +
Failed to load dashboard data
+
{error}
+ +
+
+ ); + } + + return ( +
+ {/* Dashboard Header */} +
+
+

Nutrition Dashboard

+

+ Track your daily nutrition and achieve your health goals +

+
+
+ + +
+
+ + {/* Main Dashboard Content */} + + + + + Dashboard + + + + Add Meal + + + + Trends + + + + History + + + + {/* Dashboard Tab */} + +
+ {/* Daily Summary - Takes up 2 columns on large screens */} +
+ +
+ + {/* Weekly Trends - Takes up 1 column */} +
+ +
+
+ + {/* Recent Meals */} + + +
+ Recent Meals + +
+
+ + {(dashboardData?.recentMeals?.length || 0) > 0 ? ( +
+ {dashboardData?.recentMeals?.slice(0, 3).map((meal: any) => ( +
+
+
+ {new Date(meal.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + })} +
+
+ {Math.round(meal.total_calories)} cal +
+
+ + {meal.food_items?.length > 0 && ( +
+ {meal.food_items.slice(0, 2).map((item: any, index: number) => ( + + {item.name} + {index < meal.food_items.length - 1 && index < 1 ? ', ' : ''} + + ))} + {meal.food_items.length > 2 && ` +${meal.food_items.length - 2} more`} +
+ )} + + +
+ ))} +
+ ) : ( +
+
No meals logged yet
+ +
+ )} +
+
+ + {/* Analytics Summary */} + {dashboardData?.analytics && ( +
+ + +
+ {dashboardData.analytics.avgCalories} +
+

Avg Calories/Day

+
+
+ + +
+ {dashboardData.analytics.totalMeals} +
+

Total Meals

+
+
+ + +
+ {dashboardData.analytics.daysTracked} +
+

Days Tracked

+
+
+ + +
+ {Math.round((dashboardData.analytics.daysTracked / 7) * 100)}% +
+

Week Complete

+
+
+
+ )} +
+ + {/* Add Meal Tab */} + +
+ +
+
+ + {/* Trends Tab */} + +
+ + + {/* Additional trend analysis could go here */} + + + Nutrition Insights + + +
+
+ Weekly Average: {dashboardData?.analytics?.avgCalories || 0} calories/day +
+
+ Goal Progress: + {dashboardData?.goalProgress && dashboardData.userGoals ? ( + 90 + ? 'text-green-600' : 'text-orange-600' + }`}> + {Math.round((dashboardData.goalProgress.calories.current / dashboardData.userGoals.daily_calorie_goal) * 100)}% of daily goal + + ) : ( + Set goals to see progress + )} +
+
+ Consistency: {dashboardData?.analytics?.daysTracked || 0} out of 7 days logged this week +
+
+
+
+
+
+ + {/* History Tab */} + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/calorie-tracker/DailyCalorieSummary.tsx b/src/components/calorie-tracker/DailyCalorieSummary.tsx new file mode 100644 index 0000000..33865e5 --- /dev/null +++ b/src/components/calorie-tracker/DailyCalorieSummary.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Flame, Target, TrendingUp, TrendingDown, Settings, Plus } from 'lucide-react'; + +interface DailySummaryData { + total_calories: number; + total_protein_g: number; + total_carbs_g: number; + total_fat_g: number; + total_fiber_g: number; + meal_count: number; +} + +interface UserGoals { + daily_calorie_goal: number; + daily_protein_goal_g: number; + daily_carbs_goal_g: number; + daily_fat_goal_g: number; + daily_fiber_goal_g: number; + activity_level?: string; + weight_goal?: string; +} + +interface GoalProgress { + calories: { current: number; goal: number; percentage: number }; + protein: { current: number; goal: number; percentage: number }; + carbs: { current: number; goal: number; percentage: number }; + fat: { current: number; goal: number; percentage: number }; +} + +interface DailyCalorieSummaryProps { + summary: DailySummaryData | null; + goals: UserGoals | null; + goalProgress: GoalProgress | null; + isLoading?: boolean; + onEditGoals?: () => void; + onAddMeal?: () => void; +} + +/** + * Displays daily calorie and macronutrient summary with progress toward user goals. + * + * Shows calorie consumption vs targets, macro breakdown with progress bars, + * meal count, and goal achievement status. Includes action buttons for + * goal editing and meal addition. + * + * @param summary - Daily nutrition summary data + * @param goals - User's nutrition goals + * @param goalProgress - Progress calculation for each macro + * @param isLoading - Loading state indicator + * @param onEditGoals - Callback for editing nutrition goals + * @param onAddMeal - Callback for adding new meal + */ +export function DailyCalorieSummary({ + summary, + goals, + goalProgress, + isLoading = false, + onEditGoals, + onAddMeal +}: DailyCalorieSummaryProps) { + if (isLoading) { + return ( + + + + + Today's Nutrition + + + +
+
+
+
+
+ + + ); + } + + const caloriesConsumed = summary?.total_calories || 0; + const calorieGoal = goals?.daily_calorie_goal || 2000; + const caloriesRemaining = calorieGoal - caloriesConsumed; + const calorieProgress = Math.min((caloriesConsumed / calorieGoal) * 100, 100); + + const macros = [ + { + name: 'Protein', + consumed: summary?.total_protein_g || 0, + goal: goals?.daily_protein_goal_g || 150, + unit: 'g', + color: 'bg-blue-500', + progress: goalProgress?.protein?.percentage || 0, + }, + { + name: 'Carbs', + consumed: summary?.total_carbs_g || 0, + goal: goals?.daily_carbs_goal_g || 200, + unit: 'g', + color: 'bg-green-500', + progress: goalProgress?.carbs?.percentage || 0, + }, + { + name: 'Fat', + consumed: summary?.total_fat_g || 0, + goal: goals?.daily_fat_goal_g || 70, + unit: 'g', + color: 'bg-yellow-500', + progress: goalProgress?.fat?.percentage || 0, + }, + { + name: 'Fiber', + consumed: summary?.total_fiber_g || 0, + goal: goals?.daily_fiber_goal_g || 25, + unit: 'g', + color: 'bg-purple-500', + progress: ((summary?.total_fiber_g || 0) / (goals?.daily_fiber_goal_g || 25)) * 100, + }, + ]; + + const getCalorieStatus = () => { + const percentageOfGoal = (caloriesConsumed / calorieGoal) * 100; + if (percentageOfGoal < 50) return { color: 'text-orange-600', icon: TrendingUp, text: "Keep going!" }; + if (percentageOfGoal < 90) return { color: 'text-blue-600', icon: Target, text: 'On track' }; + if (percentageOfGoal <= 110) return { color: 'text-green-600', icon: Target, text: "Goal achieved!" }; + return { color: 'text-red-600', icon: TrendingDown, text: 'Over goal' }; + }; + + const calorieStatus = getCalorieStatus(); + const StatusIcon = calorieStatus.icon; + + return ( + + +
+ + + Today's Nutrition + +
+ {onAddMeal && ( + + )} + {onEditGoals && ( + + )} +
+
+
+ + + {/* Calorie Summary */} +
+
+
+ Calories + + + {calorieStatus.text} + +
+
+
+ {Math.round(caloriesConsumed)} +
+
+ / {calorieGoal} cal +
+
+
+ + + +
+ + {summary?.meal_count || 0} meals logged + + = 0 ? 'text-green-600' : 'text-red-600'}`}> + {caloriesRemaining >= 0 ? ( + + + {Math.round(caloriesRemaining)} remaining + + ) : ( + + + {Math.round(Math.abs(caloriesRemaining))} over + + )} + +
+
+ + {/* Macronutrients */} +
+

Macronutrients

+
+ {macros.map((macro) => ( +
+
+ +
+ {macro.name} + + + {macro.consumed.toFixed(1)}{macro.unit} + +
+ +
+ Goal: {macro.goal}{macro.unit} + {macro.progress.toFixed(0)}% +
+
+ ))} +
+
+ + {/* No data state */} + {(!summary || summary.meal_count === 0) && ( +
+
No meals logged today
+ {onAddMeal && ( + + )} +
+ )} + + + ); +} \ No newline at end of file diff --git a/src/components/calorie-tracker/FoodLogManager.tsx b/src/components/calorie-tracker/FoodLogManager.tsx new file mode 100644 index 0000000..6951e7a --- /dev/null +++ b/src/components/calorie-tracker/FoodLogManager.tsx @@ -0,0 +1,484 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { createClient } from '@/utils/supabase/client'; +import { format } from 'date-fns'; +import { + Search, + Calendar, + Edit, + Trash2, + Image as ImageIcon, + AlertCircle, + Check, + X, + Clock, + ChefHat, + Filter +} from 'lucide-react'; + +interface FoodItem { + name: string; + quantity: string; + calories: number; + protein_g: number; + carbs_g: number; + fat_g: number; + fiber_g?: number; +} + +interface FoodLog { + id: string; + food_items: FoodItem[]; + total_calories: number; + total_protein_g: number; + total_carbs_g: number; + total_fat_g: number; + total_fiber_g: number; + confidence_score: number; + image_url: string; + notes: string; + created_at: string; + processing_status: string; +} + +interface FoodLogManagerProps { + userId: string; + className?: string; + onLogUpdated?: () => void; +} + +/** + * Manages food log entries with search, filter, edit, and delete functionality. + * + * Displays a list of meal entries with the ability to search by food items, + * filter by date, edit nutrition values, and delete entries. Shows processing + * status, confidence scores, and provides inline editing capabilities. + * + * @param userId - ID of the authenticated user + * @param className - Optional CSS class for styling + * @param onLogUpdated - Callback fired when a log is updated or deleted + */ +export function FoodLogManager({ userId, className, onLogUpdated }: FoodLogManagerProps) { + const [logs, setLogs] = useState([]); + const [filteredLogs, setFilteredLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [dateFilter, setDateFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState<'all' | 'completed' | 'processing' | 'failed'>('all'); + const [editingLog, setEditingLog] = useState(null); + const [editValues, setEditValues] = useState>({}); + + const supabase = createClient(); + + useEffect(() => { + fetchLogs(); + }, [userId]); + + useEffect(() => { + filterLogs(); + }, [logs, searchTerm, dateFilter, statusFilter]); + + const fetchLogs = async () => { + try { + setLoading(true); + setError(null); + + const { data, error } = await supabase + .from('nutrition_logs') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(50); + + if (error) throw error; + setLogs(data || []); + } catch (err) { + console.error('Error fetching logs:', err); + setError(err instanceof Error ? err.message : 'Failed to fetch logs'); + } finally { + setLoading(false); + } + }; + + const filterLogs = () => { + let filtered = logs; + + // Status filter + if (statusFilter !== 'all') { + filtered = filtered.filter(log => log.processing_status === statusFilter); + } + + // Search filter + if (searchTerm) { + const searchLower = searchTerm.toLowerCase(); + filtered = filtered.filter(log => + log.food_items?.some(item => + item.name.toLowerCase().includes(searchLower) + ) || + log.notes?.toLowerCase().includes(searchLower) + ); + } + + // Date filter + if (dateFilter) { + filtered = filtered.filter(log => + format(new Date(log.created_at), 'yyyy-MM-dd') === dateFilter + ); + } + + setFilteredLogs(filtered); + }; + + const handleEdit = (log: FoodLog) => { + setEditingLog(log.id); + setEditValues({ + food_items: log.food_items, + notes: log.notes, + }); + }; + + const handleSaveEdit = async (logId: string) => { + try { + if (!editValues.food_items) return; + + // Recalculate totals + const totalCalories = editValues.food_items.reduce((sum, item) => sum + item.calories, 0); + const totalProtein = editValues.food_items.reduce((sum, item) => sum + item.protein_g, 0); + const totalCarbs = editValues.food_items.reduce((sum, item) => sum + item.carbs_g, 0); + const totalFat = editValues.food_items.reduce((sum, item) => sum + item.fat_g, 0); + const totalFiber = editValues.food_items.reduce((sum, item) => sum + (item.fiber_g || 0), 0); + + const { error } = await supabase + .from('nutrition_logs') + .update({ + food_items: editValues.food_items, + notes: editValues.notes, + total_calories: totalCalories, + total_protein_g: totalProtein, + total_carbs_g: totalCarbs, + total_fat_g: totalFat, + total_fiber_g: totalFiber, + }) + .eq('id', logId); + + if (error) throw error; + + setEditingLog(null); + setEditValues({}); + await fetchLogs(); + onLogUpdated?.(); + } catch (err) { + console.error('Error updating log:', err); + alert('Failed to update meal log'); + } + }; + + const handleDelete = async (logId: string) => { + if (!confirm('Are you sure you want to delete this meal log? This action cannot be undone.')) return; + + try { + const { error } = await supabase + .from('nutrition_logs') + .delete() + .eq('id', logId); + + if (error) throw error; + + await fetchLogs(); + onLogUpdated?.(); + } catch (err) { + console.error('Error deleting log:', err); + alert('Failed to delete meal log'); + } + }; + + const getConfidenceBadge = (score: number) => { + if (score >= 0.8) return High Confidence; + if (score >= 0.6) return Medium Confidence; + return Low Confidence; + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case 'completed': + return Completed; + case 'processing': + return Processing; + case 'failed': + return Failed; + default: + return Pending; + } + }; + + const updateFoodItem = (logId: string, index: number, field: keyof FoodItem, value: string | number) => { + if (editingLog === logId && editValues.food_items) { + const newItems = [...editValues.food_items]; + newItems[index] = { ...newItems[index], [field]: value }; + setEditValues({ ...editValues, food_items: newItems }); + } + }; + + if (loading) { + return ( + + + Food Log Management + + +
+
+
Loading your meal logs...
+
+
+
+ ); + } + + if (error) { + return ( + + + Error Loading Logs + + +
+ +
{error}
+ +
+
+
+ ); + } + + return ( +
+ + + + + Food Log Management + + + + + {/* Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + setDateFilter(e.target.value)} + className="pl-10 w-full sm:w-auto" + /> +
+
+ +
+ +
+ {[ + { value: 'all', label: 'All' }, + { value: 'completed', label: 'Completed' }, + { value: 'processing', label: 'Processing' }, + { value: 'failed', label: 'Failed' } + ].map(option => ( + + ))} +
+
+
+ + {/* Food Logs */} +
+ {filteredLogs.length === 0 ? ( +
+ +
No meal logs found
+
+ {searchTerm || dateFilter || statusFilter !== 'all' + ? 'Try adjusting your filters' + : 'Start logging meals to see them here' + } +
+
+ ) : ( + filteredLogs.map((log) => ( + + +
+
+
+ + {format(new Date(log.created_at), 'MMM d, yyyy • h:mm a')} +
+ {log.processing_status === 'completed' && getConfidenceBadge(log.confidence_score)} + {getStatusBadge(log.processing_status)} +
+
+ + +
+
+ + {/* Food Items */} +
+ {editingLog === log.id ? ( +
+ {editValues.food_items?.map((item, index) => ( +
+ updateFoodItem(log.id, index, 'name', e.target.value)} + placeholder="Food name" + className="text-sm" + /> + updateFoodItem(log.id, index, 'quantity', e.target.value)} + placeholder="Quantity" + className="text-sm" + /> + updateFoodItem(log.id, index, 'calories', parseInt(e.target.value) || 0)} + placeholder="Calories" + className="text-sm" + /> + updateFoodItem(log.id, index, 'protein_g', parseFloat(e.target.value) || 0)} + placeholder="Protein (g)" + className="text-sm" + /> +
+ C: {item.carbs_g}g | F: {item.fat_g}g +
+
+ ))} +
+ + +
+
+ ) : ( + log.food_items?.map((item, index) => ( +
+
+ {item.name} + ({item.quantity}) +
+
+ {item.calories} cal | P: {item.protein_g}g | C: {item.carbs_g}g | F: {item.fat_g}g +
+
+ )) + )} +
+ + {/* Total Calories */} +
+ Total Calories: + {Math.round(log.total_calories)} +
+ + {/* Notes */} + {(log.notes || editingLog === log.id) && ( +
+
Notes:
+ {editingLog === log.id ? ( +