Feature: nutrition module (keto macros + intermittent fasting + electrolytes)
Backend:
- models/nutrition.py: NutritionGoal, Fast, ElectrolyteLog tables
- schemas/nutrition.py: Pydantic schemas for new endpoints
- routers/nutrition.py: /api/nutrition/{today,goals,fasts,electrolytes}
- main.py: register router + migration for new tables + seed keto point rules
Frontend:
- pages/Nutrition.jsx: macro bars, fast timer, eating window, electrolyte quick-add
- App.jsx: /nutrition route + Keto nav tab
- api.js: nutrition endpoints
Points schema for fasts (tier-based partial credit):
16:8=25, 18:6=40, 20:4=60, 24h=200, 36h=350, 48h=500, 72h=1000
Defaults tuned for Scot: 135kg M 44yo strict keto, 12:00-20:00 window,
Asia/Bangkok timezone, 2400/2600 cal rest/training, 20g net carb cap.
This commit is contained in:
parent
9a306bc250
commit
5dd5d39ca6
8 changed files with 818 additions and 6 deletions
|
|
@ -63,7 +63,7 @@ async def lifespan(app: FastAPI):
|
|||
logger.info("Fitness Rewards backend shutting down")
|
||||
|
||||
|
||||
app = FastAPI(title="Fitness Rewards", version="0.2.0", lifespan=lifespan)
|
||||
app = FastAPI(title="Fitness Rewards", version="0.3.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
|
|
@ -83,6 +83,7 @@ from app.routers.integrations import router as integrations_router
|
|||
from app.routers.programs import router as programs_router
|
||||
from app.routers.catalog import router as catalog_router
|
||||
from app.routers.support import router as support_router
|
||||
from app.routers.nutrition import router as nutrition_router
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(onboarding_router)
|
||||
|
|
@ -94,11 +95,12 @@ app.include_router(integrations_router)
|
|||
app.include_router(catalog_router)
|
||||
app.include_router(support_router)
|
||||
app.include_router(programs_router)
|
||||
app.include_router(nutrition_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "0.2.0"}
|
||||
return {"status": "ok", "version": "0.3.0"}
|
||||
|
||||
|
||||
|
||||
|
|
@ -134,6 +136,27 @@ async def run_migrations():
|
|||
END $$;
|
||||
"""))
|
||||
|
||||
# v3: Seed keto point rules (fast_completed, keto_day, meal_logged)
|
||||
await conn.execute(text("""
|
||||
DO $$ BEGIN
|
||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||
SELECT gen_random_uuid(), u.id, 'fast_completed', 200, 'per_event', TRUE
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM point_rules pr
|
||||
WHERE pr.user_id = u.id AND pr.category = 'fast_completed'
|
||||
);
|
||||
INSERT INTO point_rules (id, user_id, category, points, unit, is_active)
|
||||
SELECT gen_random_uuid(), u.id, 'keto_compliant_day', 100, 'per_event', TRUE
|
||||
FROM users u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM point_rules pr
|
||||
WHERE pr.user_id = u.id AND pr.category = 'keto_compliant_day'
|
||||
);
|
||||
EXCEPTION WHEN undefined_table THEN NULL;
|
||||
END $$;
|
||||
"""))
|
||||
|
||||
|
||||
async def seed_resources():
|
||||
"""Seed the resource library with curated fitness programs."""
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from app.models.oauth import OAuthToken
|
|||
from app.models.resource import Resource
|
||||
from app.models.catalog import ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
|
||||
from app.models.support import SupportThread, SupportMessage
|
||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||
|
||||
__all__ = [
|
||||
"User", "UserProfile", "Program", "ActivityLog", "FoodLog",
|
||||
|
|
@ -16,4 +17,5 @@ __all__ = [
|
|||
"OAuthToken", "Resource",
|
||||
"ProgramCatalog", "CatalogWorkout", "CrawlQueue", "WorkoutLog",
|
||||
"SupportThread", "SupportMessage",
|
||||
"NutritionGoal", "Fast", "ElectrolyteLog",
|
||||
]
|
||||
|
|
|
|||
90
backend/app/models/nutrition.py
Normal file
90
backend/app/models/nutrition.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from sqlalchemy import String, Integer, Numeric, DateTime, Boolean, Text, ForeignKey, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class NutritionGoal(Base):
|
||||
"""Per-user macro + eating-window targets. One active per user."""
|
||||
__tablename__ = "nutrition_goals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Macro targets (grams / kcal)
|
||||
calorie_target: Mapped[int] = mapped_column(Integer, default=2400)
|
||||
protein_g_target: Mapped[int] = mapped_column(Integer, default=180)
|
||||
fat_g_target: Mapped[int] = mapped_column(Integer, default=175)
|
||||
net_carbs_g_cap: Mapped[int] = mapped_column(Integer, default=20) # strict keto
|
||||
|
||||
# Training-day override (optional)
|
||||
training_calorie_target: Mapped[int | None] = mapped_column(Integer)
|
||||
training_protein_g_target: Mapped[int | None] = mapped_column(Integer)
|
||||
training_fat_g_target: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Eating window (24h local time, hours only)
|
||||
eating_window_start_hour: Mapped[int] = mapped_column(Integer, default=12) # 12:00
|
||||
eating_window_end_hour: Mapped[int] = mapped_column(Integer, default=20) # 20:00
|
||||
default_fast_hours: Mapped[int] = mapped_column(Integer, default=16) # 16:8
|
||||
|
||||
diet_style: Mapped[str] = mapped_column(String(30), default="strict_keto")
|
||||
timezone_str: Mapped[str] = mapped_column(String(50), default="Asia/Bangkok")
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Fast(Base):
|
||||
"""A single fasting bout — start/end + type + compliance flag."""
|
||||
__tablename__ = "fasts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Target length (hours) — 16, 18, 20, 24, 36, 48, 72
|
||||
target_hours: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
fast_type: Mapped[str] = mapped_column(String(20), default="daily") # daily, weekly_24, long_48, long_72, extended
|
||||
|
||||
# Completion state
|
||||
completed: Mapped[bool] = mapped_column(Boolean, default=False) # hit target
|
||||
broken_early: Mapped[bool] = mapped_column(Boolean, default=False) # ended before target
|
||||
|
||||
# Points granted (for idempotency — don't double-credit)
|
||||
points_awarded: Mapped[int] = mapped_column(Integer, default=0)
|
||||
activity_log_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("activity_log.id"))
|
||||
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_fasts_user_started", "user_id", "started_at"),
|
||||
)
|
||||
|
||||
|
||||
class ElectrolyteLog(Base):
|
||||
"""Daily electrolyte dosing checklist — lightweight tracker."""
|
||||
__tablename__ = "electrolyte_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
log_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
sodium_mg: Mapped[int] = mapped_column(Integer, default=0)
|
||||
potassium_mg: Mapped[int] = mapped_column(Integer, default=0)
|
||||
magnesium_mg: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_electrolyte_user_date", "user_id", "log_date"),
|
||||
)
|
||||
372
backend/app/routers/nutrition.py
Normal file
372
backend/app/routers/nutrition.py
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
from __future__ import annotations
|
||||
|
||||
"""Nutrition router — goals, fasts, electrolytes, daily compliance."""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
from datetime import datetime, date, timezone, timedelta
|
||||
from decimal import Decimal
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.models.nutrition import NutritionGoal, Fast, ElectrolyteLog
|
||||
from app.models.food import FoodLog
|
||||
from app.schemas.nutrition import (
|
||||
NutritionGoalIn, NutritionGoalOut, FastStart, FastUpdate, FastOut, ElectrolyteIn,
|
||||
)
|
||||
from app.utils.auth import get_current_user
|
||||
from app.services.points_engine import log_activity_with_points
|
||||
|
||||
router = APIRouter(prefix="/api/nutrition", tags=["nutrition"])
|
||||
|
||||
|
||||
# ---------- Goals ----------
|
||||
|
||||
async def _get_or_create_goal(db: AsyncSession, user_id: uuid.UUID) -> NutritionGoal:
|
||||
result = await db.execute(select(NutritionGoal).where(NutritionGoal.user_id == user_id))
|
||||
goal = result.scalar_one_or_none()
|
||||
if goal:
|
||||
return goal
|
||||
goal = NutritionGoal(user_id=user_id)
|
||||
db.add(goal)
|
||||
await db.flush()
|
||||
return goal
|
||||
|
||||
|
||||
def _goal_to_out(g: NutritionGoal) -> dict:
|
||||
return {
|
||||
"id": str(g.id),
|
||||
"calorie_target": g.calorie_target,
|
||||
"protein_g_target": g.protein_g_target,
|
||||
"fat_g_target": g.fat_g_target,
|
||||
"net_carbs_g_cap": g.net_carbs_g_cap,
|
||||
"training_calorie_target": g.training_calorie_target,
|
||||
"training_protein_g_target": g.training_protein_g_target,
|
||||
"training_fat_g_target": g.training_fat_g_target,
|
||||
"eating_window_start_hour": g.eating_window_start_hour,
|
||||
"eating_window_end_hour": g.eating_window_end_hour,
|
||||
"default_fast_hours": g.default_fast_hours,
|
||||
"diet_style": g.diet_style,
|
||||
"timezone_str": g.timezone_str,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/goals")
|
||||
async def get_goals(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
goal = await _get_or_create_goal(db, user.id)
|
||||
return _goal_to_out(goal)
|
||||
|
||||
|
||||
@router.patch("/goals")
|
||||
async def update_goals(
|
||||
req: NutritionGoalIn,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
goal = await _get_or_create_goal(db, user.id)
|
||||
for k, v in req.model_dump(exclude_unset=True).items():
|
||||
if v is not None:
|
||||
setattr(goal, k, v)
|
||||
goal.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
return _goal_to_out(goal)
|
||||
|
||||
|
||||
# ---------- Today's macro status ----------
|
||||
|
||||
@router.get("/today")
|
||||
async def get_today(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Today's macros vs target, eating-window state, active fast, compliance."""
|
||||
goal = await _get_or_create_goal(db, user.id)
|
||||
today = date.today()
|
||||
|
||||
# Sum today's food
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(FoodLog.calories * FoodLog.servings), 0),
|
||||
func.coalesce(func.sum(FoodLog.protein_g * FoodLog.servings), 0),
|
||||
func.coalesce(func.sum(FoodLog.carbs_g * FoodLog.servings), 0),
|
||||
func.coalesce(func.sum(FoodLog.fat_g * FoodLog.servings), 0),
|
||||
func.coalesce(func.sum(FoodLog.fiber_g * FoodLog.servings), 0),
|
||||
).where(FoodLog.user_id == user.id, FoodLog.food_date == today)
|
||||
)
|
||||
cals, prot, carbs, fat, fiber = result.one()
|
||||
cals, prot, carbs, fat, fiber = float(cals), float(prot), float(carbs), float(fat), float(fiber)
|
||||
net_carbs = max(0.0, carbs - fiber)
|
||||
|
||||
# Eating window (local time, using goal timezone_str — simplified: assume server in UTC, user sends local)
|
||||
now_local = datetime.now(timezone.utc) + timedelta(hours=7) # Asia/Bangkok offset hack
|
||||
hour = now_local.hour
|
||||
in_window = goal.eating_window_start_hour <= hour < goal.eating_window_end_hour
|
||||
window_str = f"{goal.eating_window_start_hour:02d}:00–{goal.eating_window_end_hour:02d}:00"
|
||||
|
||||
# Active fast
|
||||
result = await db.execute(
|
||||
select(Fast).where(Fast.user_id == user.id, Fast.ended_at.is_(None))
|
||||
.order_by(Fast.started_at.desc()).limit(1)
|
||||
)
|
||||
active_fast = result.scalar_one_or_none()
|
||||
active_fast_data = None
|
||||
if active_fast:
|
||||
elapsed = (datetime.now(timezone.utc) - active_fast.started_at).total_seconds() / 3600
|
||||
active_fast_data = {
|
||||
"id": str(active_fast.id),
|
||||
"started_at": active_fast.started_at.isoformat(),
|
||||
"target_hours": active_fast.target_hours,
|
||||
"fast_type": active_fast.fast_type,
|
||||
"elapsed_hours": round(elapsed, 2),
|
||||
"target_reached": elapsed >= active_fast.target_hours,
|
||||
}
|
||||
|
||||
# Compliance for today
|
||||
carb_ok = net_carbs <= goal.net_carbs_g_cap
|
||||
protein_ok = prot >= goal.protein_g_target * 0.85 # 85% threshold gives some slack
|
||||
compliant_day = carb_ok and protein_ok
|
||||
|
||||
return {
|
||||
"date": today.isoformat(),
|
||||
"macros": {
|
||||
"calories": round(cals, 0),
|
||||
"protein_g": round(prot, 1),
|
||||
"carbs_g": round(carbs, 1),
|
||||
"net_carbs_g": round(net_carbs, 1),
|
||||
"fat_g": round(fat, 1),
|
||||
"fiber_g": round(fiber, 1),
|
||||
},
|
||||
"targets": {
|
||||
"calories": goal.calorie_target,
|
||||
"protein_g": goal.protein_g_target,
|
||||
"fat_g": goal.fat_g_target,
|
||||
"net_carbs_cap": goal.net_carbs_g_cap,
|
||||
},
|
||||
"compliance": {
|
||||
"carb_ok": carb_ok,
|
||||
"protein_ok": protein_ok,
|
||||
"compliant_day": compliant_day,
|
||||
},
|
||||
"eating_window": {
|
||||
"start": goal.eating_window_start_hour,
|
||||
"end": goal.eating_window_end_hour,
|
||||
"display": window_str,
|
||||
"in_window_now": in_window,
|
||||
},
|
||||
"active_fast": active_fast_data,
|
||||
}
|
||||
|
||||
|
||||
# ---------- Fasts ----------
|
||||
|
||||
def _fast_to_out(f: Fast) -> dict:
|
||||
if f.ended_at:
|
||||
elapsed = (f.ended_at - f.started_at).total_seconds() / 3600
|
||||
else:
|
||||
elapsed = (datetime.now(timezone.utc) - f.started_at).total_seconds() / 3600
|
||||
return {
|
||||
"id": str(f.id),
|
||||
"started_at": f.started_at.isoformat(),
|
||||
"ended_at": f.ended_at.isoformat() if f.ended_at else None,
|
||||
"target_hours": f.target_hours,
|
||||
"fast_type": f.fast_type,
|
||||
"completed": f.completed,
|
||||
"broken_early": f.broken_early,
|
||||
"points_awarded": f.points_awarded,
|
||||
"elapsed_hours": round(elapsed, 2),
|
||||
"notes": f.notes,
|
||||
}
|
||||
|
||||
|
||||
# Points schedule per design doc
|
||||
FAST_POINTS = {
|
||||
16: 25, # 16:8 daily
|
||||
18: 40, # 18:6
|
||||
20: 60, # 20:4
|
||||
24: 200, # 24h weekly
|
||||
36: 350,
|
||||
48: 500, # 48h monthly
|
||||
72: 1000, # 72h kickoff
|
||||
}
|
||||
|
||||
|
||||
def _points_for_fast(target_hours: int, elapsed_hours: float) -> int:
|
||||
"""Award the highest tier the user actually reached, capped at target."""
|
||||
# Find the highest tier <= min(target, elapsed)
|
||||
achieved = min(target_hours, int(elapsed_hours))
|
||||
best = 0
|
||||
for tier, pts in FAST_POINTS.items():
|
||||
if tier <= achieved and pts > best:
|
||||
best = pts
|
||||
return best
|
||||
|
||||
|
||||
@router.post("/fasts")
|
||||
async def start_fast(
|
||||
req: FastStart,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
# Reject if there's already an active fast
|
||||
result = await db.execute(
|
||||
select(Fast).where(Fast.user_id == user.id, Fast.ended_at.is_(None))
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="An active fast already exists — end it first")
|
||||
|
||||
f = Fast(
|
||||
user_id=user.id,
|
||||
started_at=req.started_at or datetime.now(timezone.utc),
|
||||
target_hours=req.target_hours,
|
||||
fast_type=req.fast_type,
|
||||
notes=req.notes,
|
||||
)
|
||||
db.add(f)
|
||||
await db.flush()
|
||||
return _fast_to_out(f)
|
||||
|
||||
|
||||
@router.get("/fasts")
|
||||
async def list_fasts(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
limit: int = Query(20, le=100),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Fast).where(Fast.user_id == user.id)
|
||||
.order_by(Fast.started_at.desc()).limit(limit)
|
||||
)
|
||||
return [_fast_to_out(f) for f in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/fasts/active")
|
||||
async def get_active_fast(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Fast).where(Fast.user_id == user.id, Fast.ended_at.is_(None))
|
||||
.order_by(Fast.started_at.desc()).limit(1)
|
||||
)
|
||||
f = result.scalar_one_or_none()
|
||||
if not f:
|
||||
return {"active": False}
|
||||
return {"active": True, **_fast_to_out(f)}
|
||||
|
||||
|
||||
@router.patch("/fasts/{fast_id}")
|
||||
async def end_fast(
|
||||
fast_id: str,
|
||||
req: FastUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Fast).where(Fast.id == uuid.UUID(fast_id), Fast.user_id == user.id)
|
||||
)
|
||||
f = result.scalar_one_or_none()
|
||||
if not f:
|
||||
raise HTTPException(status_code=404, detail="Fast not found")
|
||||
if f.ended_at:
|
||||
raise HTTPException(status_code=400, detail="Fast already ended")
|
||||
|
||||
ended = req.ended_at or datetime.now(timezone.utc)
|
||||
if ended < f.started_at:
|
||||
raise HTTPException(status_code=400, detail="End time cannot be before start time")
|
||||
|
||||
f.ended_at = ended
|
||||
if req.notes is not None:
|
||||
f.notes = req.notes
|
||||
|
||||
elapsed_hours = (ended - f.started_at).total_seconds() / 3600
|
||||
f.completed = elapsed_hours >= f.target_hours
|
||||
f.broken_early = not f.completed
|
||||
|
||||
# Award points for whatever tier was achieved (partial credit)
|
||||
pts = _points_for_fast(f.target_hours, elapsed_hours)
|
||||
if pts > 0 and f.points_awarded == 0:
|
||||
activity = await log_activity_with_points(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
category="fast_completed",
|
||||
activity_date=ended.date(),
|
||||
title=f"{f.fast_type} fast — {int(elapsed_hours)}h",
|
||||
duration_minutes=int(elapsed_hours * 60),
|
||||
source="nutrition",
|
||||
metadata={"fast_id": str(f.id), "target_hours": f.target_hours, "elapsed_hours": elapsed_hours},
|
||||
)
|
||||
# Override points with our tier-based calc (log_activity used default rule)
|
||||
activity.points_earned = pts
|
||||
f.activity_log_id = activity.id
|
||||
f.points_awarded = pts
|
||||
|
||||
await db.flush()
|
||||
return _fast_to_out(f)
|
||||
|
||||
|
||||
@router.delete("/fasts/{fast_id}")
|
||||
async def delete_fast(
|
||||
fast_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Fast).where(Fast.id == uuid.UUID(fast_id), Fast.user_id == user.id)
|
||||
)
|
||||
f = result.scalar_one_or_none()
|
||||
if not f:
|
||||
raise HTTPException(status_code=404, detail="Fast not found")
|
||||
await db.delete(f)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
# ---------- Electrolytes ----------
|
||||
|
||||
@router.post("/electrolytes")
|
||||
async def log_electrolytes(
|
||||
req: ElectrolyteIn,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
entry = ElectrolyteLog(
|
||||
user_id=user.id,
|
||||
log_date=datetime.now(timezone.utc),
|
||||
sodium_mg=req.sodium_mg,
|
||||
potassium_mg=req.potassium_mg,
|
||||
magnesium_mg=req.magnesium_mg,
|
||||
notes=req.notes,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return {"id": str(entry.id), "status": "logged"}
|
||||
|
||||
|
||||
@router.get("/electrolytes/today")
|
||||
async def get_electrolytes_today(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
today_start = datetime.combine(date.today(), datetime.min.time()).replace(tzinfo=timezone.utc)
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(ElectrolyteLog.sodium_mg), 0),
|
||||
func.coalesce(func.sum(ElectrolyteLog.potassium_mg), 0),
|
||||
func.coalesce(func.sum(ElectrolyteLog.magnesium_mg), 0),
|
||||
).where(
|
||||
ElectrolyteLog.user_id == user.id,
|
||||
ElectrolyteLog.log_date >= today_start,
|
||||
)
|
||||
)
|
||||
sodium, potassium, magnesium = result.one()
|
||||
return {
|
||||
"sodium_mg": int(sodium),
|
||||
"potassium_mg": int(potassium),
|
||||
"magnesium_mg": int(magnesium),
|
||||
"targets": {"sodium_mg": 5000, "potassium_mg": 3500, "magnesium_mg": 400},
|
||||
}
|
||||
67
backend/app/schemas/nutrition.py
Normal file
67
backend/app/schemas/nutrition.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class NutritionGoalIn(BaseModel):
|
||||
calorie_target: int | None = None
|
||||
protein_g_target: int | None = None
|
||||
fat_g_target: int | None = None
|
||||
net_carbs_g_cap: int | None = None
|
||||
training_calorie_target: int | None = None
|
||||
training_protein_g_target: int | None = None
|
||||
training_fat_g_target: int | None = None
|
||||
eating_window_start_hour: int | None = None
|
||||
eating_window_end_hour: int | None = None
|
||||
default_fast_hours: int | None = None
|
||||
diet_style: str | None = None
|
||||
timezone_str: str | None = None
|
||||
|
||||
|
||||
class NutritionGoalOut(BaseModel):
|
||||
id: str
|
||||
calorie_target: int
|
||||
protein_g_target: int
|
||||
fat_g_target: int
|
||||
net_carbs_g_cap: int
|
||||
training_calorie_target: int | None
|
||||
training_protein_g_target: int | None
|
||||
training_fat_g_target: int | None
|
||||
eating_window_start_hour: int
|
||||
eating_window_end_hour: int
|
||||
default_fast_hours: int
|
||||
diet_style: str
|
||||
timezone_str: str
|
||||
|
||||
|
||||
class FastStart(BaseModel):
|
||||
target_hours: int
|
||||
fast_type: str = "daily" # daily, weekly_24, long_48, long_72, extended
|
||||
started_at: datetime | None = None # defaults to now if omitted
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class FastUpdate(BaseModel):
|
||||
ended_at: datetime | None = None # defaults to now if omitted, terminates the fast
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class FastOut(BaseModel):
|
||||
id: str
|
||||
started_at: datetime
|
||||
ended_at: datetime | None
|
||||
target_hours: int
|
||||
fast_type: str
|
||||
completed: bool
|
||||
broken_early: bool
|
||||
points_awarded: int
|
||||
elapsed_hours: float
|
||||
notes: str | None
|
||||
|
||||
|
||||
class ElectrolyteIn(BaseModel):
|
||||
sodium_mg: int = 0
|
||||
potassium_mg: int = 0
|
||||
magnesium_mg: int = 0
|
||||
notes: str | None = None
|
||||
Loading…
Add table
Add a link
Reference in a new issue