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:
Carlos Escalante
2026-03-20 18:57:03 -06:00
parent bd91eb4171
commit f279907ae3
61 changed files with 9256 additions and 85 deletions

View File

@@ -0,0 +1,462 @@
from calendar import monthrange
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.calendar import CalendarEvent, DailyNote
from app.models.kettlebell import KettlebellSession
from app.models.supplement import Supplement, SupplementLog
router = APIRouter()
# ── Pydantic schemas ─────────────────────────────────────────────────────────
class DayMeta(BaseModel):
date: str
has_note: bool
event_count: int
supplement_compliance: Optional[float]
has_workout: bool
calorie_total: Optional[float]
class DailyNoteUpsert(BaseModel):
content: str = ""
mood: Optional[str] = None
energy_level: Optional[int] = None
class DailyNoteOut(BaseModel):
id: Optional[int]
date: str
content: str
mood: Optional[str]
energy_level: Optional[int]
updated_at: Optional[str]
class CalendarEventCreate(BaseModel):
date: str # YYYY-MM-DD
title: str
description: Optional[str] = None
event_type: str = "general"
color: Optional[str] = None
start_time: Optional[str] = None
is_completed: bool = False
class CalendarEventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
event_type: Optional[str] = None
color: Optional[str] = None
start_time: Optional[str] = None
is_completed: Optional[bool] = None
class CalendarEventOut(BaseModel):
id: int
date: str
title: str
description: Optional[str]
event_type: str
color: Optional[str]
start_time: Optional[str]
is_completed: bool
created_at: Optional[str]
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: str
taken_today: bool
streak: int
class KettlebellSessionSummary(BaseModel):
id: int
title: str
focus: str
total_duration_min: int
difficulty: str
status: str
completed_at: Optional[str]
class DayDetail(BaseModel):
date: str
note: Optional[DailyNoteOut]
events: List[CalendarEventOut]
supplements: List[SupplementWithStatus]
kettlebell_sessions: List[KettlebellSessionSummary]
# ── Helpers ───────────────────────────────────────────────────────────────────
def _day_window(d: date):
"""Return (start_dt, end_dt) for a full day."""
start = datetime(d.year, d.month, d.day, 0, 0, 0)
end = datetime(d.year, d.month, d.day, 23, 59, 59)
return start, end
def _note_out(note: DailyNote) -> DailyNoteOut:
return DailyNoteOut(
id=note.id,
date=note.date.isoformat(),
content=note.content,
mood=note.mood,
energy_level=note.energy_level,
updated_at=note.updated_at.isoformat() if note.updated_at else None,
)
def _event_out(ev: CalendarEvent) -> CalendarEventOut:
return CalendarEventOut(
id=ev.id,
date=ev.date.isoformat(),
title=ev.title,
description=ev.description,
event_type=ev.event_type,
color=ev.color,
start_time=ev.start_time,
is_completed=ev.is_completed,
created_at=ev.created_at.isoformat() if ev.created_at else None,
)
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/month", response_model=List[DayMeta])
def get_month_summary(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
year: int = Query(...),
month: int = Query(...),
) -> Any:
_, last_day = monthrange(year, month)
month_start = date(year, month, 1)
month_end = date(year, month, last_day)
month_start_dt = datetime(year, month, 1, 0, 0, 0)
month_end_dt = datetime(year, month, last_day, 23, 59, 59)
uid = current_user.id
# DailyNotes
notes = session.exec(
select(DailyNote)
.where(DailyNote.user_id == uid)
.where(DailyNote.date >= month_start)
.where(DailyNote.date <= month_end)
).all()
notes_by_date = {n.date: n for n in notes}
# CalendarEvents
events = session.exec(
select(CalendarEvent)
.where(CalendarEvent.user_id == uid)
.where(CalendarEvent.date >= month_start)
.where(CalendarEvent.date <= month_end)
).all()
event_counts: dict[date, int] = {}
for ev in events:
event_counts[ev.date] = event_counts.get(ev.date, 0) + 1
# SupplementLogs
active_supp_count = session.exec(
select(Supplement).where(Supplement.user_id == uid).where(Supplement.is_active)
).all()
total_supplements = len(active_supp_count)
supp_logs = session.exec(
select(SupplementLog)
.where(SupplementLog.user_id == uid)
.where(SupplementLog.taken_at >= month_start_dt)
.where(SupplementLog.taken_at <= month_end_dt)
).all()
supp_by_date: dict[date, set] = {}
for log in supp_logs:
d = log.taken_at.date()
supp_by_date.setdefault(d, set()).add(log.supplement_id)
# KettlebellSessions
kb_sessions = session.exec(
select(KettlebellSession)
.where(KettlebellSession.user_id == uid)
.where(KettlebellSession.status == "completed")
.where(KettlebellSession.completed_at >= month_start_dt)
.where(KettlebellSession.completed_at <= month_end_dt)
).all()
workout_dates: set[date] = set()
for kb in kb_sessions:
if kb.completed_at:
workout_dates.add(kb.completed_at.date())
# FoodLogs — import lazily to avoid circular imports
from app.models.food import FoodLog # noqa: PLC0415
food_logs = session.exec(
select(FoodLog)
.where(FoodLog.user_id == uid)
.where(FoodLog.timestamp >= month_start_dt)
.where(FoodLog.timestamp <= month_end_dt)
).all()
calories_by_date: dict[date, float] = {}
for fl in food_logs:
d = fl.timestamp.date()
calories_by_date[d] = calories_by_date.get(d, 0.0) + (fl.calories or 0.0)
result = []
for day_num in range(1, last_day + 1):
d = date(year, month, day_num)
taken = len(supp_by_date.get(d, set()))
compliance = (taken / total_supplements) if total_supplements > 0 else None
result.append(
DayMeta(
date=d.isoformat(),
has_note=bool(notes_by_date.get(d) and notes_by_date[d].content),
event_count=event_counts.get(d, 0),
supplement_compliance=compliance,
has_workout=d in workout_dates,
calorie_total=calories_by_date.get(d) or None,
)
)
return result
@router.get("/day", response_model=DayDetail)
def get_day_detail(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
date_str: str = Query(..., alias="date"),
) -> Any:
try:
d = date.fromisoformat(date_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
uid = current_user.id
start, end = _day_window(d)
note = session.exec(select(DailyNote).where(DailyNote.user_id == uid).where(DailyNote.date == d)).first()
events = session.exec(
select(CalendarEvent)
.where(CalendarEvent.user_id == uid)
.where(CalendarEvent.date == d)
.order_by(CalendarEvent.start_time)
).all()
# Supplements with taken status for this date
active_supps = session.exec(
select(Supplement).where(Supplement.user_id == uid).where(Supplement.is_active).order_by(Supplement.name)
).all()
day_logs = session.exec(
select(SupplementLog)
.where(SupplementLog.user_id == uid)
.where(SupplementLog.taken_at >= start)
.where(SupplementLog.taken_at <= end)
).all()
taken_ids = {log.supplement_id for log in day_logs}
supplements = [
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.isoformat(),
taken_today=s.id in taken_ids,
streak=0,
)
for s in active_supps
]
kb_sessions = session.exec(
select(KettlebellSession)
.where(KettlebellSession.user_id == uid)
.where(KettlebellSession.created_at >= start)
.where(KettlebellSession.created_at <= end)
).all()
kb_out = [
KettlebellSessionSummary(
id=kb.id,
title=kb.title,
focus=kb.focus,
total_duration_min=kb.total_duration_min,
difficulty=kb.difficulty,
status=kb.status,
completed_at=kb.completed_at.isoformat() if kb.completed_at else None,
)
for kb in kb_sessions
]
return DayDetail(
date=d.isoformat(),
note=_note_out(note) if note else None,
events=[_event_out(ev) for ev in events],
supplements=supplements,
kettlebell_sessions=kb_out,
)
@router.get("/notes/{date_str}", response_model=DailyNoteOut)
def get_note(
date_str: str,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
try:
d = date.fromisoformat(date_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format.")
note = session.exec(
select(DailyNote).where(DailyNote.user_id == current_user.id).where(DailyNote.date == d)
).first()
if not note:
raise HTTPException(status_code=404, detail="Note not found")
return _note_out(note)
@router.put("/notes/{date_str}", response_model=DailyNoteOut)
def upsert_note(
date_str: str,
data: DailyNoteUpsert,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
try:
d = date.fromisoformat(date_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format.")
note = session.exec(
select(DailyNote).where(DailyNote.user_id == current_user.id).where(DailyNote.date == d)
).first()
if note:
note.content = data.content
if data.mood is not None:
note.mood = data.mood
if data.energy_level is not None:
note.energy_level = data.energy_level
note.updated_at = datetime.utcnow()
else:
note = DailyNote(
user_id=current_user.id,
date=d,
content=data.content,
mood=data.mood,
energy_level=data.energy_level,
updated_at=datetime.utcnow(),
)
session.add(note)
session.commit()
session.refresh(note)
return _note_out(note)
@router.get("/events", response_model=List[CalendarEventOut])
def list_events(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
start: str = Query(...),
end: str = Query(...),
) -> Any:
try:
start_date = date.fromisoformat(start)
end_date = date.fromisoformat(end)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format.")
events = session.exec(
select(CalendarEvent)
.where(CalendarEvent.user_id == current_user.id)
.where(CalendarEvent.date >= start_date)
.where(CalendarEvent.date <= end_date)
.order_by(CalendarEvent.date, CalendarEvent.start_time)
).all()
return [_event_out(ev) for ev in events]
@router.post("/events", response_model=CalendarEventOut, status_code=201)
def create_event(
data: CalendarEventCreate,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
try:
d = date.fromisoformat(data.date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format.")
ev = CalendarEvent(
user_id=current_user.id,
date=d,
title=data.title,
description=data.description,
event_type=data.event_type,
color=data.color,
start_time=data.start_time,
is_completed=data.is_completed,
)
session.add(ev)
session.commit()
session.refresh(ev)
return _event_out(ev)
@router.put("/events/{event_id}", response_model=CalendarEventOut)
def update_event(
event_id: int,
data: CalendarEventUpdate,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
ev = session.get(CalendarEvent, event_id)
if not ev:
raise HTTPException(status_code=404, detail="Event not found")
if ev.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(ev, key, value)
session.add(ev)
session.commit()
session.refresh(ev)
return _event_out(ev)
@router.delete("/events/{event_id}", status_code=204)
def delete_event(
event_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> None:
ev = session.get(CalendarEvent, event_id)
if not ev:
raise HTTPException(status_code=404, detail="Event not found")
if ev.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
session.delete(ev)
session.commit()

View File

@@ -0,0 +1,404 @@
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, select
from app.ai.kettlebell import kettlebell_module
from app.api import deps
from app.models.kettlebell import KettlebellSession, KettlebellSetLog
router = APIRouter()
class KettlebellGenerateRequest(BaseModel):
focus: str
duration_minutes: int
available_weights: list[float]
class LogSetRequest(BaseModel):
exercise_order: int
set_number: int
actual_reps: int
actual_weight_kg: float
actual_duration_seconds: int
perceived_effort: int
class CompleteSessionRequest(BaseModel):
notes: Optional[str] = None
class ExerciseProgression(BaseModel):
date: str
max_weight: float
avg_rpe: float
total_volume: float
class PersonalRecord(BaseModel):
exercise_name: str
max_weight: float
date: str
class WeeklyVolume(BaseModel):
week: str
sessions: int
total_volume: float
class AnalyticsResponse(BaseModel):
weekly_sessions: List[WeeklyVolume]
exercise_progressions: Dict[str, List[ExerciseProgression]]
personal_records: List[PersonalRecord]
avg_rpe_trend: List[Dict]
@router.get("/analytics", response_model=AnalyticsResponse)
def get_analytics(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Get aggregated analytics across all completed sessions."""
completed_sessions = session.exec(
select(KettlebellSession)
.where(KettlebellSession.user_id == current_user.id)
.where(KettlebellSession.status == "completed")
.order_by(KettlebellSession.completed_at)
).all()
if not completed_sessions:
return AnalyticsResponse(
weekly_sessions=[],
exercise_progressions={},
personal_records=[],
avg_rpe_trend=[],
)
session_ids = [s.id for s in completed_sessions]
all_sets = session.exec(select(KettlebellSetLog).where(KettlebellSetLog.session_id.in_(session_ids))).all()
# Map session_id -> session
session_map = {s.id: s for s in completed_sessions}
# Map session_id -> exercise_order -> exercise_name
exercise_name_map: Dict[int, Dict[int, str]] = {}
for s in completed_sessions:
exercises = s.exercises.get("exercises", []) if s.exercises else []
exercise_name_map[s.id] = {ex["order"]: ex["name"] for ex in exercises if "order" in ex and "name" in ex}
# Weekly sessions + volume
weeks: Dict[str, Dict] = defaultdict(lambda: {"sessions": set(), "volume": 0.0})
for set_log in all_sets:
s = session_map.get(set_log.session_id)
if not s or not s.completed_at:
continue
week_start = s.completed_at - timedelta(days=s.completed_at.weekday())
week_key = week_start.strftime("%Y-%m-%d")
weeks[week_key]["sessions"].add(set_log.session_id)
volume = set_log.actual_reps * set_log.actual_weight_kg
weeks[week_key]["volume"] += volume
weekly_sessions = [
WeeklyVolume(week=k, sessions=len(v["sessions"]), total_volume=round(v["volume"], 1))
for k, v in sorted(weeks.items())
]
# Exercise progressions + PRs
exercise_data: Dict[str, List[Dict]] = defaultdict(list)
for set_log in all_sets:
s = session_map.get(set_log.session_id)
if not s or not s.completed_at:
continue
ex_name = exercise_name_map.get(set_log.session_id, {}).get(set_log.exercise_order)
if not ex_name:
continue
exercise_data[ex_name].append(
{
"date": s.completed_at.strftime("%Y-%m-%d"),
"weight": set_log.actual_weight_kg,
"rpe": set_log.perceived_effort,
"volume": set_log.actual_reps * set_log.actual_weight_kg,
}
)
exercise_progressions: Dict[str, List[ExerciseProgression]] = {}
personal_records: List[PersonalRecord] = []
for ex_name, sets in exercise_data.items():
by_date: Dict[str, Dict] = defaultdict(lambda: {"weights": [], "rpes": [], "volume": 0.0})
for s in sets:
by_date[s["date"]]["weights"].append(s["weight"])
by_date[s["date"]]["rpes"].append(s["rpe"])
by_date[s["date"]]["volume"] += s["volume"]
progressions = [
ExerciseProgression(
date=d,
max_weight=max(v["weights"]),
avg_rpe=round(sum(v["rpes"]) / len(v["rpes"]), 1),
total_volume=round(v["volume"], 1),
)
for d, v in sorted(by_date.items())
]
exercise_progressions[ex_name] = progressions
all_weights = [s["weight"] for s in sets]
max_w = max(all_weights)
pr_date = next(s["date"] for s in sorted(sets, key=lambda x: x["date"]) if s["weight"] == max_w)
personal_records.append(PersonalRecord(exercise_name=ex_name, max_weight=max_w, date=pr_date))
personal_records.sort(key=lambda x: x.max_weight, reverse=True)
# Avg RPE trend by session
avg_rpe_trend = []
sets_by_session: Dict[int, List] = defaultdict(list)
for set_log in all_sets:
sets_by_session[set_log.session_id].append(set_log.perceived_effort)
for s in completed_sessions:
rpes = sets_by_session.get(s.id, [])
if rpes and s.completed_at:
avg_rpe_trend.append(
{
"date": s.completed_at.strftime("%Y-%m-%d"),
"avg_rpe": round(sum(rpes) / len(rpes), 1),
"session_title": s.title,
}
)
return AnalyticsResponse(
weekly_sessions=weekly_sessions,
exercise_progressions=exercise_progressions,
personal_records=personal_records,
avg_rpe_trend=avg_rpe_trend,
)
@router.post("/generate", response_model=KettlebellSession)
def generate_session(
*,
current_user: deps.CurrentUser,
request: KettlebellGenerateRequest,
session: Session = Depends(deps.get_session),
) -> Any:
"""Generate a new kettlebell session using AI."""
try:
user_profile = (
f"Age: {current_user.age or 'unknown'}, "
f"Gender: {current_user.gender or 'unknown'}, "
f"Weight: {current_user.weight or 'unknown'} kg, "
f"Height: {current_user.height or 'unknown'} cm"
)
available_weights_kg = ", ".join(str(w) for w in sorted(request.available_weights))
generated = kettlebell_module(
user_profile=user_profile,
available_weights_kg=available_weights_kg,
focus=request.focus,
duration_minutes=request.duration_minutes,
)
s = generated.session
kb_session = KettlebellSession(
user_id=current_user.id,
title=s.title,
focus=s.focus,
exercises={"exercises": [ex.model_dump() for ex in s.exercises]},
total_duration_min=s.total_duration_min,
difficulty=s.difficulty,
notes=s.notes,
status="generated",
)
session.add(kb_session)
session.commit()
session.refresh(kb_session)
return kb_session
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", response_model=List[KettlebellSession])
def list_sessions(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Get all kettlebell sessions for the current user."""
statement = (
select(KettlebellSession)
.where(KettlebellSession.user_id == current_user.id)
.order_by(KettlebellSession.created_at.desc())
)
return session.exec(statement).all()
@router.get("/{session_id}", response_model=KettlebellSession)
def get_session(
session_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Get a single kettlebell session."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
return kb_session
@router.patch("/{session_id}/start", response_model=KettlebellSession)
def start_session(
session_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Mark a session as in progress."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
kb_session.status = "in_progress"
kb_session.started_at = datetime.utcnow()
session.add(kb_session)
session.commit()
session.refresh(kb_session)
return kb_session
@router.post("/{session_id}/sets", response_model=KettlebellSetLog)
def log_set(
session_id: int,
request: LogSetRequest,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Log a completed set."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
set_log = KettlebellSetLog(
session_id=session_id,
exercise_order=request.exercise_order,
set_number=request.set_number,
actual_reps=request.actual_reps,
actual_weight_kg=request.actual_weight_kg,
actual_duration_seconds=request.actual_duration_seconds,
perceived_effort=request.perceived_effort,
)
session.add(set_log)
session.commit()
session.refresh(set_log)
return set_log
@router.get("/{session_id}/sets", response_model=List[KettlebellSetLog])
def get_sets(
session_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Get all logged sets for a session."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
statement = select(KettlebellSetLog).where(KettlebellSetLog.session_id == session_id)
return session.exec(statement).all()
@router.post("/{session_id}/retry", response_model=KettlebellSession)
def retry_session(
session_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Clone an abandoned session as a fresh generated session."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
new_session = KettlebellSession(
user_id=current_user.id,
title=kb_session.title,
focus=kb_session.focus,
exercises=kb_session.exercises,
total_duration_min=kb_session.total_duration_min,
difficulty=kb_session.difficulty,
notes="",
status="generated",
)
session.add(new_session)
session.commit()
session.refresh(new_session)
return new_session
@router.patch("/{session_id}/abandon", response_model=KettlebellSession)
def abandon_session(
session_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Mark a session as abandoned."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
kb_session.status = "abandoned"
session.add(kb_session)
session.commit()
session.refresh(kb_session)
return kb_session
@router.delete("/{session_id}", status_code=204)
def delete_session(
session_id: int,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> None:
"""Delete a session and all its set logs."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
statement = select(KettlebellSetLog).where(KettlebellSetLog.session_id == session_id)
for log in session.exec(statement).all():
session.delete(log)
session.flush()
session.delete(kb_session)
session.commit()
@router.patch("/{session_id}/complete", response_model=KettlebellSession)
def complete_session(
session_id: int,
request: CompleteSessionRequest,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> Any:
"""Mark a session as completed."""
kb_session = session.get(KettlebellSession, session_id)
if not kb_session:
raise HTTPException(status_code=404, detail="Session not found")
if kb_session.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Not authorized")
kb_session.status = "completed"
kb_session.completed_at = datetime.utcnow()
if request.notes is not None:
kb_session.notes = request.notes
session.add(kb_session)
session.commit()
session.refresh(kb_session)
return kb_session

View File

@@ -1,9 +1,11 @@
from datetime import date as date_type
from datetime import datetime
from typing import Any
import litellm
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
from pydantic import BaseModel
from sqlmodel import Session
from sqlmodel import Session, select
from app.ai.nutrition import NutritionalInfo, analyze_nutrition_from_image, nutrition_module
from app.api import deps
@@ -16,9 +18,18 @@ class AnalyzeRequest(BaseModel):
description: str
class FoodLogCreate(BaseModel):
name: str
calories: float
protein: float
carbs: float
fats: float
@router.post("/analyze", response_model=NutritionalInfo)
def analyze_food(
request: AnalyzeRequest,
current_user: deps.CurrentUser,
) -> Any:
"""
Analyze food description and return nutritional info using DSPy.
@@ -32,6 +43,7 @@ def analyze_food(
@router.post("/analyze/image", response_model=NutritionalInfo)
async def analyze_food_image(
current_user: deps.CurrentUser,
file: UploadFile = File(...),
description: str = Form(""),
) -> Any:
@@ -51,7 +63,7 @@ async def analyze_food_image(
def log_food(
*,
session: Session = Depends(deps.get_session),
nutrition_info: NutritionalInfo,
nutrition_info: FoodLogCreate,
current_user: deps.CurrentUser,
) -> Any:
"""
@@ -81,8 +93,6 @@ def read_logs(
"""
Get food logs for current user.
"""
from sqlmodel import select
statement = (
select(FoodLog)
.where(FoodLog.user_id == current_user.id)
@@ -91,3 +101,55 @@ def read_logs(
.limit(limit)
)
return session.exec(statement).all()
class NutritionSummary(BaseModel):
date: str
total_calories: float
total_protein: float
total_carbs: float
total_fats: float
log_count: int
target_calories: float | None
target_protein: float | None
target_carbs: float | None
target_fat: float | None
@router.get("/summary", response_model=NutritionSummary)
def get_nutrition_summary(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
date: str = Query(default=None, description="Date in YYYY-MM-DD format, defaults to today"),
) -> Any:
"""
Get aggregated macro totals for a given day.
"""
if date:
target_date = datetime.strptime(date, "%Y-%m-%d").date()
else:
target_date = date_type.today()
start = datetime(target_date.year, target_date.month, target_date.day, 0, 0, 0)
end = datetime(target_date.year, target_date.month, target_date.day, 23, 59, 59)
statement = (
select(FoodLog)
.where(FoodLog.user_id == current_user.id)
.where(FoodLog.timestamp >= start)
.where(FoodLog.timestamp <= end)
)
logs = session.exec(statement).all()
return NutritionSummary(
date=target_date.isoformat(),
total_calories=sum(log.calories for log in logs),
total_protein=sum(log.protein for log in logs),
total_carbs=sum(log.carbs for log in logs),
total_fats=sum(log.fats for log in logs),
log_count=len(logs),
target_calories=getattr(current_user, "target_calories", None),
target_protein=getattr(current_user, "target_protein", None),
target_carbs=getattr(current_user, "target_carbs", None),
target_fat=getattr(current_user, "target_fat", None),
)

View File

@@ -0,0 +1,126 @@
import json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
from app.api import deps
from app.config import settings
from app.models.push_subscription import PushSubscription
router = APIRouter()
class SubscriptionKeys(BaseModel):
p256dh: str
auth: str
class SubscribeRequest(BaseModel):
endpoint: str
keys: SubscriptionKeys
reminder_hour: int = 9
reminder_minute: int = 0
timezone: str = "UTC"
class UnsubscribeRequest(BaseModel):
endpoint: str
def send_push(
subscriptions: list[PushSubscription],
title: str,
body: str,
url: str = "/supplements",
session: Session | None = None,
) -> None:
payload = json.dumps({"title": title, "body": body, "url": url})
for sub in subscriptions:
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
},
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": settings.VAPID_MAILTO},
)
except WebPushException as ex:
if ex.response and ex.response.status_code in (404, 410):
sub.is_active = False
if session:
session.add(sub)
@router.get("/vapid-public-key")
def get_vapid_public_key() -> dict:
return {"public_key": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe", status_code=201)
def subscribe(
data: SubscribeRequest,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> dict:
existing = session.exec(select(PushSubscription).where(PushSubscription.endpoint == data.endpoint)).first()
if existing:
existing.user_id = current_user.id
existing.p256dh = data.keys.p256dh
existing.auth = data.keys.auth
existing.reminder_hour = data.reminder_hour
existing.reminder_minute = data.reminder_minute
existing.timezone = data.timezone
existing.is_active = True
session.add(existing)
else:
sub = PushSubscription(
user_id=current_user.id,
endpoint=data.endpoint,
p256dh=data.keys.p256dh,
auth=data.keys.auth,
reminder_hour=data.reminder_hour,
reminder_minute=data.reminder_minute,
timezone=data.timezone,
)
session.add(sub)
session.commit()
return {"status": "subscribed"}
@router.delete("/unsubscribe")
def unsubscribe(
data: UnsubscribeRequest,
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> dict:
sub = session.exec(
select(PushSubscription)
.where(PushSubscription.endpoint == data.endpoint)
.where(PushSubscription.user_id == current_user.id)
).first()
if sub:
sub.is_active = False
session.add(sub)
session.commit()
return {"status": "unsubscribed"}
@router.post("/test")
def send_test_notification(
current_user: deps.CurrentUser,
session: Session = Depends(deps.get_session),
) -> dict:
subs = session.exec(
select(PushSubscription)
.where(PushSubscription.user_id == current_user.id)
.where(PushSubscription.is_active == True) # noqa: E712
).all()
if not subs:
raise HTTPException(status_code=404, detail="No active subscriptions")
send_push(list(subs), title="Test Notification", body="Push notifications are working!", session=session)
session.commit()
return {"sent": len(subs)}

View 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

View File

@@ -59,6 +59,8 @@ def update_user_me(
Update own user.
"""
user_data = user_in.model_dump(exclude_unset=True)
if "password" in user_data:
user_data["password_hash"] = security.get_password_hash(user_data.pop("password"))
current_user.sqlmodel_update(user_data)
session.add(current_user)
session.commit()