feat(fitness): add AI Smart Macro Logger using Gemini API

This commit is contained in:
Parth
2026-09-21 17:27:44 +05:30
parent 05942d8f0f
commit e4c8fd3dcb
4 changed files with 324 additions and 1 deletions
+10
View File
@@ -8,6 +8,7 @@
"name": "antigravity-app",
"version": "0.1.0",
"dependencies": {
"@google/generative-ai": "^0.24.1",
"clsx": "^2.1.1",
"framer-motion": "^12.35.0",
"lucide-react": "^0.577.0",
@@ -459,6 +460,15 @@
"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": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"clsx": "^2.1.1",
"framer-motion": "^12.35.0",
"lucide-react": "^0.577.0",
+86
View File
@@ -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 }
);
}
}
+227 -1
View File
@@ -19,7 +19,9 @@ import {
TrendingUp,
Target,
Utensils,
Check
Check,
Loader2,
Bot
} from 'lucide-react';
import { WorkoutSession, DailyNutrition, MuscleRecovery, WorkoutExercise } from '@/lib/types';
import { SCIENCE_TIPS, getFormattedDate } from '@/lib/initialData';
@@ -95,6 +97,68 @@ export const FitnessView: React.FC<FitnessViewProps> = ({
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
const [workoutName, setWorkoutName] = useState('Push Hypertrophy');
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>
</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&apos;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">
{/* Calories Card */}
<div className="glass-card p-5 border-l-4 border-l-lime-500">