mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 12:28:46 +01:00
Add supplements, kettlebell, calendar, push notifications, and PWA support
- Supplement tracking: CRUD endpoints, /today, /logs, Supplements page - Kettlebell workouts: session tracking, analytics endpoint, ActiveSession page - Calendar module: events CRUD, calendar components - Push notifications: VAPID keys, PushSubscription model, APScheduler reminders, service worker with push/notificationclick handlers, Profile notifications UI - PWA: vite-plugin-pwa, manifest, icons, service worker generation - Frontend: TypeScript types, API modules, ConfirmModal, toast notifications - Auth fixes: password hashing, nutrition endpoint auth - CLAUDE.md: project documentation and development guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
238
backend/app/api/v1/endpoints/supplements.py
Normal file
238
backend/app/api/v1/endpoints/supplements.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from datetime import date, datetime
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api import deps
|
||||
from app.models.supplement import Supplement, SupplementLog
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SupplementCreate(BaseModel):
|
||||
name: str
|
||||
dosage: float
|
||||
unit: str
|
||||
frequency: str = "daily"
|
||||
scheduled_times: List[str] = []
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SupplementUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
dosage: Optional[float] = None
|
||||
unit: Optional[str] = None
|
||||
frequency: Optional[str] = None
|
||||
scheduled_times: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SupplementLogCreate(BaseModel):
|
||||
dose_taken: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
taken_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class SupplementWithStatus(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
dosage: float
|
||||
unit: str
|
||||
frequency: str
|
||||
scheduled_times: List[str]
|
||||
notes: Optional[str]
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
taken_today: bool
|
||||
streak: int
|
||||
|
||||
|
||||
@router.get("/", response_model=List[Supplement])
|
||||
def list_supplements(
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> Any:
|
||||
statement = (
|
||||
select(Supplement)
|
||||
.where(Supplement.user_id == current_user.id)
|
||||
.where(Supplement.is_active)
|
||||
.order_by(Supplement.name)
|
||||
)
|
||||
return session.exec(statement).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=Supplement)
|
||||
def create_supplement(
|
||||
*,
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
data: SupplementCreate,
|
||||
) -> Any:
|
||||
supplement = Supplement(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
dosage=data.dosage,
|
||||
unit=data.unit,
|
||||
frequency=data.frequency,
|
||||
scheduled_times=data.scheduled_times,
|
||||
notes=data.notes,
|
||||
)
|
||||
session.add(supplement)
|
||||
session.commit()
|
||||
session.refresh(supplement)
|
||||
return supplement
|
||||
|
||||
|
||||
@router.put("/{supplement_id}", response_model=Supplement)
|
||||
def update_supplement(
|
||||
supplement_id: int,
|
||||
data: SupplementUpdate,
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> Any:
|
||||
supplement = session.get(Supplement, supplement_id)
|
||||
if not supplement:
|
||||
raise HTTPException(status_code=404, detail="Supplement not found")
|
||||
if supplement.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(supplement, key, value)
|
||||
session.add(supplement)
|
||||
session.commit()
|
||||
session.refresh(supplement)
|
||||
return supplement
|
||||
|
||||
|
||||
@router.delete("/{supplement_id}", status_code=204)
|
||||
def delete_supplement(
|
||||
supplement_id: int,
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> None:
|
||||
supplement = session.get(Supplement, supplement_id)
|
||||
if not supplement:
|
||||
raise HTTPException(status_code=404, detail="Supplement not found")
|
||||
if supplement.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
# Soft delete
|
||||
supplement.is_active = False
|
||||
session.add(supplement)
|
||||
session.commit()
|
||||
|
||||
|
||||
@router.post("/{supplement_id}/log", response_model=SupplementLog)
|
||||
def log_supplement(
|
||||
supplement_id: int,
|
||||
data: SupplementLogCreate,
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> Any:
|
||||
supplement = session.get(Supplement, supplement_id)
|
||||
if not supplement:
|
||||
raise HTTPException(status_code=404, detail="Supplement not found")
|
||||
if supplement.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized")
|
||||
log = SupplementLog(
|
||||
user_id=current_user.id,
|
||||
supplement_id=supplement_id,
|
||||
taken_at=data.taken_at or datetime.utcnow(),
|
||||
dose_taken=data.dose_taken,
|
||||
notes=data.notes,
|
||||
)
|
||||
session.add(log)
|
||||
session.commit()
|
||||
session.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
@router.get("/logs", response_model=List[SupplementLog])
|
||||
def get_supplement_logs(
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
supplement_id: Optional[int] = Query(default=None),
|
||||
start_date: Optional[str] = Query(default=None, description="YYYY-MM-DD"),
|
||||
end_date: Optional[str] = Query(default=None, description="YYYY-MM-DD"),
|
||||
) -> Any:
|
||||
statement = select(SupplementLog).where(SupplementLog.user_id == current_user.id)
|
||||
if supplement_id:
|
||||
statement = statement.where(SupplementLog.supplement_id == supplement_id)
|
||||
if start_date:
|
||||
dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
statement = statement.where(SupplementLog.taken_at >= dt)
|
||||
if end_date:
|
||||
dt = datetime.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
statement = statement.where(SupplementLog.taken_at <= dt)
|
||||
statement = statement.order_by(SupplementLog.taken_at.desc())
|
||||
return session.exec(statement).all()
|
||||
|
||||
|
||||
@router.get("/today", response_model=List[SupplementWithStatus])
|
||||
def get_today_supplements(
|
||||
current_user: deps.CurrentUser,
|
||||
session: Session = Depends(deps.get_session),
|
||||
) -> Any:
|
||||
today = date.today()
|
||||
start = datetime(today.year, today.month, today.day, 0, 0, 0)
|
||||
end = datetime(today.year, today.month, today.day, 23, 59, 59)
|
||||
|
||||
supplements = session.exec(
|
||||
select(Supplement)
|
||||
.where(Supplement.user_id == current_user.id)
|
||||
.where(Supplement.is_active)
|
||||
.order_by(Supplement.name)
|
||||
).all()
|
||||
|
||||
today_logs = session.exec(
|
||||
select(SupplementLog)
|
||||
.where(SupplementLog.user_id == current_user.id)
|
||||
.where(SupplementLog.taken_at >= start)
|
||||
.where(SupplementLog.taken_at <= end)
|
||||
).all()
|
||||
|
||||
taken_ids = {log.supplement_id for log in today_logs}
|
||||
|
||||
result = []
|
||||
for s in supplements:
|
||||
# Calculate streak: consecutive days taken
|
||||
streak = 0
|
||||
check_date = today
|
||||
while True:
|
||||
d_start = datetime(check_date.year, check_date.month, check_date.day, 0, 0, 0)
|
||||
d_end = datetime(check_date.year, check_date.month, check_date.day, 23, 59, 59)
|
||||
taken = session.exec(
|
||||
select(SupplementLog)
|
||||
.where(SupplementLog.supplement_id == s.id)
|
||||
.where(SupplementLog.taken_at >= d_start)
|
||||
.where(SupplementLog.taken_at <= d_end)
|
||||
).first()
|
||||
if taken:
|
||||
streak += 1
|
||||
from datetime import timedelta
|
||||
|
||||
check_date = check_date - timedelta(days=1)
|
||||
else:
|
||||
break
|
||||
if streak > 365:
|
||||
break
|
||||
|
||||
result.append(
|
||||
SupplementWithStatus(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
dosage=s.dosage,
|
||||
unit=s.unit,
|
||||
frequency=s.frequency,
|
||||
scheduled_times=s.scheduled_times or [],
|
||||
notes=s.notes,
|
||||
is_active=s.is_active,
|
||||
created_at=s.created_at,
|
||||
taken_today=s.id in taken_ids,
|
||||
streak=streak,
|
||||
)
|
||||
)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user