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

5
.env.example Normal file
View File

@@ -0,0 +1,5 @@
OPENAI_API_KEY=your_key_here
POSTGRES_USER=your_user
POSTGRES_PASSWORD=your_password
POSTGRES_DB=your_db_name
VITE_API_URL=http://your_host:8000

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# System files
.DS_Store
Thumbs.db
# Environment Variables
.env
.env.*
!.env.example
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
.venv/
*.egg-info/
.coverage
htmlcov/
.pytest_cache/
# Node / Frontend
node_modules/
npm-debug.log
yarn-error.log
.npm-cache/
dist/
build/
.output/
# Docker
postgres_data/
# IDE settings
.vscode/
.idea/

View File

@@ -1,2 +1,13 @@
# healthy-fit
Health and Fitness tracker
Initial prompt:
I need you to help me develop an application using Python and React, the main goal for the app is to track health metrics: nutrition (macros, calories, minerals), exercise (walking, weight lifting, sports like tennis) and everything else related to living a healthy lifestyle.
We can introduce an AI component to the application and it'll be ideal to leverage DSPy to enhance the engineering of prompts. Advanced features could include RAG and a Knowledge graph, I still don't have an use case for these, so, help me think how these approaches could be used in the app. Vector/semantic search or GraphRAG could add some value so also consider those.
The user will be able to easily track the macros, nutrient and calories they consume by just uploading a picture from their phone, and then refine the details manually. This information would be saved to a DB of some sort (help me define which would be best) and then be used across the different app modules. The user can track their blood indicators such as cholesterol, vitamin D3, testosterone and more. Also the weight, height and set goals to improve health. The app can help the user create a diet and exercise to gain musle and or lose weight/fat depending on their preference.
The architecture of the app must support initially a web version, but in the future the idea is to launch it to the Apple Store and Google Play Store. We are exclusively focusing on open source technologies that can be deployed to any cloud such as AWS, GCP or Azure or even on-premise or private VPS running Linux. Docker and Kubernetes are the preferred platform to run the app and services.

10
backend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,25 @@
import dspy
from pydantic import BaseModel, Field
class NutritionalInfo(BaseModel):
name: str
calories: float
protein: float
carbs: float
fats: float
micros: dict | None = None
class ExtractNutrition(dspy.Signature):
"""Extract nutritional information from a food description."""
description: str = dspy.InputField(desc="Description of the food or meal")
nutritional_info: NutritionalInfo = dspy.OutputField(desc="Nutritional information as a structured object")
class NutritionModule(dspy.Module):
def __init__(self):
super().__init__()
self.extract = dspy.ChainOfThought(ExtractNutrition)
def forward(self, description: str):
return self.extract(description=description)
nutrition_module = NutritionModule()

View File

43
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,43 @@
from typing import Generator
from sqlmodel import Session
from app.db import engine
from typing import Generator, Annotated
from sqlmodel import Session, select
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from pydantic import ValidationError
from app.db import engine
from app.models.user import User
from app.core import security
from app.config import settings
from app.schemas.token import TokenPayload
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login/access-token")
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
TokenDep = Annotated[str, Depends(oauth2_scheme)]
def get_current_user(session: SessionDep, token: TokenDep) -> User:
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
token_data = TokenPayload(**payload)
except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
user = session.get(User, int(token_data.sub))
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
CurrentUser = Annotated[User, Depends(get_current_user)]

View File

View File

