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