mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 12:48:47 +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:
57
backend/app/ai/kettlebell.py
Normal file
57
backend/app/ai/kettlebell.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import dspy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExerciseBlock(BaseModel):
|
||||
order: int = Field(description="Position in workout sequence")
|
||||
name: str = Field(description="Exercise name")
|
||||
description: str = Field(description="Brief description of the movement")
|
||||
sets: int = Field(description="Number of sets")
|
||||
reps: int = Field(description="Number of reps per set (0 if timed)")
|
||||
duration_seconds: int = Field(description="Duration in seconds per set (0 if rep-based)")
|
||||
weight_kg: float = Field(description="Prescribed weight in kg")
|
||||
rest_seconds: int = Field(description="Rest time between sets in seconds")
|
||||
coaching_tip: str = Field(description="Key coaching cue for this exercise")
|
||||
|
||||
|
||||
class KettlebellSessionOutput(BaseModel):
|
||||
reasoning: str = Field(description="Step-by-step reasoning for session design choices")
|
||||
title: str = Field(description="Session title")
|
||||
focus: str = Field(description="Session focus e.g. strength, conditioning, mobility")
|
||||
total_duration_min: int = Field(description="Estimated total workout duration in minutes")
|
||||
difficulty: str = Field(description="Difficulty level: beginner, intermediate, advanced")
|
||||
exercises: list[ExerciseBlock] = Field(description="Ordered list of exercises in the session")
|
||||
notes: str = Field(description="Coaching notes and any special instructions for the session")
|
||||
|
||||
|
||||
class GenerateKettlebellSession(dspy.Signature):
|
||||
"""Generate a personalized kettlebell workout session based on user profile and preferences.
|
||||
|
||||
Think step-by-step: assess user fitness level, pick movements appropriate to the focus and
|
||||
difficulty, assign weights respecting progressive overload principles from available weights,
|
||||
sequence exercises for proper warm-up and fatigue management, and ensure total work time
|
||||
(sets × reps/duration + rest periods) fits within the requested duration.
|
||||
"""
|
||||
|
||||
user_profile: str = dspy.InputField(desc="User details including age, weight, fitness level, and goals")
|
||||
available_weights_kg: str = dspy.InputField(desc="Comma-separated list of available kettlebell weights in kg")
|
||||
focus: str = dspy.InputField(desc="Session focus: strength, conditioning, mobility, fat loss, etc.")
|
||||
duration_minutes: int = dspy.InputField(desc="Target session duration in minutes")
|
||||
session: KettlebellSessionOutput = dspy.OutputField(desc="Complete structured kettlebell session")
|
||||
|
||||
|
||||
class KettlebellModule(dspy.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.generate = dspy.ChainOfThought(GenerateKettlebellSession)
|
||||
|
||||
def forward(self, user_profile: str, available_weights_kg: str, focus: str, duration_minutes: int):
|
||||
return self.generate(
|
||||
user_profile=user_profile,
|
||||
available_weights_kg=available_weights_kg,
|
||||
focus=focus,
|
||||
duration_minutes=duration_minutes,
|
||||
)
|
||||
|
||||
|
||||
kettlebell_module = KettlebellModule()
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import health, login, nutrition, plans, users
|
||||
from app.api.v1.endpoints import calendar, health, kettlebell, login, nutrition, plans, push, supplements, users
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(login.router, tags=["login"])
|
||||
@@ -8,3 +8,7 @@ api_router.include_router(users.router, prefix="/users", tags=["users"])
|
||||
api_router.include_router(nutrition.router, prefix="/nutrition", tags=["nutrition"])
|
||||
api_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
api_router.include_router(plans.router, prefix="/plans", tags=["plans"])
|
||||
api_router.include_router(kettlebell.router, prefix="/kettlebell", tags=["kettlebell"])
|
||||
api_router.include_router(supplements.router, prefix="/supplements", tags=["supplements"])
|
||||
api_router.include_router(calendar.router, prefix="/calendar", tags=["calendar"])
|
||||
api_router.include_router(push.router, prefix="/push", tags=["push"])
|
||||
|
||||
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()
|
||||
404
backend/app/api/v1/endpoints/kettlebell.py
Normal file
404
backend/app/api/v1/endpoints/kettlebell.py
Normal 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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
126
backend/app/api/v1/endpoints/push.py
Normal file
126
backend/app/api/v1/endpoints/push.py
Normal 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)}
|
||||
238
backend/app/api/v1/endpoints/supplements.py
Normal file
238
backend/app/api/v1/endpoints/supplements.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
27
backend/app/models/calendar.py
Normal file
27
backend/app/models/calendar.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import datetime as dt
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class DailyNote(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
date: dt.date = Field(index=True)
|
||||
content: str = Field(default="", max_length=10000)
|
||||
mood: Optional[str] = None # "great"/"good"/"okay"/"bad"/"awful"
|
||||
energy_level: Optional[int] = None # 1–10
|
||||
updated_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)
|
||||
|
||||
|
||||
class CalendarEvent(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
date: dt.date = Field(index=True)
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
event_type: str = "general" # "workout" | "supplement" | "general"
|
||||
color: Optional[str] = None
|
||||
start_time: Optional[str] = None # "HH:MM"
|
||||
is_completed: bool = False
|
||||
created_at: dt.datetime = Field(default_factory=dt.datetime.utcnow)
|
||||
44
backend/app/models/kettlebell.py
Normal file
44
backend/app/models/kettlebell.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import JSON, Field, SQLModel
|
||||
|
||||
|
||||
class ExerciseBlock(BaseModel):
|
||||
order: int
|
||||
name: str
|
||||
description: str
|
||||
sets: int
|
||||
reps: int
|
||||
duration_seconds: int # reps=0 → timed, duration_seconds=0 → rep-based
|
||||
weight_kg: float
|
||||
rest_seconds: int
|
||||
coaching_tip: str
|
||||
|
||||
|
||||
class KettlebellSession(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
title: str
|
||||
focus: str # e.g. "strength", "conditioning"
|
||||
exercises: dict = Field(default={}, sa_type=JSON) # full AI-prescribed exercise list
|
||||
total_duration_min: int
|
||||
difficulty: str
|
||||
notes: str = Field(default="")
|
||||
status: str = Field(default="generated") # "generated" | "in_progress" | "completed" | "abandoned"
|
||||
started_at: Optional[datetime] = Field(default=None)
|
||||
completed_at: Optional[datetime] = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class KettlebellSetLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
session_id: int = Field(foreign_key="kettlebellsession.id", index=True)
|
||||
exercise_order: int
|
||||
set_number: int
|
||||
actual_reps: int
|
||||
actual_weight_kg: float
|
||||
actual_duration_seconds: int
|
||||
perceived_effort: int # 1–10 RPE
|
||||
completed_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
17
backend/app/models/push_subscription.py
Normal file
17
backend/app/models/push_subscription.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class PushSubscription(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
endpoint: str = Field(unique=True, index=True)
|
||||
p256dh: str
|
||||
auth: str
|
||||
reminder_hour: int = Field(default=9)
|
||||
reminder_minute: int = Field(default=0)
|
||||
timezone: str = Field(default="UTC")
|
||||
is_active: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
27
backend/app/models/supplement.py
Normal file
27
backend/app/models/supplement.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Column
|
||||
from sqlmodel import JSON, Field, SQLModel
|
||||
|
||||
|
||||
class Supplement(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
name: str = Field(index=True)
|
||||
dosage: float
|
||||
unit: str # mg / mcg / IU / g
|
||||
frequency: str = Field(default="daily") # daily / weekly / as_needed
|
||||
scheduled_times: List[str] = Field(default=[], sa_column=Column(JSON)) # ["08:00", "20:00"]
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = Field(default=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class SupplementLog(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
user_id: int = Field(foreign_key="user.id", index=True)
|
||||
supplement_id: int = Field(foreign_key="supplement.id", index=True)
|
||||
taken_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
dose_taken: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
@@ -19,4 +19,10 @@ class User(SQLModel, table=True):
|
||||
weight: Optional[float] = None
|
||||
unit_preference: str = Field(default="metric") # "metric" or "imperial"
|
||||
|
||||
# Nutrition Targets
|
||||
target_calories: Optional[float] = None
|
||||
target_protein: Optional[float] = None
|
||||
target_carbs: Optional[float] = None
|
||||
target_fat: Optional[float] = None
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
47
backend/app/scheduler.py
Normal file
47
backend/app/scheduler.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytz
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.db import engine
|
||||
from app.models.push_subscription import PushSubscription
|
||||
|
||||
scheduler = BackgroundScheduler(timezone="UTC")
|
||||
|
||||
|
||||
def send_supplement_reminders() -> None:
|
||||
from app.api.v1.endpoints.push import send_push
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
with Session(engine) as session:
|
||||
subs = session.exec(
|
||||
select(PushSubscription).where(PushSubscription.is_active == True) # noqa: E712
|
||||
).all()
|
||||
due = []
|
||||
for sub in subs:
|
||||
try:
|
||||
tz = pytz.timezone(sub.timezone)
|
||||
local_now = now_utc.astimezone(tz)
|
||||
if local_now.hour == sub.reminder_hour and local_now.minute == sub.reminder_minute:
|
||||
due.append(sub)
|
||||
except Exception:
|
||||
pass
|
||||
if due:
|
||||
send_push(due, title="Supplement Reminder", body="Time to log your supplements for today!", session=session)
|
||||
session.commit()
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
scheduler.add_job(
|
||||
send_supplement_reminders,
|
||||
CronTrigger(minute="*"),
|
||||
id="supplement_reminders",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
scheduler.shutdown(wait=False)
|
||||
@@ -21,6 +21,10 @@ class UserRead(UserBase):
|
||||
height: Optional[float] = None
|
||||
weight: Optional[float] = None
|
||||
unit_preference: str = "metric"
|
||||
target_calories: Optional[float] = None
|
||||
target_protein: Optional[float] = None
|
||||
target_carbs: Optional[float] = None
|
||||
target_fat: Optional[float] = None
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
@@ -34,3 +38,7 @@ class UserUpdate(SQLModel):
|
||||
height: Optional[float] = None
|
||||
weight: Optional[float] = None
|
||||
unit_preference: Optional[str] = None
|
||||
target_calories: Optional[float] = None
|
||||
target_protein: Optional[float] = None
|
||||
target_carbs: Optional[float] = None
|
||||
target_fat: Optional[float] = None
|
||||
|
||||
Reference in New Issue
Block a user