@@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api.v1.endpoints import users, login, nutrition, health
api_router = APIRouter()
api_router.include_router(login.router, tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
api_router.include_router(health.router, prefix="/health", tags=["health"])

View File

View File

@@ -0,0 +1,35 @@
from typing import Any, List
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.api import deps
from app.models.health import HealthMetric
from pydantic import BaseModel
router = APIRouter()
class HealthMetricCreate(BaseModel):
metric_type: str
value: float
unit: str
user_id: int # TODO: remove when auth is fully integrated
@router.post("/", response_model=HealthMetric)
def create_metric(
*,
session: Session = Depends(deps.get_session),
metric_in: HealthMetricCreate,
) -> Any:
metric = HealthMetric(metric_type=metric_in.metric_type, value=metric_in.value, unit=metric_in.unit, user_id=metric_in.user_id)
session.add(metric)
session.commit()
session.refresh(metric)
return metric
@router.get("/{user_id}", response_model=List[HealthMetric])
def read_metrics(
user_id: int,
session: Session = Depends(deps.get_session),
) -> Any:
statement = select(HealthMetric).where(HealthMetric.user_id == user_id)
metrics = session.exec(statement).all()
return metrics

View File

@@ -0,0 +1,35 @@
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select
from app.api import deps
from app.core import security
from app.config import settings
from app.models.user import User
from app.schemas.token import Token
router = APIRouter()
@router.post("/login/access-token", response_model=Token)
def login_access_token(
session: Session = Depends(deps.get_session),
form_data: OAuth2PasswordRequestForm = Depends()
) -> Any:
"""
OAuth2 compatible token login, get an access token for future requests
"""
statement = select(User).where(User.email == form_data.username)
user = session.exec(statement).first()
if not user or not security.verify_password(form_data.password, user.password_hash):
raise HTTPException(status_code=400, detail="Incorrect email or password")
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return {
"access_token": security.create_access_token(
user.id, expires_delta=access_token_expires
),
"token_type": "bearer",
}

View File

@@ -0,0 +1,54 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session
from app.api import deps
from app.ai.nutrition import nutrition_module, NutritionalInfo
from app.core.security import create_access_token # Just ensuring we have auth imports if needed later
from app.models.user import User
router = APIRouter()
class AnalyzeRequest(BaseModel):
description: str
from app.models.food import FoodLog, FoodItem
from app.api.deps import get_session
from app.core.security import get_password_hash # Not needed
from app.config import settings
@router.post("/analyze", response_model=NutritionalInfo)
def analyze_food(
request: AnalyzeRequest,
) -> Any:
"""
Analyze food description and return nutritional info using DSPy.
"""
try:
result = nutrition_module(description=request.description)
return result.nutritional_info
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/log", response_model=FoodLog)
def log_food(
*,
session: Session = Depends(deps.get_session),
nutrition_info: NutritionalInfo,
current_user: deps.CurrentUser,
) -> Any:
"""
Save food log to database.
"""
food_log = FoodLog(
user_id=current_user.id,
name=nutrition_info.name,
calories=nutrition_info.calories,
protein=nutrition_info.protein,
carbs=nutrition_info.carbs,
fats=nutrition_info.fats,
)
session.add(food_log)
session.commit()
session.refresh(food_log)
return food_log

View File

@@ -0,0 +1,36 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.api import deps
from app.core import security
from app.models.user import User
from app.schemas.user import UserCreate, UserRead
router = APIRouter()
@router.post("/", response_model=UserRead)
def create_user(
*,
session: Session = Depends(deps.get_session),
user_in: UserCreate,
) -> Any:
"""
Create new user.
"""
user = session.exec(select(User).where(User.email == user_in.email)).first()
if user:
raise HTTPException(
status_code=400,
detail="The user with this email already exists in the system",
)
user = User(
email=user_in.email,
username=user_in.username,
password_hash=security.get_password_hash(user_in.password),
)
session.add(user)
session.commit()
session.refresh(user)
return user

13
backend/app/config.py Normal file
View File

@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
DATABASE_URL: str
OPENAI_API_KEY: str | None = None
SECRET_KEY: str = "changethis"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
class Config:
env_file = ".env"
settings = Settings()

View File

View File

@@ -0,0 +1,7 @@
import dspy
from app.config import settings
def configure_dspy():
if settings.OPENAI_API_KEY:
lm = dspy.LM("openai/gpt-4o-mini", api_key=settings.OPENAI_API_KEY)
dspy.configure(lm=lm)

View File

@@ -0,0 +1,24 @@
from datetime import datetime, timedelta
from typing import Any, Union
from jose import jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)

14
backend/app/db.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlmodel import SQLModel, create_engine, Session, text
from app.config import settings
engine = create_engine(settings.DATABASE_URL)
def get_session():
with Session(engine) as session:
yield session
def init_db():
with Session(engine) as session:
session.exec(text("CREATE EXTENSION IF NOT EXISTS vector"))
session.commit()
SQLModel.metadata.create_all(engine)

33
backend/app/main.py Normal file
View File

@@ -0,0 +1,33 @@
from contextlib import asynccontextmanager
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI
from app.api.v1.api import api_router
from app.db import init_db
from app.core.ai_config import configure_dspy
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
configure_dspy()
yield
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Healthy Fit API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.get("/")
def read_root():
return {"message": "Welcome to Healthy Fit API"}

