import json from collections import defaultdict 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 estimate_workout_active_kcal from app.fitness.calculators import ( compute_daily_targets, compute_expected_targets, compute_targets, one_rep_max, targets_to_api, tdee_breakdown_to_api, tdee_expected_to_api, EXPECTED_LOOKBACK_DAYS, ) 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_params(self, row: FitnessProfile) -> dict[str, Any]: return { "sex": row.sex, "age": row.age, "height_cm": row.height_cm, "weight_kg": row.weight_kg, "goal": row.goal, "neat_base_kcal": row.neat_base_kcal, "activity_level": row.activity_level, "weekly_workouts": row.weekly_workouts, "baseline_steps": row.baseline_steps, "baseline_workout_kcal": row.baseline_workout_kcal, } def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]: targets = compute_targets(self._profile_params(row)) return { "sex": row.sex, "age": row.age, "height_cm": row.height_cm, "weight_kg": row.weight_kg, "goal": row.goal, "target_weight_kg": row.target_weight_kg, "neat_base_kcal": row.neat_base_kcal, "activity_level": row.activity_level, "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", "goal", "target_weight_kg", "neat_base_kcal", "activity_level", "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(self._profile_params(row)) 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 { "weight_kg": 70, "height_cm": 170, "age": 30, "sex": "male", "goal": "maintain", "neat_base_kcal": 200, "activity_level": "moderate", "weekly_workouts": 3, "baseline_steps": None, "baseline_workout_kcal": None, } def _activity_history( self, end_day: date, *, days: int = EXPECTED_LOOKBACK_DAYS, ) -> list[dict[str, Any]]: """Daily steps and workout kcal for `days` calendar days before end_day (exclusive).""" if days <= 0: return [] start = end_day - timedelta(days=days) range_start = datetime.combine(start, time.min, tzinfo=timezone.utc) range_end = datetime.combine(end_day - timedelta(days=1), time.max, tzinfo=timezone.utc) profile_row = self._get_profile_row() weight_kg = float(profile_row.weight_kg) if profile_row else 70.0 steps_by_day: dict[date, int] = defaultdict(int) workout_kcal_by_day: dict[date, float] = defaultdict(float) steps_rows = self.db.scalars( select(StepLog).where( StepLog.user_id == self.user_id, StepLog.logged_at >= range_start, StepLog.logged_at <= range_end, ) ).all() for row in steps_rows: steps_by_day[row.logged_at.date()] += row.steps workouts_rows = self.db.scalars( select(WorkoutLog).where( WorkoutLog.user_id == self.user_id, WorkoutLog.logged_at >= range_start, WorkoutLog.logged_at <= range_end, ) ).all() for row in workouts_rows: d = row.logged_at.date() workout_kcal_by_day[d] += estimate_workout_active_kcal( self._workout_to_dict(row), weight_kg=weight_kg, ) history: list[dict[str, Any]] = [] cursor = start while cursor < end_day: history.append( { "date": cursor.isoformat(), "steps": steps_by_day.get(cursor, 0), "workout_kcal": round(workout_kcal_by_day.get(cursor, 0.0), 1), } ) cursor += timedelta(days=1) return history def _maybe_update_baseline(self, profile_row: FitnessProfile | None, expected: dict[str, Any]) -> None: if profile_row is None: return if expected.get("source") != "weekly_avg": return if int(expected.get("days_with_data") or 0) < 5: return profile_row.baseline_steps = int(expected.get("expected_steps") or 0) profile_row.baseline_workout_kcal = round( float(expected.get("expected_workout_kcal") or 0) * 7, 1, ) def _expected_payload( self, profile: dict[str, Any], day: date, *, profile_row: FitnessProfile | None = None, update_baseline: bool = False, ) -> tuple[dict[str, Any], dict[str, float]]: history = self._activity_history(day, days=EXPECTED_LOOKBACK_DAYS) expected_daily = compute_expected_targets(profile, history=history) if update_baseline: self._maybe_update_baseline(profile_row, expected_daily) return tdee_expected_to_api(expected_daily), targets_to_api(expected_daily) 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_row() profile_dict = self.get_profile() profile = self._profile_for_budget(profile_dict) 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, } daily = compute_daily_targets( profile, steps_total=steps_total, workouts=workouts, ) targets = targets_to_api(daily) target_day = day or datetime.now(timezone.utc).date() tdee_expected, targets_expected = self._expected_payload( profile, target_day, profile_row=profile_row, update_baseline=target_day == datetime.now(timezone.utc).date(), ) if profile_row is not None and target_day == datetime.now(timezone.utc).date(): self.db.commit() return { "date": target_day.isoformat(), "profile_configured": profile_row is not None, "totals": totals, "targets": targets, "targets_expected": targets_expected, "tdee_breakdown": tdee_breakdown_to_api(daily), "tdee_expected": tdee_expected, "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, "goal": profile_row.goal, "neat_base_kcal": profile_row.neat_base_kcal, } ) 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, activity_type: str | None = None, met: float | None = None, logged_at: datetime | str | None = None, day: date | None = None, days_ago: int | None = None, ) -> dict[str, Any]: profile = self.get_profile() or {} weight_kg = float(profile.get("weight_kg") or 70) if active_calories is None and duration_min and met is not None: active_calories = round(met * weight_kg * (float(duration_min) / 60.0), 1) elif active_calories is None and duration_min: draft = { "title": title, "notes": notes, "activity_type": activity_type, "met": met, "duration_min": duration_min, } active_calories = estimate_workout_active_kcal(draft, weight_kg=weight_kg) or None 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 {} weight_kg = float(profile.get("weight_kg") or 70) weekly_target = 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), weight_kg=weight_kg) 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_tdee_breakdown: 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"], "targets_expected": full.get("targets_expected"), "meal_count": len(full["meals"]), "workout_count": len(full["workouts"]), } if include_tdee_breakdown: item["tdee_breakdown"] = full.get("tdee_breakdown") item["tdee_expected"] = full.get("tdee_expected") 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, }