Files
healthy-fit/backend/app/api/v1/endpoints/calendar.py
Carlos Escalante f279907ae3 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>
2026-03-20 18:57:03 -06:00

463 lines
14 KiB
Python

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()