mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 09:08:46 +01:00
- 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>
463 lines
14 KiB
Python
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()
|