Add AI-powered nutrition and plan modules

Introduces DSPy-based nutrition and plan generation modules, including image analysis for nutritional info and personalized diet/exercise plans. Adds new API endpoints for health metrics/goals, nutrition image analysis, and plan management. Updates models, schemas, and backend structure to support these features, and includes initial training data and configuration for prompt optimization.
This commit is contained in:
Carlos Escalante
2026-01-18 17:14:56 -06:00
parent 5dc6dc88f7
commit 184c8330a7
36 changed files with 2868 additions and 110 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0"
},

View File

@@ -3,6 +3,8 @@ import { AuthProvider } from './context/AuthContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Nutrition from './pages/Nutrition';
import Health from './pages/Health';
import Plans from './pages/Plans';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
@@ -22,7 +24,16 @@ function App() {
<Nutrition />
</ProtectedRoute>
} />
{/* Add Health route later */}
<Route path="/health" element={
<ProtectedRoute>
<Health />
</ProtectedRoute>
} />
<Route path="/plans" element={
<ProtectedRoute>
<Plans />
</ProtectedRoute>
} />
</Routes>
</div>
</Router>

View File

@@ -13,6 +13,10 @@ const Dashboard = () => {
<h2 className="text-2xl font-bold mb-2">Health Metrics</h2>
<p className="text-gray-400">Track weight and blood indicators.</p>
</Link>
<Link to="/plans" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
<h2 className="text-2xl font-bold mb-2">AI Coach</h2>
<p className="text-gray-400">Get personalized diet & workout plans.</p>
</Link>
</div>
</div>
);

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useMemo } from 'react';
import client from '../api/client';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const Health = () => {
const [metrics, setMetrics] = useState([]);
const [goals, setGoals] = useState([]);
const [newMetric, setNewMetric] = useState({ metric_type: 'weight', value: '', unit: 'kg' });
const [newGoal, setNewGoal] = useState({ goal_type: 'lose_weight', target_value: '', target_date: '' });
const [loading, setLoading] = useState(false);
const [selectedMetricType, setSelectedMetricType] = useState('weight');
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const [metricsRes, goalsRes] = await Promise.all([
client.get('/health/metrics'),
client.get('/health/goals')
]);
setMetrics(metricsRes.data);
setGoals(goalsRes.data);
} catch (error) {
console.error('Failed to fetch health data', error);
}
};
const handleAddMetric = async (e) => {
e.preventDefault();
setLoading(true);
try {
await client.post('/health/metrics', newMetric);
setNewMetric({ ...newMetric, value: '' });
fetchData();
} catch (error) {
console.error(error);
alert('Failed to add metric');
} finally {
setLoading(false);
}
};
const handleAddGoal = async (e) => {
e.preventDefault();
setLoading(true);
try {
await client.post('/health/goals', {
...newGoal,
target_date: newGoal.target_date || null
});
setNewGoal({ ...newGoal, target_value: '', target_date: '' });
fetchData();
} catch (error) {
console.error(error);
alert('Failed to add goal');
} finally {
setLoading(false);
}
};
const chartData = useMemo(() => {
return metrics
.filter(m => m.metric_type === selectedMetricType)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.map(m => ({
date: new Date(m.timestamp).toLocaleDateString(),
value: m.value
}));
}, [metrics, selectedMetricType]);
return (
<div className="p-8 max-w-6xl mx-auto animated-fade-in">
<h1 className="text-3xl font-bold mb-8 text-white">Health Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Metrics Section */}
<div className="space-y-6">
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<h2 className="text-xl font-bold mb-4 text-blue-400">Track New Metric</h2>
<form onSubmit={handleAddMetric} className="space-y-4">
<div>
<label className="block text-gray-400 mb-1">Type</label>
<select
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
value={newMetric.metric_type}
onChange={(e) => setNewMetric({ ...newMetric, metric_type: e.target.value })}
>
<option value="weight">Weight</option>
<option value="cholesterol">Cholesterol</option>
<option value="vitamin_d">Vitamin D</option>
<option value="testosterone">Testosterone</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-gray-400 mb-1">Value</label>
<input
type="number"
step="0.01"
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
value={newMetric.value}
onChange={(e) => setNewMetric({ ...newMetric, value: e.target.value })}
required
/>
</div>
<div>
<label className="block text-gray-400 mb-1">Unit</label>
<input
type="text"
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
value={newMetric.unit}
onChange={(e) => setNewMetric({ ...newMetric, unit: e.target.value })}
required
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 p-2 rounded text-white font-bold transition-all shadow-lg"
>
{loading ? 'Adding...' : 'Add Metric'}
</button>
</form>
</div>
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Progress Chart</h2>
<select
className="bg-gray-700 text-sm text-gray-300 rounded p-1 border border-gray-600 outline-none"
value={selectedMetricType}
onChange={(e) => setSelectedMetricType(e.target.value)}
>
<option value="weight">Weight</option>
<option value="cholesterol">Cholesterol</option>
<option value="vitamin_d">Vitamin D</option>
<option value="testosterone">Testosterone</option>
</select>
</div>
<div className="h-64 w-full">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9CA3AF" />
<YAxis stroke="#9CA3AF" />
<Tooltip
contentStyle={{ backgroundColor: '#1F2937', border: 'none', color: '#F9FAFB' }}
/>
<Line type="monotone" dataKey="value" stroke="#3B82F6" strokeWidth={2} dot={{ r: 4 }} activeDot={{ r: 8 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-500">
No data available for this metric
</div>
)}
</div>
</div>
</div>
{/* Goals Section */}
<div className="space-y-6">
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<h2 className="text-xl font-bold mb-4 text-purple-400">Set New Goal</h2>
<form onSubmit={handleAddGoal} className="space-y-4">
<div>
<label className="block text-gray-400 mb-1">Goal Type</label>
<select
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
value={newGoal.goal_type}
onChange={(e) => setNewGoal({ ...newGoal, goal_type: e.target.value })}
>
<option value="lose_weight">Lose Weight</option>
<option value="gain_muscle">Gain Muscle</option>
<option value="improve_health">Improve Indicators</option>
</select>
</div>
<div>
<label className="block text-gray-400 mb-1">Target Value</label>
<input
type="number"
step="0.01"
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
value={newGoal.target_value}
onChange={(e) => setNewGoal({ ...newGoal, target_value: e.target.value })}
required
/>
</div>
<div>
<label className="block text-gray-400 mb-1">Target Date (Optional)</label>
<input
type="date"
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
value={newGoal.target_date}
onChange={(e) => setNewGoal({ ...newGoal, target_date: e.target.value })}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 p-2 rounded text-white font-bold transition-all shadow-lg"
>
{loading ? 'Setting...' : 'Set Goal'}
</button>
</form>
</div>
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<h2 className="text-xl font-bold mb-4 text-white">Active Goals</h2>
<div className="space-y-3">
{goals.length === 0 ? (
<p className="text-gray-500">No active goals.</p>
) : (
goals.map((g) => (
<div key={g.id} className="bg-gray-700/50 p-4 rounded border-l-4 border-purple-500 hover:bg-gray-700 transition-colors">
<div className="flex justify-between items-start">
<span className="font-bold text-white capitalize">{g.goal_type.replace('_', ' ')}</span>
<span className="text-purple-300 font-mono text-lg">{g.target_value}</span>
</div>
{g.target_date && (
<p className="text-xs text-gray-400 mt-1">
Target: {new Date(g.target_date).toLocaleDateString()}
</p>
)}
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default Health;

View File

@@ -1,15 +1,37 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import client from '../api/client';
const Nutrition = () => {
const [description, setDescription] = useState('');
const [analysis, setAnalysis] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const fileInputRef = useRef(null);
const handleFileSelect = (e) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
}
};
const handleAnalyze = async () => {
setLoading(true);
try {
const res = await client.post('/nutrition/analyze', { description });
let res;
if (selectedFile) {
const formData = new FormData();
formData.append('file', selectedFile);
if (description) {
formData.append('description', description);
}
res = await client.post('/nutrition/analyze/image', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
} else {
res = await client.post('/nutrition/analyze', { description });
}
setAnalysis(res.data);
} catch (error) {
console.error(error);
@@ -22,11 +44,12 @@ const Nutrition = () => {
const handleSave = async () => {
if (!analysis) return;
try {
// Backend extracts user from token via params/dependency
await client.post('/nutrition/log', analysis);
alert('Saved!');
setAnalysis(null);
setDescription('');
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (error) {
console.error(error);
alert('Failed to save');
@@ -35,51 +58,89 @@ const Nutrition = () => {
return (
<div className="p-8 max-w-4xl mx-auto">
<h1 className="text-3xl font-bold mb-6">Nutrition Tracker</h1>
<div className="bg-gray-800 p-6 rounded-lg mb-8">
<textarea
className="w-full p-4 bg-gray-700 rounded mb-4 text-white"
rows="4"
placeholder="Describe your meal (e.g. 'A chicken breast with a cup of rice')..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<h1 className="text-3xl font-bold mb-6 text-white">Nutrition Tracker</h1>
<div className="bg-gray-800 p-6 rounded-lg mb-8 shadow-lg">
<div className="mb-4">
<label className="block text-gray-300 mb-2 font-medium">Describe your meal or upload a photo</label>
<textarea
className="w-full p-4 bg-gray-700 rounded border border-gray-600 focus:border-blue-500 focus:outline-none text-white transition-colors"
rows="3"
placeholder="E.g. 'A chicken breast with a cup of rice'..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="mb-6">
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
ref={fileInputRef}
className="hidden"
id="food-image-upload"
/>
<label
htmlFor="food-image-upload"
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg border transition-all ${selectedFile
? 'bg-blue-900 border-blue-500 text-blue-200'
: 'bg-gray-700 border-gray-600 text-gray-300 hover:bg-gray-600'
}`}
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
{selectedFile ? selectedFile.name : 'Upload Photo'}
</label>
{selectedFile && (
<button
onClick={() => {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
className="ml-2 text-red-400 hover:text-red-300 text-sm"
>
Remove
</button>
)}
</div>
<button
onClick={handleAnalyze}
disabled={loading}
className="bg-blue-600 px-6 py-2 rounded text-white font-bold"
disabled={loading || (!description && !selectedFile)}
className={`px-8 py-3 rounded-lg font-bold text-white transition-all transform hover:scale-105 ${loading || (!description && !selectedFile)
? 'bg-gray-600 cursor-not-allowed'
: 'bg-gradient-to-r from-blue-600 to-indigo-600 shadow-lg hover:shadow-blue-500/50'
}`}
>
{loading ? 'Analyzing...' : 'Analyze'}
{loading ? 'Analyzing...' : 'Analyze Meal'}
</button>
</div>
{analysis && (
<div className="bg-gray-800 p-6 rounded-lg">
<h2 className="text-2xl font-bold mb-4">Analysis Result</h2>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-gray-700 rounded">
<span className="block text-gray-400">Calories</span>
<span className="text-xl font-bold">{analysis.calories}</span>
</div>
<div className="p-4 bg-gray-700 rounded">
<span className="block text-gray-400">Protein</span>
<span className="text-xl font-bold">{analysis.protein}g</span>
</div>
<div className="p-4 bg-gray-700 rounded">
<span className="block text-gray-400">Carbs</span>
<span className="text-xl font-bold">{analysis.carbs}g</span>
</div>
<div className="p-4 bg-gray-700 rounded">
<span className="block text-gray-400">Fats</span>
<span className="text-xl font-bold">{analysis.fats}g</span>
</div>
<div className="bg-gray-800 p-6 rounded-lg shadow-lg animated-fade-in">
<h2 className="text-2xl font-bold mb-6 text-white">Analysis Result</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{[
{ label: 'Calories', value: analysis.calories, unit: '' },
{ label: 'Protein', value: analysis.protein, unit: 'g' },
{ label: 'Carbs', value: analysis.carbs, unit: 'g' },
{ label: 'Fats', value: analysis.fats, unit: 'g' }
].map((item) => (
<div key={item.label} className="p-4 bg-gray-700/50 rounded-lg border border-gray-600 text-center">
<span className="block text-gray-400 text-sm">{item.label}</span>
<span className="text-2xl font-bold text-white">{item.value}{item.unit}</span>
</div>
))}
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
className="bg-green-600 hover:bg-green-700 px-8 py-2 rounded-lg text-white font-bold transition-colors shadow-lg hover:shadow-green-500/30"
>
Save to Log
</button>
</div>
<button
onClick={handleSave}
className="bg-green-600 px-6 py-2 rounded text-white font-bold"
>
Save to Log
</button>
</div>
)}
</div>

View File

@@ -0,0 +1,198 @@
import { useState, useEffect } from 'react';
import client from '../api/client';
import ReactMarkdown from 'react-markdown'; // Assuming we might want md support, but for now I'll use structured display or simple whitespace pre-line
const Plans = () => {
const [plans, setPlans] = useState([]);
const [goal, setGoal] = useState('');
const [userDetails, setUserDetails] = useState('');
const [loading, setLoading] = useState(false);
const [selectedPlan, setSelectedPlan] = useState(null);
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
try {
const res = await client.get('/plans/');
setPlans(res.data);
if (res.data.length > 0) setSelectedPlan(res.data[0]);
} catch (error) {
console.error('Failed to fetch plans', error);
}
};
const handleGenerate = async (e) => {
e.preventDefault();
setLoading(true);
try {
const res = await client.post('/plans/generate', { goal, user_details: userDetails });
setPlans([res.data, ...plans]);
setSelectedPlan(res.data);
setGoal('');
setUserDetails('');
} catch (error) {
console.error(error);
alert('Failed to generate plan');
} finally {
setLoading(false);
}
};
return (
<div className="p-8 max-w-7xl mx-auto animated-fade-in">
<h1 className="text-3xl font-bold mb-8 text-white">AI Coach</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column: Generator & History */}
<div className="space-y-6 lg:col-span-1">
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<h2 className="text-xl font-bold mb-4 text-purple-400">Request New Plan</h2>
<form onSubmit={handleGenerate} className="space-y-4">
<div>
<label className="block text-gray-400 mb-1">Your Goal</label>
<input
type="text"
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
placeholder="e.g. Lose 5kg in 2 months"
value={goal}
onChange={(e) => setGoal(e.target.value)}
required
/>
</div>
<div>
<label className="block text-gray-400 mb-1">Your Details</label>
<textarea
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
rows="3"
placeholder="Male, 30, 80kg, access to gym..."
value={userDetails}
onChange={(e) => setUserDetails(e.target.value)}
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 p-2 rounded text-white font-bold transition-all shadow-lg"
>
{loading ? 'Generating Plan...' : 'Generate Plan'}
</button>
</form>
</div>
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700 h-96 overflow-y-auto">
<h2 className="text-xl font-bold mb-4 text-white">History</h2>
<div className="space-y-3">
{plans.map((p) => (
<div
key={p.id}
onClick={() => setSelectedPlan(p)}
className={`p-4 rounded cursor-pointer transition-colors border-l-4 ${selectedPlan?.id === p.id
? 'bg-gray-700 border-purple-500'
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700'
}`}
>
<p className="font-bold text-white truncate">{p.goal}</p>
<p className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString()}</p>
</div>
))}
{plans.length === 0 && <p className="text-gray-500">No plans yet.</p>}
</div>
</div>
</div>
{/* Right Column: Plan View */}
<div className="lg:col-span-2">
{selectedPlan ? (
<div className="bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-700 min-h-[600px]">
{loading && selectedPlan === plans[0] && plans.length > 0 ? (
// Show loading logic if we just added it - actually layout handles this ok
// But better to check loading state
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-700 rounded w-1/3"></div>
<div className="h-4 bg-gray-700 rounded w-full"></div>
<div className="h-4 bg-gray-700 rounded w-full"></div>
</div>
) : (
<>
<div className="flex justify-between items-start mb-6 border-b border-gray-700 pb-4">
<div>
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
{selectedPlan.structured_content?.title || selectedPlan.goal}
</h2>
<p className="text-gray-400 mt-2">{selectedPlan.structured_content?.summary}</p>
</div>
<span className="text-sm text-gray-500 bg-gray-900 px-3 py-1 rounded-full">
{new Date(selectedPlan.created_at).toLocaleDateString()}
</span>
</div>
{selectedPlan.structured_content ? (
<div className="space-y-8">
<div>
<h3 className="text-xl font-bold text-blue-400 mb-3 flex items-center">
<span className="mr-2">🥗</span> Diet Plan
</h3>
<ul className="space-y-2">
{selectedPlan.structured_content.diet_plan?.map((item, i) => (
<li key={i} className="flex items-start text-gray-300">
<span className="mr-2 text-blue-500"></span>
{item}
</li>
))}
</ul>
</div>
<div>
<h3 className="text-xl font-bold text-green-400 mb-3 flex items-center">
<span className="mr-2">💪</span> Exercise Routine
</h3>
<ul className="space-y-2">
{selectedPlan.structured_content.exercise_plan?.map((item, i) => (
<li key={i} className="flex items-start text-gray-300">
<span className="mr-2 text-green-500"></span>
{item}
</li>
))}
</ul>
</div>
<div>
<h3 className="text-xl font-bold text-yellow-400 mb-3 flex items-center">
<span className="mr-2">💡</span> Coach Tips
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedPlan.structured_content.tips?.map((item, i) => (
<div key={i} className="bg-gray-700/50 p-4 rounded border border-gray-600">
<p className="text-gray-300">{item}</p>
</div>
))}
</div>
</div>
</div>
) : (
<div className="text-gray-300 whitespace-pre-wrap font-mono text-sm">
{selectedPlan.content}
</div>
)}
</>
)}
</div>
) : (
<div className="bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-700 h-full flex flex-col items-center justify-center text-center">
<div className="text-6xl mb-4">🤖</div>
<h2 className="text-2xl font-bold text-white mb-2">Welcome to AI Coach</h2>
<p className="text-gray-400 max-w-md">
Describe your goals and get a personalized nutrition and workout plan generated by our advanced AI.
</p>
</div>
)}
</div>
</div>
</div>
);
};
export default Plans;