Initial project scaffolding for health tracker app

Set up backend and frontend structure for a health and fitness tracker using Python (FastAPI, SQLModel, DSPy) and React. Includes Docker and Compose configs, authentication, nutrition AI module, health/nutrition/user endpoints, database models, and basic frontend with routing and context. Enables tracking nutrition, health metrics, and user management, with architecture ready for future mobile and cloud deployment.
This commit is contained in:
Carlos Escalante
2026-01-18 10:29:44 -06:00
parent b11e2740ea
commit 5dc6dc88f7
55 changed files with 5751 additions and 0 deletions

42
frontend/src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

33
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,33 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Nutrition from './pages/Nutrition';
import ProtectedRoute from './components/ProtectedRoute';
function App() {
return (
<AuthProvider>
<Router>
<div className="min-h-screen bg-gray-900 text-white font-sans">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
} />
<Route path="/nutrition" element={
<ProtectedRoute>
<Nutrition />
</ProtectedRoute>
} />
{/* Add Health route later */}
</Routes>
</div>
</Router>
</AuthProvider>
);
}
export default App;

View File

@@ -0,0 +1,17 @@
import axios from 'axios';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const client = axios.create({
baseURL: `${API_URL}/api/v1`,
});
client.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default client;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,13 @@
import { useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../context/AuthContext';
const ProtectedRoute = ({ children }) => {
const { token, loading } = useContext(AuthContext);
if (loading) return <div>Loading...</div>;
if (!token) return <Navigate to="/login" />;
return children;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,47 @@
import { createContext, useState, useEffect } from 'react';
import client from '../api/client';
export const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [token, setToken] = useState(localStorage.getItem('token'));
const [loading, setLoading] = useState(true);
useEffect(() => {
if (token) {
// Fetch user profile if needed, or just decode token
// For now, we assume user is logged in if token exists
// Ideally call /users/me
setLoading(false);
} else {
setLoading(false);
}
}, [token]);
const login = async (email, password) => {
const formData = new FormData();
formData.append('username', email);
formData.append('password', password);
const response = await client.post('/login/access-token', formData);
const { access_token } = response.data;
localStorage.setItem('token', access_token);
setToken(access_token);
// Ideally fetch user details here
return true;
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
};

3
frontend/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom';
const Dashboard = () => {
return (
<div className="p-8 text-white">
<h1 className="text-4xl font-bold mb-8">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Link to="/nutrition" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
<h2 className="text-2xl font-bold mb-2">Nutrition Tracker</h2>
<p className="text-gray-400">Log meals and view macros.</p>
</Link>
<Link to="/health" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
<h2 className="text-2xl font-bold mb-2">Health Metrics</h2>
<p className="text-gray-400">Track weight and blood indicators.</p>
</Link>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,57 @@
import { useState, useContext } from 'react';
import { AuthContext } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
const Login = () => {
const { login } = useContext(AuthContext);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await login(email, password);
navigate('/');
} catch (err) {
setError('Invalid credentials');
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
<div className="w-full max-w-md p-8 bg-gray-800 rounded-lg shadow-lg">
<h2 className="text-2xl font-bold mb-6 text-center text-purple-400">Healthy Fit Login</h2>
{error && <p className="text-red-500 mb-4">{error}</p>}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block mb-2">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-2 rounded bg-gray-700 border border-gray-600 focus:outline-none focus:border-purple-500"
required
/>
</div>
<div className="mb-6">
<label className="block mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2 rounded bg-gray-700 border border-gray-600 focus:outline-none focus:border-purple-500"
required
/>
</div>
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-700 text-white p-2 rounded font-bold transition">
Login
</button>
</form>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import client from '../api/client';
const Nutrition = () => {
const [description, setDescription] = useState('');
const [analysis, setAnalysis] = useState(null);
const [loading, setLoading] = useState(false);
const handleAnalyze = async () => {
setLoading(true);
try {
const res = await client.post('/nutrition/analyze', { description });
setAnalysis(res.data);
} catch (error) {
console.error(error);
alert('Failed to analyze');
} finally {
setLoading(false);
}
};
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('');
} catch (error) {
console.error(error);
alert('Failed to save');
}
};
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)}
/>
<button
onClick={handleAnalyze}
disabled={loading}
className="bg-blue-600 px-6 py-2 rounded text-white font-bold"
>
{loading ? 'Analyzing...' : 'Analyze'}
</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>
<button
onClick={handleSave}
className="bg-green-600 px-6 py-2 rounded text-white font-bold"
>
Save to Log
</button>
</div>
)}
</div>
);
};
export default Nutrition;