691 lines
24 KiB
Python
691 lines
24 KiB
Python
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(),
|
|
}
|
|
|
|
@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,
|
|
}
|