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:
462
backend/app/api/v1/endpoints/calendar.py
Normal file
462
backend/app/api/v1/endpoints/calendar.py
Normal 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()
|
||||
Reference in New Issue
Block a user