feat(fitness): add AI Smart Macro Logger using Gemini API
This commit is contained in:
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "antigravity-app",
|
"name": "antigravity-app",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
@@ -459,6 +460,15 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@google/generative-ai": {
|
||||||
|
"version": "0.24.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.24.1.tgz",
|
||||||
|
"integrity": "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.35.0",
|
"framer-motion": "^12.35.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { mealDescription } = await req.json();
|
||||||
|
|
||||||
|
if (!mealDescription || typeof mealDescription !== 'string' || !mealDescription.trim()) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: 'Please provide a valid meal description.' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (!apiKey) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'GEMINI_API_KEY is not configured in .env.local on the server.',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
|
||||||
|
// Use gemini-1.5-flash with structured JSON response
|
||||||
|
const model = genAI.getGenerativeModel({
|
||||||
|
model: 'gemini-1.5-flash',
|
||||||
|
generationConfig: {
|
||||||
|
responseMimeType: 'application/json',
|
||||||
|
temperature: 0.2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompt = `You are an expert clinical sports nutritionist and dietitian specializing in evidence-based macronutrient tracking.
|
||||||
|
Analyze the following meal description and accurately estimate the macronutrients and total energy intake.
|
||||||
|
|
||||||
|
Meal description:
|
||||||
|
"${mealDescription.trim()}"
|
||||||
|
|
||||||
|
Return ONLY a valid JSON object matching this schema:
|
||||||
|
{
|
||||||
|
"mealName": "Short descriptive title of the meal",
|
||||||
|
"calories": number (integer total kcal),
|
||||||
|
"protein": number (integer grams of protein),
|
||||||
|
"carbs": number (integer grams of carbohydrates),
|
||||||
|
"fats": number (integer grams of total fat),
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "Food item name and portion",
|
||||||
|
"calories": number,
|
||||||
|
"protein": number,
|
||||||
|
"carbs": number,
|
||||||
|
"fats": number
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"insight": "One concise sentence on the nutritional value or timing (e.g., 'Excellent post-workout ratio providing >3g leucine for MPS stimulus.')"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const result = await model.generateContent(prompt);
|
||||||
|
const responseText = result.response.text();
|
||||||
|
const data = JSON.parse(responseText);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
mealName: data.mealName || 'Logged Meal',
|
||||||
|
calories: Math.round(Number(data.calories) || 0),
|
||||||
|
protein: Math.round(Number(data.protein) || 0),
|
||||||
|
carbs: Math.round(Number(data.carbs) || 0),
|
||||||
|
fats: Math.round(Number(data.fats) || 0),
|
||||||
|
items: Array.isArray(data.items) ? data.items : [],
|
||||||
|
insight: data.insight || 'Meal analyzed successfully.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Error analyzing meal with Gemini:', error);
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to analyze meal';
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Target,
|
Target,
|
||||||
Utensils,
|
Utensils,
|
||||||
Check
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Bot
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { WorkoutSession, DailyNutrition, MuscleRecovery, WorkoutExercise } from '@/lib/types';
|
import { WorkoutSession, DailyNutrition, MuscleRecovery, WorkoutExercise } from '@/lib/types';
|
||||||
import { SCIENCE_TIPS, getFormattedDate } from '@/lib/initialData';
|
import { SCIENCE_TIPS, getFormattedDate } from '@/lib/initialData';
|
||||||
@@ -95,6 +97,68 @@ export const FitnessView: React.FC<FitnessViewProps> = ({
|
|||||||
setTimeout(() => setIsSynced(false), 2500);
|
setTimeout(() => setIsSynced(false), 2500);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AI Smart Macro Logger State
|
||||||
|
const [mealInput, setMealInput] = useState('');
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [aiError, setAiError] = useState<string | null>(null);
|
||||||
|
const [lastAnalyzedMeal, setLastAnalyzedMeal] = useState<{
|
||||||
|
mealName: string;
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fats: number;
|
||||||
|
items?: { name: string; calories: number; protein: number; carbs: number; fats: number }[];
|
||||||
|
insight?: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const sampleMealChips = [
|
||||||
|
'3 boiled eggs, 2 rotis with dal, 1 scoop whey',
|
||||||
|
'200g grilled chicken, 1 cup brown rice & broccoli',
|
||||||
|
'1 scoop whey isolate with 250ml milk & 1 banana',
|
||||||
|
'1 cup rolled oats, 1 tbsp peanut butter & Greek yogurt',
|
||||||
|
'Paneer bhurji (150g) with 2 multigrain rotis'
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleAnalyzeMeal = async (customPrompt?: string) => {
|
||||||
|
const promptToUse = customPrompt || mealInput;
|
||||||
|
if (!promptToUse.trim() || isAnalyzing) return;
|
||||||
|
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
setAiError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/analyze-meal', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mealDescription: promptToUse.trim() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok || !json.success) {
|
||||||
|
throw new Error(json.error || 'Failed to analyze meal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const meal = json.data;
|
||||||
|
setLastAnalyzedMeal(meal);
|
||||||
|
|
||||||
|
// Auto-fill calculated macros directly into the macro tracker!
|
||||||
|
onUpdateNutrition({
|
||||||
|
calories: Math.max(0, nutrition.calories + meal.calories),
|
||||||
|
protein: Math.max(0, nutrition.protein + meal.protein),
|
||||||
|
carbs: Math.max(0, nutrition.carbs + meal.carbs),
|
||||||
|
fats: Math.max(0, nutrition.fats + meal.fats),
|
||||||
|
});
|
||||||
|
|
||||||
|
setMealInput('');
|
||||||
|
playNotificationChime('streak');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('AI meal analyze error:', err);
|
||||||
|
setAiError(err instanceof Error ? err.message : 'Error analyzing meal. Check API key or network.');
|
||||||
|
} finally {
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Log Workout Form State
|
// Log Workout Form State
|
||||||
const [workoutName, setWorkoutName] = useState('Push Hypertrophy');
|
const [workoutName, setWorkoutName] = useState('Push Hypertrophy');
|
||||||
const [workoutDate, setWorkoutDate] = useState(getFormattedDate(0));
|
const [workoutDate, setWorkoutDate] = useState(getFormattedDate(0));
|
||||||
@@ -244,6 +308,168 @@ export const FitnessView: React.FC<FitnessViewProps> = ({
|
|||||||
<span className="text-xs text-slate-400">Bodyweight: <strong className="text-white">{nutrition.bodyweightKg} kg</strong></span>
|
<span className="text-xs text-slate-400">Bodyweight: <strong className="text-white">{nutrition.bodyweightKg} kg</strong></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* AI SMART MEAL INTAKE CARD */}
|
||||||
|
<div className="glass-card p-5 sm:p-6 border border-lime-500/30 bg-gradient-to-br from-[#0c1a17]/90 via-[#0d1424]/90 to-[#120f26]/90 relative overflow-hidden shadow-2xl space-y-4">
|
||||||
|
<div className="absolute top-0 right-0 w-80 h-80 bg-lime-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-80 h-80 bg-purple-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
{/* Card Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 relative z-10">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-2 rounded-xl bg-gradient-to-tr from-lime-500/20 to-emerald-500/20 border border-lime-500/40 text-lime-400">
|
||||||
|
<Bot className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm sm:text-base font-bold text-white flex items-center gap-2">
|
||||||
|
<span>AI Smart Meal Intake Logger</span>
|
||||||
|
<span className="text-[10px] font-extrabold uppercase px-2 py-0.5 rounded-full bg-lime-500/10 text-lime-400 border border-lime-500/30">
|
||||||
|
Gemini AI
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Describe what you ate in natural language—AI calculates the macros and updates your tracker automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meal Input and Submit Action */}
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAnalyzeMeal();
|
||||||
|
}}
|
||||||
|
className="space-y-3 relative z-10"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mealInput}
|
||||||
|
onChange={(e) => setMealInput(e.target.value)}
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
placeholder="e.g. 3 boiled eggs, 2 rotis with dal, 1 scoop whey protein"
|
||||||
|
className="flex-1 px-4 py-3 rounded-xl bg-slate-950/80 border border-slate-800 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-lime-500 transition-colors disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isAnalyzing || !mealInput.trim()}
|
||||||
|
className="px-6 py-3 rounded-xl bg-gradient-to-r from-lime-500 via-emerald-500 to-teal-500 text-slate-950 font-bold text-xs shadow-lg shadow-lime-500/20 hover:scale-102 active:scale-98 transition-all disabled:opacity-40 disabled:hover:scale-100 disabled:cursor-not-allowed cursor-pointer flex items-center justify-center gap-2 shrink-0"
|
||||||
|
>
|
||||||
|
{isAnalyzing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Analyzing with AI...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
<span>Analyze Meal with AI</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Prompt Chips */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 pt-1">
|
||||||
|
<span className="text-[11px] font-semibold text-slate-400 flex items-center gap-1 mr-1">
|
||||||
|
<Sparkles className="w-3 h-3 text-lime-400" />
|
||||||
|
Quick ideas:
|
||||||
|
</span>
|
||||||
|
{sampleMealChips.map((chip, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
disabled={isAnalyzing}
|
||||||
|
onClick={() => {
|
||||||
|
setMealInput(chip);
|
||||||
|
handleAnalyzeMeal(chip);
|
||||||
|
}}
|
||||||
|
className="text-[11px] px-2.5 py-1 rounded-lg bg-slate-900/80 hover:bg-slate-800 border border-slate-800 hover:border-lime-500/40 text-slate-300 hover:text-lime-300 transition-all cursor-pointer text-left truncate max-w-xs sm:max-w-none disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{chip}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Error Banner */}
|
||||||
|
{aiError && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-950/30 border border-red-500/40 text-xs text-red-300 flex items-center justify-between">
|
||||||
|
<span>⚠️ {aiError}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setAiError(null)}
|
||||||
|
className="text-red-400 hover:text-red-200 text-xs font-bold ml-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last Analyzed Meal Results Card */}
|
||||||
|
{lastAnalyzedMeal && (
|
||||||
|
<div className="p-4 rounded-xl bg-lime-950/20 border border-lime-500/40 space-y-3 animate-in fade-in duration-300 relative z-10">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-lime-500/20 pb-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-lime-400" />
|
||||||
|
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||||
|
{lastAnalyzedMeal.mealName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full border border-emerald-500/30">
|
||||||
|
✓ Auto-filled to Today's Intake
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Added Macro Badges */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900/90 border border-lime-500/30 text-center">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase block">Calories</span>
|
||||||
|
<span className="text-lg font-black text-lime-400 font-mono">+{lastAnalyzedMeal.calories}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 block">kcal</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900/90 border border-cyan-500/30 text-center">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase block">Protein</span>
|
||||||
|
<span className="text-lg font-black text-cyan-400 font-mono">+{lastAnalyzedMeal.protein}g</span>
|
||||||
|
<span className="text-[10px] text-slate-500 block">Leucine rich</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900/90 border border-amber-500/30 text-center">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase block">Carbohydrates</span>
|
||||||
|
<span className="text-lg font-black text-amber-400 font-mono">+{lastAnalyzedMeal.carbs}g</span>
|
||||||
|
<span className="text-[10px] text-slate-500 block">Glycogen fuel</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-slate-900/90 border border-orange-500/30 text-center">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase block">Healthy Fats</span>
|
||||||
|
<span className="text-lg font-black text-orange-400 font-mono">+{lastAnalyzedMeal.fats}g</span>
|
||||||
|
<span className="text-[10px] text-slate-500 block">Hormone balance</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Breakdown & Scientific Insight */}
|
||||||
|
{lastAnalyzedMeal.insight && (
|
||||||
|
<p className="text-xs text-lime-300/90 bg-slate-950/40 p-2.5 rounded-lg border border-lime-500/20 italic">
|
||||||
|
💡 {lastAnalyzedMeal.insight}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lastAnalyzedMeal.items && lastAnalyzedMeal.items.length > 0 && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-1">
|
||||||
|
Itemized Portion Estimates
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1.5">
|
||||||
|
{lastAnalyzedMeal.items.map((it, i) => (
|
||||||
|
<div key={i} className="text-[11px] p-1.5 px-2 rounded bg-slate-900/60 border border-slate-800 text-slate-300 flex items-center justify-between">
|
||||||
|
<span className="truncate pr-2 font-medium">{it.name}</span>
|
||||||
|
<span className="text-slate-400 shrink-0 font-mono">{it.calories} kcal ({it.protein}g P)</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Calories Card */}
|
{/* Calories Card */}
|
||||||
<div className="glass-card p-5 border-l-4 border-l-lime-500">
|
<div className="glass-card p-5 border-l-4 border-l-lime-500">
|
||||||
|
|||||||
Reference in New Issue
Block a user