import json from datetime import date, datetime, time, timedelta, timezone from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session from app.db.models import ( BodyMetric, FitnessProfile, FitnessReminder, FoodLog, StepLog, WaterLog, WorkoutLog, ) from app.fitness.activity_budget import ( build_base_targets, compute_activity_bonus, estimate_workout_active_kcal, scale_targets, ) from app.fitness.calculators import compute_targets, one_rep_max from app.fitness.body_composition import compute_body_composition DEFAULT_REMINDERS = [ {"kind": "water", "hour": 9, "minute": 0, "interval_hours": 2}, {"kind": "meal", "hour": 13, "minute": 0, "interval_hours": None}, {"kind": "workout", "hour": 18, "minute": 0, "interval_hours": None}, {"kind": "weigh_in", "hour": 8, "minute": 0, "interval_hours": None}, ] class FitnessService: def __init__(self, db: Session, user_id: int): self.db = db self.user_id = user_id def _get_profile_row(self) -> FitnessProfile | None: return self.db.scalar(select(FitnessProfile).where(FitnessProfile.user_id == self.user_id).limit(1)) def get_profile(self) -> dict[str, Any] | None: row = self._get_profile_row() if not row: return None return self._profile_to_dict(row) def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]: targets = compute_targets( { "sex": row.sex, "age": row.age, "height_cm": row.height_cm, "weight_kg": row.weight_kg, "activity_level": row.activity_level, "goal": row.goal, } ) return { "sex": row.sex, "age": row.age, "height_cm": row.height_cm, "weight_kg": row.weight_kg, "activity_level": row.activity_level, "goal": row.goal, "target_weight_kg": row.target_weight_kg, "weekly_workouts": row.weekly_workouts, "baseline_steps": row.baseline_steps, "baseline_workout_kcal": row.baseline_workout_kcal, "calorie_target": row.calorie_target, "protein_g": row.protein_g, "fat_g": row.fat_g, "carbs_g": row.carbs_g, "water_l": row.water_l, "computed": targets, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } def set_profile(self, updates: dict[str, Any]) -> dict[str, Any]: row = self._get_profile_row() is_new = row is None if is_new: row = FitnessProfile(user_id=self.user_id) self.db.add(row) self.db.flush() for key in ( "sex", "age", "height_cm", "weight_kg", "activity_level", "goal", "target_weight_kg", "weekly_workouts", "baseline_steps", "baseline_workout_kcal", ): if key in updates and updates[key] is not None: setattr(row, key, updates[key]) targets = compute_targets( { "sex": row.sex, "age": row.age, "height_cm": row.height_cm, "weight_kg": row.weight_kg, "activity_level": row.activity_level, "goal": row.goal, } ) row.calorie_target = targets["calorie_target"] row.protein_g = targets["protein_g"] row.fat_g = targets["fat_g"] row.carbs_g = targets["carbs_g"] row.water_l = targets["water_l"] row.updated_at = datetime.now(timezone.utc) if is_new: self._ensure_default_reminders() self.db.commit() self.db.refresh(row) return {"ok": True, "profile": self._profile_to_dict(row)} def _ensure_default_reminders(self) -> None: existing = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id)).all() if existing: return for item in DEFAULT_REMINDERS: self.db.add(FitnessReminder(user_id=self.user_id, **item)) def calc_targets(self, params: dict[str, Any]) -> dict[str, Any]: return compute_targets(params) def calc_body_composition(self, params: dict[str, Any]) -> dict[str, Any]: profile = self.get_profile() or {} sex = params.get("sex") or profile.get("sex") or "male" height_cm = float(params.get("height_cm") or profile.get("height_cm") or 170) weight_kg = float(params.get("weight_kg") or profile.get("weight_kg") or 70) return compute_body_composition( sex=str(sex), height_cm=height_cm, weight_kg=weight_kg, neck_cm=params.get("neck_cm"), waist_cm=params.get("waist_cm"), hip_cm=params.get("hip_cm"), body_fat_pct=params.get("body_fat_pct"), ) def get_latest_body_composition(self) -> dict[str, Any] | None: rows = self.list_body_metrics(limit=1) return rows[0] if rows else None @staticmethod def _body_metric_to_dict(row: BodyMetric) -> dict[str, Any]: return { "id": row.id, "weight_kg": row.weight_kg, "body_fat_pct": row.body_fat_pct, "body_fat_method": row.body_fat_method, "chest_cm": row.chest_cm, "waist_cm": row.waist_cm, "neck_cm": row.neck_cm, "hip_cm": row.hip_cm, "whr": row.whr, "lbm_kg": row.lbm_kg, "ffmi": row.ffmi, "notes": row.notes, "recorded_at": row.recorded_at.isoformat() if row.recorded_at else None, } @staticmethod def _resolve_logged_at( *, logged_at: datetime | str | None = None, day: date | None = None, days_ago: int | None = None, ) -> datetime: if logged_at is not None: if isinstance(logged_at, str): dt = datetime.fromisoformat(logged_at.replace("Z", "+00:00")) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt if logged_at.tzinfo is None: return logged_at.replace(tzinfo=timezone.utc) return logged_at target_day = day if target_day is None and days_ago is not None: target_day = datetime.now(timezone.utc).date() - timedelta(days=int(days_ago)) if target_day is None: return datetime.now(timezone.utc) return datetime.combine(target_day, time(12, 0), tzinfo=timezone.utc) def _profile_for_budget(self, profile: dict[str, Any] | None) -> dict[str, Any]: if profile: return profile return { "calorie_target": 2000, "protein_g": 140, "fat_g": 65, "carbs_g": 200, "water_l": 2.5, "weight_kg": 70, "activity_level": "moderate", "weekly_workouts": 3, } def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]: d = day or datetime.now(timezone.utc).date() start = datetime.combine(d, time.min, tzinfo=timezone.utc) end = datetime.combine(d, time.max, tzinfo=timezone.utc) return start, end def get_daily_summary(self, day: date | None = None) -> dict[str, Any]: start, end = self._day_bounds(day) profile_row = self.get_profile() profile = self._profile_for_budget(profile_row) foods = self.db.scalars( select(FoodLog) .where(FoodLog.user_id == self.user_id, FoodLog.logged_at >= start, FoodLog.logged_at <= end) .order_by(FoodLog.logged_at) ).all() waters = self.db.scalars( select(WaterLog) .where(WaterLog.user_id == self.user_id, WaterLog.logged_at >= start, WaterLog.logged_at <= end) .order_by(WaterLog.logged_at) ).all() workouts_rows = self.db.scalars( select(WorkoutLog) .where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start, WorkoutLog.logged_at <= end) .order_by(WorkoutLog.logged_at) ).all() steps_rows = self.db.scalars( select(StepLog) .where(StepLog.user_id == self.user_id, StepLog.logged_at >= start, StepLog.logged_at <= end) .order_by(StepLog.logged_at) ).all() workouts = [self._workout_to_dict(w) for w in workouts_rows] steps_total = sum(s.steps for s in steps_rows) totals = { "calories": sum(f.calories for f in foods), "protein_g": sum(f.protein_g for f in foods), "fat_g": sum(f.fat_g for f in foods), "carbs_g": sum(f.carbs_g for f in foods), "water_ml": sum(w.amount_ml for w in waters), "steps": steps_total, } base_targets = build_base_targets(profile) activity = compute_activity_bonus( profile, steps_total=steps_total, workouts=workouts, ) effective_targets, targets_base = scale_targets( base_targets, activity.total_bonus_kcal, ) return { "date": (day or datetime.now(timezone.utc).date()).isoformat(), "profile_configured": profile_row is not None, "totals": totals, "targets": effective_targets, "targets_base": targets_base, "activity": activity.to_dict(), "meals": [self._food_to_dict(f) for f in foods], "water": [self._water_to_dict(w) for w in waters], "workouts": workouts, "steps": [self._step_to_dict(s) for s in steps_rows], "steps_total": steps_total, } def log_meal( self, *, description: str, meal_type: str = "snack", calories: float = 0, protein_g: float = 0, fat_g: float = 0, carbs_g: float = 0, source: str = "llm", estimated: bool = True, ) -> dict[str, Any]: row = FoodLog( user_id=self.user_id, meal_type=meal_type[:32], description=description[:2000], calories=calories, protein_g=protein_g, fat_g=fat_g, carbs_g=carbs_g, source=source[:32], estimated=estimated, ) self.db.add(row) self.db.commit() self.db.refresh(row) return {"ok": True, "meal": self._food_to_dict(row)} def log_water(self, amount_ml: int) -> dict[str, Any]: row = WaterLog(user_id=self.user_id, amount_ml=max(0, amount_ml)) self.db.add(row) self.db.commit() self.db.refresh(row) return {"ok": True, "water": self._water_to_dict(row)} def log_steps( self, steps: int, *, active_calories: float | None = None, logged_at: datetime | str | None = None, day: date | None = None, days_ago: int | None = None, notes: str = "", source: str = "manual", ) -> dict[str, Any]: row = StepLog( user_id=self.user_id, steps=max(0, int(steps)), active_calories=active_calories, notes=notes[:2000], source=source[:32], logged_at=self._resolve_logged_at( logged_at=logged_at, day=day, days_ago=days_ago, ), ) self.db.add(row) self.db.commit() self.db.refresh(row) return {"ok": True, "step_log": self._step_to_dict(row)} def log_weight( self, weight_kg: float, *, body_fat_pct: float | None = None, chest_cm: float | None = None, waist_cm: float | None = None, neck_cm: float | None = None, hip_cm: float | None = None, notes: str = "", recorded_at: datetime | str | None = None, day: date | None = None, days_ago: int | None = None, ) -> dict[str, Any]: profile = self.get_profile() or {} sex = profile.get("sex") or "male" height_cm = float(profile.get("height_cm") or 170) computed = compute_body_composition( sex=str(sex), height_cm=height_cm, weight_kg=weight_kg, neck_cm=neck_cm, waist_cm=waist_cm, hip_cm=hip_cm, body_fat_pct=body_fat_pct, ) row = BodyMetric( user_id=self.user_id, weight_kg=weight_kg, body_fat_pct=computed.get("body_fat_pct"), body_fat_method=computed.get("body_fat_method"), chest_cm=chest_cm, waist_cm=waist_cm, neck_cm=neck_cm, hip_cm=hip_cm, whr=computed.get("whr"), lbm_kg=computed.get("lbm_kg"), ffmi=computed.get("ffmi"), notes=notes[:1000], recorded_at=self._resolve_logged_at( logged_at=recorded_at, day=day, days_ago=days_ago, ), ) self.db.add(row) profile_row = self._get_profile_row() if profile_row: profile_row.weight_kg = weight_kg targets = compute_targets( { "sex": profile_row.sex, "age": profile_row.age, "height_cm": profile_row.height_cm, "weight_kg": weight_kg, "activity_level": profile_row.activity_level, "goal": profile_row.goal, } ) profile_row.calorie_target = targets["calorie_target"] profile_row.protein_g = targets["protein_g"] profile_row.fat_g = targets["fat_g"] profile_row.carbs_g = targets["carbs_g"] profile_row.water_l = targets["water_l"] self.db.commit() self.db.refresh(row) metric = self._body_metric_to_dict(row) return { "ok": True, "metric": metric, "computed": { "body_fat_pct": computed.get("body_fat_pct"), "body_fat_method": computed.get("body_fat_method"), "whr": computed.get("whr"), "lbm_kg": computed.get("lbm_kg"), "ffmi": computed.get("ffmi"), "warnings": computed.get("warnings") or [], }, } def log_workout( self, *, title: str, notes: str = "", duration_min: int | None = None, exercises: list[dict[str, Any]] | None = None, active_calories: float | None = None, total_calories: float | None = None, steps: int | None = None, logged_at: datetime | str | None = None, day: date | None = None, days_ago: int | None = None, ) -> dict[str, Any]: row = WorkoutLog( user_id=self.user_id, title=title[:255], notes=notes[:2000], duration_min=duration_min, active_calories=active_calories, total_calories=total_calories, steps=steps, exercises_json=json.dumps(exercises or [], ensure_ascii=False), logged_at=self._resolve_logged_at( logged_at=logged_at, day=day, days_ago=days_ago, ), ) self.db.add(row) self.db.commit() self.db.refresh(row) return {"ok": True, "workout": self._workout_to_dict(row)} def get_workout_stats( self, *, days: int = 7, end_day: date | None = None, ) -> dict[str, Any]: days = max(1, min(days, 90)) end = end_day or datetime.now(timezone.utc).date() start = end - timedelta(days=days - 1) start_dt, _ = self._day_bounds(start) _, end_dt = self._day_bounds(end) rows = self.db.scalars( select(WorkoutLog) .where(WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= start_dt, WorkoutLog.logged_at <= end_dt) .order_by(WorkoutLog.logged_at) ).all() profile = self.get_profile() or {} weekly_target = int(profile.get("weekly_workouts") or 3) count = len(rows) duration_min = sum(r.duration_min or 0 for r in rows) active_kcal = round( sum(estimate_workout_active_kcal(self._workout_to_dict(r)) for r in rows), 1, ) days_with_workout: set[date] = set() for row in rows: if row.logged_at: days_with_workout.add(row.logged_at.astimezone(timezone.utc).date()) streak = 0 cursor = end while cursor >= start: if cursor in days_with_workout: streak += 1 cursor -= timedelta(days=1) else: break return { "days": days, "start_date": start.isoformat(), "end_date": end.isoformat(), "count": count, "duration_min": duration_min, "active_kcal": active_kcal, "weekly_target": weekly_target, "streak": streak, } def list_body_metrics(self, limit: int = 30) -> list[dict[str, Any]]: rows = self.db.scalars( select(BodyMetric).where(BodyMetric.user_id == self.user_id).order_by(BodyMetric.recorded_at.desc()).limit(limit) ).all() return [self._body_metric_to_dict(r) for r in rows] def delete_food_log(self, log_id: int) -> bool: row = self.db.get(FoodLog, log_id) if not row or row.user_id != self.user_id: return False self.db.delete(row) self.db.commit() return True def delete_water_log(self, log_id: int) -> bool: row = self.db.get(WaterLog, log_id) if not row or row.user_id != self.user_id: return False self.db.delete(row) self.db.commit() return True def delete_workout_log(self, log_id: int) -> bool: row = self.db.get(WorkoutLog, log_id) if not row or row.user_id != self.user_id: return False self.db.delete(row) self.db.commit() return True def delete_step_log(self, log_id: int) -> bool: row = self.db.get(StepLog, log_id) if not row or row.user_id != self.user_id: return False self.db.delete(row) self.db.commit() return True def list_reminders(self) -> list[dict[str, Any]]: rows = self.db.scalars(select(FitnessReminder).where(FitnessReminder.user_id == self.user_id).order_by(FitnessReminder.kind)).all() return [self._reminder_to_dict(r) for r in rows] def set_reminder( self, kind: str, *, enabled: bool | None = None, hour: int | None = None, minute: int | None = None, interval_hours: int | None = None, ) -> dict[str, Any]: row = self.db.scalar( select(FitnessReminder).where(FitnessReminder.user_id == self.user_id, FitnessReminder.kind == kind) ) if not row: row = FitnessReminder(user_id=self.user_id, kind=kind) self.db.add(row) if enabled is not None: row.enabled = enabled if hour is not None: row.hour = hour if minute is not None: row.minute = minute if interval_hours is not None: row.interval_hours = interval_hours self.db.commit() self.db.refresh(row) return {"ok": True, "reminder": self._reminder_to_dict(row)} def calc_one_rm(self, weight_kg: float, reps: int) -> dict[str, Any]: return {"ok": True, "one_rm_kg": one_rep_max(weight_kg, reps)} def get_history( self, *, days: int = 7, end_day: date | None = None, include_targets_base: bool = True, ) -> dict[str, Any]: days = max(1, min(days, 90)) end = end_day or datetime.now(timezone.utc).date() start = end - timedelta(days=days - 1) summaries: list[dict[str, Any]] = [] for offset in range(days): d = start + timedelta(days=offset) full = self.get_daily_summary(d) totals = full["totals"] has_data = bool(full["meals"] or full["water"] or full["workouts"] or full["steps"]) item: dict[str, Any] = { "date": full["date"], "has_data": has_data, "totals": totals, "targets": full["targets"], "meal_count": len(full["meals"]), "workout_count": len(full["workouts"]), } if include_targets_base: item["targets_base"] = full.get("targets_base") summaries.append(item) return { "start_date": start.isoformat(), "end_date": end.isoformat(), "days": days, "summaries": summaries, } def snapshot(self) -> dict[str, Any]: today = datetime.now(timezone.utc).date() return { "profile": self.get_profile(), "today": self.get_daily_summary(today), "history": self.get_history(days=7, end_day=today), "workout_stats": self.get_workout_stats(days=7, end_day=today), "body_metrics": self.list_body_metrics(limit=10), "reminders": self.list_reminders(), } def get_charts( self, *, weeks: int = 52, trend: bool = True, end_day: date | None = None, ) -> dict[str, Any]: from app.fitness.charts import build_fitness_charts return build_fitness_charts( self.db, self.user_id, weeks=weeks, trend=trend, end_day=end_day, ) @staticmethod def _food_to_dict(row: FoodLog) -> dict[str, Any]: return { "id": row.id, "meal_type": row.meal_type, "description": row.description, "calories": row.calories, "protein_g": row.protein_g, "fat_g": row.fat_g, "carbs_g": row.carbs_g, "source": row.source, "estimated": row.estimated, "logged_at": row.logged_at.isoformat() if row.logged_at else None, } @staticmethod def _water_to_dict(row: WaterLog) -> dict[str, Any]: return { "id": row.id, "amount_ml": row.amount_ml, "logged_at": row.logged_at.isoformat() if row.logged_at else None, } @staticmethod def _step_to_dict(row: StepLog) -> dict[str, Any]: return { "id": row.id, "steps": row.steps, "active_calories": row.active_calories, "source": row.source, "notes": row.notes, "logged_at": row.logged_at.isoformat() if row.logged_at else None, } @staticmethod def _workout_to_dict(row: WorkoutLog) -> dict[str, Any]: try: exercises = json.loads(row.exercises_json or "[]") except json.JSONDecodeError: exercises = [] return { "id": row.id, "title": row.title, "notes": row.notes, "duration_min": row.duration_min, "active_calories": row.active_calories, "total_calories": row.total_calories, "steps": row.steps, "exercises": exercises, "logged_at": row.logged_at.isoformat() if row.logged_at else None, } @staticmethod def _reminder_to_dict(row: FitnessReminder) -> dict[str, Any]: return { "id": row.id, "kind": row.kind, "hour": row.hour, "minute": row.minute, "interval_hours": row.interval_hours, "enabled": row.enabled, "last_fired_at": row.last_fired_at.isoformat() if row.last_fired_at else None, }