View File

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from typing import Optional, List, Dict
from sqlmodel import Field, SQLModel, JSON
from pgvector.sqlalchemy import Vector
from sqlalchemy import Column
class FoodItem(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(index=True)
calories: float
protein: float
carbs: float
fats: float
micros: Dict = Field(default={}, sa_column=Column(JSON))
embedding: List[float] = Field(sa_column=Column(Vector(1536))) # OpenAI embedding size
class FoodLog(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
food_item_id: Optional[int] = Field(default=None, foreign_key="fooditem.id")
name: str # In case no food item is linked or custom entry
calories: float
protein: float
carbs: float
fats: float
image_url: Optional[str] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,19 @@
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class HealthMetric(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
metric_type: str = Field(index=True) # e.g., "weight", "cholesterol", "testosterone"
value: float
unit: str
timestamp: datetime = Field(default_factory=datetime.utcnow)
class HealthGoal(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
goal_type: str # e.g., "lose_weight", "gain_muscle"
target_value: float
target_date: Optional[datetime] = None
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -0,0 +1,10 @@
from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
email: str = Field(index=True, unique=True)
password_hash: str
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

View File

@@ -0,0 +1,9 @@
from typing import Optional
from sqlmodel import SQLModel
class Token(SQLModel):
access_token: str
token_type: str
class TokenPayload(SQLModel):
sub: Optional[str] = None

View File

@@ -0,0 +1,17 @@
from typing import Optional
from sqlmodel import SQLModel, Field
class UserBase(SQLModel):
email: str
username: str
class UserCreate(UserBase):
password: str
class UserRead(UserBase):
id: int
class UserUpdate(SQLModel):
email: Optional[str] = None
username: Optional[str] = None
password: Optional[str] = None

14
backend/requirements.txt Normal file
View File

@@ -0,0 +1,14 @@
fastapi
uvicorn
pydantic-settings
sqlmodel
psycopg2-binary
pgvector
dspy-ai
python-multipart
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.0.1
pytest
httpx
python-dotenv

73
develop.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/bin/bash
# Exit on error
set -e
# Load environment variables from .env
if [ -f .env ]; then
set -a
source .env
set +a
else
echo "Error: .env file not found"
exit 1
fi
# Start Database container
echo "Starting database container..."
docker compose up -d db
echo "Waiting for database to be ready..."
until docker compose exec -T db pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}; do
echo "Database is unavailable - sleeping"
sleep 1
done
# Set DATABASE_URL for local connection (using mapped port 5435)
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5435/${POSTGRES_DB}"
echo "Configured DATABASE_URL for local development"
# Virtual Environment Setup
VENV_DIR="backend/.venv"
if [ ! -d "$VENV_DIR" ]; then
echo "Creating virtual environment in $VENV_DIR..."
python3 -m venv "$VENV_DIR"
fi
echo "Activating virtual environment..."
source "$VENV_DIR/bin/activate"
echo "Installing dependencies..."
pip install -r backend/requirements.txt
# Function to handle shutdown
cleanup() {
echo ""
echo "Stopping services..."
# Kill all child processes of this script
pkill -P $$
exit
}
# Trap signals for cleanup
trap cleanup SIGINT SIGTERM
echo "Starting Backend..."
# Run in background
(cd backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000) &
echo "Starting Frontend..."
# Run in background
(cd frontend && npm run dev) &
# Wait for process to settle
sleep 2
echo ""
echo "Development environment running!"
echo "Backend: http://localhost:8000"
echo "Frontend: http://localhost:5173"
echo "Press Ctrl+C to stop"
# Wait indefinitely for background jobs
wait

50
docker-compose.yml Normal file
View File

@@ -0,0 +1,50 @@
services:
db:
image: pgvector/pgvector:pg16
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5435:5432"
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U user -d healthyfit" ]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
- ./backend:/app
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
# - OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_API_KEY=${OPENAI_API_KEY}
depends_on:
db:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
volumes:
- ./frontend:/app
- /app/node_modules
ports:
- "5173:5173"
environment:
- VITE_API_URL=${VITE_API_URL}
depends_on:
- backend
volumes:
postgres_data:

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
cache=./.npm-cache

10
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev", "--", "--host"]

16
frontend/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4658
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
"recharts": "^3.6.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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;

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

7
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})