From 0f2827030b7fa3805d5733b7f11df3c219751d8a Mon Sep 17 00:00:00 2001 From: grigo Date: Tue, 16 Jun 2026 08:04:15 +0300 Subject: [PATCH] fixed dynamic TDEE --- backend/app/api/routes/fitness.py | 4 + backend/app/fitness/calculators.py | 107 +++++++++++++++ backend/app/fitness/charts.py | 154 +++++++++++++++++++++- backend/app/fitness/context.py | 47 +++++-- backend/app/fitness/service.py | 121 ++++++++++++++++- backend/app/tools/registry.py | 18 +++ backend/tests/test_expected_tdee.py | 80 +++++++++++ frontend/src/api/client.ts | 14 ++ frontend/src/components/FitnessCharts.tsx | 11 +- frontend/src/pages/Fitness.css | 12 ++ frontend/src/pages/Fitness.tsx | 53 +++++++- 11 files changed, 603 insertions(+), 18 deletions(-) create mode 100644 backend/tests/test_expected_tdee.py diff --git a/backend/app/api/routes/fitness.py b/backend/app/api/routes/fitness.py index 6c50fc4..7e32f05 100644 --- a/backend/app/api/routes/fitness.py +++ b/backend/app/api/routes/fitness.py @@ -24,6 +24,10 @@ class ProfileUpdate(BaseModel): goal: str | None = None target_weight_kg: float | None = None neat_base_kcal: float | None = Field(default=None, ge=200, le=300) + activity_level: str | None = None + weekly_workouts: int | None = Field(default=None, ge=0, le=14) + baseline_steps: int | None = Field(default=None, ge=0) + baseline_workout_kcal: float | None = Field(default=None, ge=0) class MealCreate(BaseModel): diff --git a/backend/app/fitness/calculators.py b/backend/app/fitness/calculators.py index 9613653..4876b69 100644 --- a/backend/app/fitness/calculators.py +++ b/backend/app/fitness/calculators.py @@ -21,6 +21,17 @@ PROTEIN_G_PER_KG = { } FAT_G_PER_KG = 1.0 +EXPECTED_LOOKBACK_DAYS = 7 +EXPECTED_MIN_DAYS_WITH_DATA = 3 +DEFAULT_SESSION_KCAL = 350.0 + +ACTIVITY_LEVEL_STEPS: dict[str, int] = { + "sedentary": 5000, + "moderate": 8000, + "active": 10000, + "very_active": 12000, +} + def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float: base = 10 * weight_kg + 6.25 * height_cm - 5 * age @@ -166,3 +177,99 @@ def compute_targets(profile: dict[str, Any]) -> dict[str, Any]: "carbs_g": daily["carbs_g"], "water_l": daily["water_l"], } + + +def _activity_level_steps(activity_level: str | None) -> int: + key = (activity_level or "moderate").lower().replace("-", "_") + return ACTIVITY_LEVEL_STEPS.get(key, ACTIVITY_LEVEL_STEPS["moderate"]) + + +def _history_days_with_data(history: list[dict[str, Any]]) -> int: + return sum( + 1 + for row in history + if int(row.get("steps") or 0) > 0 or float(row.get("workout_kcal") or 0) > 0 + ) + + +def resolve_expected_activity( + profile: dict[str, Any], + *, + history: list[dict[str, Any]], + lookback_days: int = EXPECTED_LOOKBACK_DAYS, +) -> tuple[int, float, str, int]: + """Return expected daily steps, workout kcal, source, and days_with_data.""" + days_with_data = _history_days_with_data(history) + + if days_with_data >= EXPECTED_MIN_DAYS_WITH_DATA: + steps_vals = [int(row.get("steps") or 0) for row in history] + workout_vals = [float(row.get("workout_kcal") or 0) for row in history] + expected_steps = round(sum(steps_vals) / len(steps_vals)) + expected_workout_kcal = round(sum(workout_vals) / len(workout_vals), 1) + return expected_steps, expected_workout_kcal, "weekly_avg", days_with_data + + baseline_steps = profile.get("baseline_steps") + baseline_workout_kcal = profile.get("baseline_workout_kcal") + if baseline_steps is not None or baseline_workout_kcal is not None: + steps = int(baseline_steps) if baseline_steps is not None else _activity_level_steps( + profile.get("activity_level") + ) + workout_daily = ( + round(float(baseline_workout_kcal) / 7, 1) + if baseline_workout_kcal is not None + else round( + int(profile.get("weekly_workouts") or 3) * DEFAULT_SESSION_KCAL / 7, + 1, + ) + ) + return steps, workout_daily, "baseline", days_with_data + + weekly_workouts = int(profile.get("weekly_workouts") or 3) + return ( + _activity_level_steps(profile.get("activity_level")), + round(weekly_workouts * DEFAULT_SESSION_KCAL / 7, 1), + "defaults", + days_with_data, + ) + + +def compute_expected_targets( + profile: dict[str, Any], + *, + history: list[dict[str, Any]], + lookback_days: int = EXPECTED_LOOKBACK_DAYS, +) -> dict[str, Any]: + expected_steps, expected_workout_kcal, source, days_with_data = resolve_expected_activity( + profile, + history=history, + lookback_days=lookback_days, + ) + workouts = [{"active_calories": expected_workout_kcal}] if expected_workout_kcal > 0 else [] + daily = compute_daily_targets( + profile, + steps_total=expected_steps, + workouts=workouts, + ) + return { + **daily, + "source": source, + "lookback_days": lookback_days, + "days_with_data": days_with_data, + "expected_steps": expected_steps, + "expected_workout_kcal": expected_workout_kcal, + } + + +def tdee_expected_to_api(daily: dict[str, Any]) -> dict[str, Any]: + return { + "bmr": daily["bmr"], + "neat_kcal": daily["neat_kcal"], + "steps_kcal": daily["steps_kcal"], + "workout_kcal": daily["workout_kcal"], + "tdee": daily["tdee"], + "calorie_target": daily["calorie_target"], + "steps": daily.get("expected_steps", daily.get("steps", 0)), + "source": daily.get("source", "defaults"), + "lookback_days": daily.get("lookback_days", EXPECTED_LOOKBACK_DAYS), + "days_with_data": daily.get("days_with_data", 0), + } diff --git a/backend/app/fitness/charts.py b/backend/app/fitness/charts.py index a282ab3..435c752 100644 --- a/backend/app/fitness/charts.py +++ b/backend/app/fitness/charts.py @@ -9,12 +9,20 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session -from app.db.models import BodyMetric, FoodLog, StepLog, WaterLog +from app.db.models import BodyMetric, FitnessProfile, FoodLog, StepLog, WaterLog, WorkoutLog +from app.fitness.activity_budget import estimate_workout_active_kcal +from app.fitness.calculators import ( + EXPECTED_LOOKBACK_DAYS, + compute_daily_targets, + compute_expected_targets, +) METRIC_DEFS: dict[str, dict[str, str]] = { "weight_kg": {"label": "Вес", "unit": "кг"}, "body_fat_pct": {"label": "Жир", "unit": "%"}, "calories": {"label": "Калории", "unit": "ккал/день"}, + "tdee": {"label": "TDEE факт", "unit": "ккал/день"}, + "tdee_expected": {"label": "TDEE план", "unit": "ккал/день"}, "protein_g": {"label": "Белок", "unit": "г/день"}, "water_l": {"label": "Вода", "unit": "л/день"}, "steps": {"label": "Шаги", "unit": "шаг/день"}, @@ -55,6 +63,106 @@ def _last(values: list[tuple[date, float]]) -> float | None: return values[-1][1] +def _profile_for_charts(row: FitnessProfile | None) -> dict[str, float | int | str | None] | None: + if row is None: + return None + 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 _load_activity_maps( + db: Session, + user_id: int, + range_start: datetime, + range_end: datetime, + weight_kg: float, +) -> tuple[dict[date, int], dict[date, float]]: + steps_by_day: dict[date, int] = defaultdict(int) + workout_kcal_by_day: dict[date, float] = defaultdict(float) + + steps_rows = db.scalars( + select(StepLog).where( + StepLog.user_id == 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 = db.scalars( + select(WorkoutLog).where( + WorkoutLog.user_id == 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( + { + "title": row.title, + "duration_min": row.duration_min, + "active_calories": row.active_calories, + }, + weight_kg=weight_kg, + ) + return steps_by_day, workout_kcal_by_day + + +def _activity_history_before( + day: date, + steps_by_day: dict[date, int], + workout_kcal_by_day: dict[date, float], + *, + days: int = EXPECTED_LOOKBACK_DAYS, +) -> list[dict[str, float | int]]: + history: list[dict[str, float | int]] = [] + start = day - timedelta(days=days) + cursor = start + while cursor < day: + history.append( + { + "steps": steps_by_day.get(cursor, 0), + "workout_kcal": workout_kcal_by_day.get(cursor, 0.0), + } + ) + cursor += timedelta(days=1) + return history + + +def _tdee_actual_for_day( + profile: dict[str, float | int | str | None], + steps_by_day: dict[date, int], + workout_kcal_by_day: dict[date, float], + day: date, +) -> float: + steps = steps_by_day.get(day, 0) + workout_kcal = workout_kcal_by_day.get(day, 0.0) + workouts = [{"active_calories": workout_kcal}] if workout_kcal > 0 else [] + return float(compute_daily_targets(profile, steps_total=steps, workouts=workouts)["tdee"]) + + +def _tdee_expected_for_day( + profile: dict[str, float | int | str | None], + steps_by_day: dict[date, int], + workout_kcal_by_day: dict[date, float], + day: date, +) -> float: + history = _activity_history_before(day, steps_by_day, workout_kcal_by_day) + return float(compute_expected_targets(profile, history=history)["tdee"]) + + def build_fitness_charts( db: Session, user_id: int, @@ -71,6 +179,25 @@ def build_fitness_charts( range_start = datetime.combine(first_week_start, datetime.min.time(), tzinfo=timezone.utc) range_end = datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc) + profile_row = db.scalar( + select(FitnessProfile).where(FitnessProfile.user_id == user_id).limit(1) + ) + profile = _profile_for_charts(profile_row) + weight_kg = float(profile["weight_kg"]) if profile else 70.0 + + activity_start = datetime.combine( + first_week_start - timedelta(days=EXPECTED_LOOKBACK_DAYS), + datetime.min.time(), + tzinfo=timezone.utc, + ) + steps_by_day, workout_kcal_by_day = _load_activity_maps( + db, + user_id, + activity_start, + range_end, + weight_kg, + ) + daily: dict[date, dict[str, float]] = defaultdict(lambda: { "calories": 0.0, "protein_g": 0.0, @@ -180,6 +307,14 @@ def build_fitness_charts( week_daily_values.append(totals["water_ml"] / 1000.0) elif metric == "steps" and totals and "steps" in flags: week_daily_values.append(totals["steps"]) + elif metric == "tdee" and profile is not None and day_cursor <= end: + week_daily_values.append( + _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor) + ) + elif metric == "tdee_expected" and profile is not None and day_cursor <= end: + week_daily_values.append( + _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, day_cursor) + ) day_cursor += timedelta(days=1) value: float | None @@ -253,6 +388,9 @@ def build_fitness_charts( end, trend=trend, lookback_days=min(30, max(days_with_data, 7)), + profile=profile, + steps_by_day=steps_by_day, + workout_kcal_by_day=workout_kcal_by_day, ) return { @@ -276,6 +414,9 @@ def _build_daily_series( *, trend: bool, lookback_days: int, + profile: dict[str, float | int | str | None] | None = None, + steps_by_day: dict[date, int] | None = None, + workout_kcal_by_day: dict[date, float] | None = None, ) -> dict[str, Any]: start = end - timedelta(days=lookback_days - 1) day_points: list[date] = [] @@ -314,6 +455,17 @@ def _build_daily_series( elif key == "steps" and totals and "steps" in flags: value = totals["steps"] has_data = True + elif key == "tdee" and profile is not None and steps_by_day is not None and workout_kcal_by_day is not None: + value = _tdee_actual_for_day(profile, steps_by_day, workout_kcal_by_day, d) + has_data = True + elif ( + key == "tdee_expected" + and profile is not None + and steps_by_day is not None + and workout_kcal_by_day is not None + ): + value = _tdee_expected_for_day(profile, steps_by_day, workout_kcal_by_day, d) + has_data = True points.append( { diff --git a/backend/app/fitness/context.py b/backend/app/fitness/context.py index 47f29a0..3748cff 100644 --- a/backend/app/fitness/context.py +++ b/backend/app/fitness/context.py @@ -36,30 +36,60 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str: totals = today.get("totals") or {} targets = today.get("targets") or {} breakdown = today.get("tdee_breakdown") or {} + expected = today.get("tdee_expected") or {} + targets_expected = today.get("targets_expected") or {} steps_total = today.get("steps_total") or 0 water_l = totals.get("water_ml", 0) / 1000 water_target = targets.get("water_ml", 2500) / 1000 if breakdown: lines.append( - f"TDEE за день: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + " + f"TDEE факт: BMR {breakdown.get('bmr')} + NEAT {breakdown.get('neat_kcal')} + " f"шаги {breakdown.get('steps_kcal')} ({steps_total} шаг.) + " f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → " f"цель {breakdown.get('calorie_target')} ккал" ) elif steps_total == 0: lines.append( - "Шаги/тренировки не внесены — TDEE считается как BMR + NEAT. " + "Шаги/тренировки не внесены — TDEE факт = BMR + NEAT. " "log_steps / log_workout для точной дневной цели." ) + if expected: + source = expected.get("source", "?") + source_labels = { + "weekly_avg": "среднее за неделю", + "baseline": "baseline профиля", + "defaults": "по activity_level", + } + source_label = source_labels.get(str(source), str(source)) + days_data = expected.get("days_with_data", 0) + lookback = expected.get("lookback_days", 7) + extra = f", {days_data} дн. с данными за {lookback} дн." if source == "weekly_avg" else "" + lines.append( + f"TDEE план ({source_label}{extra}): BMR {expected.get('bmr')} + NEAT {expected.get('neat_kcal')} + " + f"шаги {expected.get('steps_kcal')} (~{expected.get('steps', 0)} шаг.) + " + f"тренировки {expected.get('workout_kcal')} = {expected.get('tdee')} ккал → " + f"цель {expected.get('calorie_target')} ккал" + ) + lines.append("") - lines.append( - f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · " - f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " - f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " - f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г" - ) + if targets_expected and targets_expected.get("carbs_g") != targets.get("carbs_g"): + lines.append( + f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал " + f"(план {targets_expected.get('calories', 0):.0f}) · " + f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " + f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " + f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} " + f"(план {targets_expected.get('carbs_g', 0):.0f}) г" + ) + else: + lines.append( + f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · " + f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " + f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " + f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 0):.0f} г" + ) lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л") workouts = today.get("workouts") or [] @@ -99,6 +129,7 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str: "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, " "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " "TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. " + "TDEE факт — по залогированной активности; TDEE план — среднее за неделю (или baseline) для утреннего бюджета углеводов. " "БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. " "Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. " "Еда — оценка LLM (≈)." diff --git a/backend/app/fitness/service.py b/backend/app/fitness/service.py index 2e41f3d..218f3a8 100644 --- a/backend/app/fitness/service.py +++ b/backend/app/fitness/service.py @@ -1,4 +1,5 @@ import json +from collections import defaultdict from datetime import date, datetime, time, timedelta, timezone from typing import Any @@ -17,10 +18,13 @@ from app.db.models import ( 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 @@ -54,6 +58,10 @@ class FitnessService: "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]: @@ -66,6 +74,10 @@ class FitnessService: "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, @@ -86,6 +98,8 @@ class FitnessService: 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]) @@ -188,8 +202,95 @@ class FitnessService: "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() @@ -199,8 +300,9 @@ class FitnessService: 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) + profile_row = self._get_profile_row() + profile_dict = self.get_profile() + profile = self._profile_for_budget(profile_dict) foods = self.db.scalars( select(FoodLog) @@ -241,13 +343,24 @@ class FitnessService: 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": (day or datetime.now(timezone.utc).date()).isoformat(), + "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, @@ -603,11 +716,13 @@ class FitnessService: "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 { diff --git a/backend/app/tools/registry.py b/backend/app/tools/registry.py index aaf6696..dc44439 100644 --- a/backend/app/tools/registry.py +++ b/backend/app/tools/registry.py @@ -350,6 +350,22 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [ "type": "number", "description": "NEAT-база 200–300 ккал, по умолчанию 200", }, + "activity_level": { + "type": "string", + "description": "sedentary/moderate/active/very_active — fallback для TDEE план", + }, + "weekly_workouts": { + "type": "integer", + "description": "Тренировок в неделю для fallback TDEE план", + }, + "baseline_steps": { + "type": "integer", + "description": "Ожидаемые шаги/день (fallback TDEE план)", + }, + "baseline_workout_kcal": { + "type": "number", + "description": "Ожидаемые ккал тренировок в неделю (fallback TDEE план)", + }, }, "required": [], }, @@ -923,6 +939,8 @@ async def execute_tool( for k in ( "sex", "age", "height_cm", "weight_kg", "goal", "target_weight_kg", "neat_base_kcal", + "activity_level", "weekly_workouts", + "baseline_steps", "baseline_workout_kcal", ) if k in arguments and arguments[k] is not None } diff --git a/backend/tests/test_expected_tdee.py b/backend/tests/test_expected_tdee.py new file mode 100644 index 0000000..512e37b --- /dev/null +++ b/backend/tests/test_expected_tdee.py @@ -0,0 +1,80 @@ +import unittest +from datetime import date, timedelta + +from app.fitness.calculators import ( + compute_daily_targets, + compute_expected_targets, + resolve_expected_activity, +) + +PROFILE_LOSE = { + "sex": "male", + "age": 30, + "height_cm": 180, + "weight_kg": 86, + "goal": "lose", + "neat_base_kcal": 200, + "activity_level": "moderate", + "weekly_workouts": 3, + "baseline_steps": None, + "baseline_workout_kcal": None, +} + + +class ExpectedActivityTests(unittest.TestCase): + def test_weekly_avg_with_enough_history(self) -> None: + history = [ + {"steps": 8000, "workout_kcal": 400.0}, + {"steps": 9000, "workout_kcal": 500.0}, + {"steps": 7000, "workout_kcal": 450.0}, + {"steps": 0, "workout_kcal": 0.0}, + ] + steps, workout_kcal, source, days = resolve_expected_activity(PROFILE_LOSE, history=history) + self.assertEqual(source, "weekly_avg") + self.assertEqual(days, 3) + self.assertEqual(steps, 6000) + self.assertEqual(workout_kcal, 337.5) + + def test_baseline_fallback(self) -> None: + profile = { + **PROFILE_LOSE, + "baseline_steps": 10000, + "baseline_workout_kcal": 2100.0, + } + history = [{"steps": 1000, "workout_kcal": 50.0}] + steps, workout_kcal, source, _ = resolve_expected_activity(profile, history=history) + self.assertEqual(source, "baseline") + self.assertEqual(steps, 10000) + self.assertEqual(workout_kcal, 300.0) + + def test_defaults_fallback(self) -> None: + history = [{"steps": 0, "workout_kcal": 0.0}] * 7 + steps, workout_kcal, source, _ = resolve_expected_activity(PROFILE_LOSE, history=history) + self.assertEqual(source, "defaults") + self.assertEqual(steps, 8000) + self.assertAlmostEqual(workout_kcal, 150.0, delta=0.1) + + def test_history_excludes_current_day_for_expected(self) -> None: + """Expected for day D uses only prior days in history list.""" + day = date(2026, 6, 16) + prior_days = [] + cursor = day - timedelta(days=7) + while cursor < day: + prior_days.append({"steps": 8000, "workout_kcal": 450.0}) + cursor += timedelta(days=1) + expected = compute_expected_targets(PROFILE_LOSE, history=prior_days) + rest = compute_daily_targets(PROFILE_LOSE, steps_total=0, workouts=[]) + self.assertEqual(expected["source"], "weekly_avg") + self.assertGreater(expected["tdee"], rest["tdee"]) + self.assertGreater(expected["carbs_g"], rest["carbs_g"]) + + def test_morning_carbs_scenario(self) -> None: + rest = compute_daily_targets(PROFILE_LOSE, steps_total=0, workouts=[]) + history = [{"steps": 8000, "workout_kcal": 450.0}] * 7 + expected = compute_expected_targets(PROFILE_LOSE, history=history) + self.assertLess(rest["carbs_g"], 10) + self.assertGreater(expected["carbs_g"], 100) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3ad141a..bf71002 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -293,6 +293,12 @@ export interface FitnessTdeeBreakdown { steps: number; } +export interface FitnessTdeeExpected extends FitnessTdeeBreakdown { + source: "weekly_avg" | "baseline" | "defaults"; + lookback_days: number; + days_with_data: number; +} + export interface FitnessTargets { calories: number; protein_g: number; @@ -338,6 +344,10 @@ export interface FitnessProfile { goal?: string; target_weight_kg?: number | null; neat_base_kcal?: number; + activity_level?: string; + weekly_workouts?: number; + baseline_steps?: number | null; + baseline_workout_kcal?: number | null; calorie_target?: number; protein_g?: number; fat_g?: number; @@ -387,7 +397,9 @@ export interface FitnessDailySummary { steps?: number; }; targets: FitnessTargets; + targets_expected?: FitnessTargets; tdee_breakdown?: FitnessTdeeBreakdown; + tdee_expected?: FitnessTdeeExpected; steps?: StepLogItem[]; steps_total?: number; meals: FoodLogItem[]; @@ -434,7 +446,9 @@ export interface FitnessDayOverview { has_data: boolean; totals: FitnessDailySummary["totals"]; targets: FitnessDailySummary["targets"]; + targets_expected?: FitnessTargets; tdee_breakdown?: FitnessTdeeBreakdown; + tdee_expected?: FitnessTdeeExpected; meal_count: number; workout_count: number; } diff --git a/frontend/src/components/FitnessCharts.tsx b/frontend/src/components/FitnessCharts.tsx index 44ff7c2..eb0fa73 100644 --- a/frontend/src/components/FitnessCharts.tsx +++ b/frontend/src/components/FitnessCharts.tsx @@ -2,7 +2,16 @@ import { useMemo } from "react"; import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client"; import "./FitnessCharts.css"; -const CHART_KEYS = ["weight_kg", "calories", "protein_g", "water_l", "steps", "body_fat_pct"] as const; +const CHART_KEYS = [ + "weight_kg", + "calories", + "tdee", + "tdee_expected", + "protein_g", + "water_l", + "steps", + "body_fat_pct", +] as const; interface MetricChartProps { series: FitnessChartSeries; diff --git a/frontend/src/pages/Fitness.css b/frontend/src/pages/Fitness.css index 822d460..6db05d3 100644 --- a/frontend/src/pages/Fitness.css +++ b/frontend/src/pages/Fitness.css @@ -385,6 +385,18 @@ border-radius: 0 4px 4px 0; } +.fitness-progress-plan-marker { + position: absolute; + top: -2px; + width: 2px; + height: calc(100% + 4px); + background: #c9a227; + border-radius: 1px; + transform: translateX(-50%); + z-index: 3; + pointer-events: none; +} + .fitness-activity-block { margin-bottom: 0.75rem; font-size: 0.85rem; diff --git a/frontend/src/pages/Fitness.tsx b/frontend/src/pages/Fitness.tsx index 3d3babb..506c2fe 100644 --- a/frontend/src/pages/Fitness.tsx +++ b/frontend/src/pages/Fitness.tsx @@ -38,26 +38,38 @@ function ProgressBar({ label, current, target, + targetPlan, unit, }: { label: string; current: number; target: number; + targetPlan?: number; unit: string; }) { - const scaleMax = Math.max(target, current, 1); + const showPlan = targetPlan != null && Math.abs(targetPlan - target) >= 1; + const scaleMax = Math.max(target, targetPlan ?? target, current, 1); const fillPct = (Math.min(current, scaleMax) / scaleMax) * 100; const overflowPct = current > target ? ((current - target) / scaleMax) * 100 : 0; + const planPct = showPlan ? (Math.min(targetPlan!, scaleMax) / scaleMax) * 100 : 0; return (
{label} - {current.toFixed(0)}/{target.toFixed(0)} {unit} + {current.toFixed(0)}/{target.toFixed(0)} + {showPlan ? ` · план ${targetPlan!.toFixed(0)}` : ""} {unit}
+ {showPlan ? ( +
+ ) : null} {overflowPct > 0 ? (
) : null} @@ -66,6 +78,15 @@ function ProgressBar({ ); } +function expectedSourceLabel(source: string, daysWithData: number, lookbackDays: number) { + if (source === "weekly_avg") { + return `среднее за ${lookbackDays} дн., ${daysWithData} дн. с данными`; + } + if (source === "baseline") return "baseline профиля"; + if (source === "defaults") return "по activity_level"; + return source; +} + export default function Fitness() { const [snapshot, setSnapshot] = useState(null); const [selectedDate, setSelectedDate] = useState(todayIso); @@ -168,7 +189,9 @@ export default function Fitness() { const totals = daySummary?.totals; const targets = daySummary?.targets; + const targetsExpected = daySummary?.targets_expected; const tdeeBreakdown = daySummary?.tdee_breakdown; + const tdeeExpected = daySummary?.tdee_expected; const workoutStats = snapshot?.workout_stats; const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0]; const isToday = selectedDate === todayIso(); @@ -248,14 +271,32 @@ export default function Fitness() { {tdeeBreakdown ? (

- TDEE: BMR {tdeeBreakdown.bmr} + NEAT {tdeeBreakdown.neat_kcal} + шаги{" "} + TDEE факт: BMR {tdeeBreakdown.bmr} + NEAT {tdeeBreakdown.neat_kcal} + шаги{" "} {tdeeBreakdown.steps_kcal} ({daySummary?.steps_total ?? tdeeBreakdown.steps}) + тренировки{" "} {tdeeBreakdown.workout_kcal} = {tdeeBreakdown.tdee} ккал

-

Цель ккал: {tdeeBreakdown.calorie_target}

+

Цель ккал (факт): {tdeeBreakdown.calorie_target}

+ {tdeeExpected ? ( + <> +

+ TDEE план: BMR {tdeeExpected.bmr} + NEAT {tdeeExpected.neat_kcal} + шаги{" "} + {tdeeExpected.steps_kcal} (~{tdeeExpected.steps}) + тренировки {tdeeExpected.workout_kcal} ={" "} + {tdeeExpected.tdee} ккал +

+

+ Цель ккал (план): {tdeeExpected.calorie_target} + {" · "} + {expectedSourceLabel( + tdeeExpected.source, + tdeeExpected.days_with_data, + tdeeExpected.lookback_days, + )} +

+ + ) : null} {!daySummary?.steps_total && !daySummary?.workouts?.length ? (

- Шаги и тренировки не внесены — TDEE = BMR + NEAT. Внесите данные через чат для точной цели. + Шаги и тренировки не внесены — факт = BMR + NEAT. План основан на средней активности за неделю.

) : null}
@@ -276,6 +317,7 @@ export default function Fitness() { label="Калории" current={totals.calories} target={targets.calories} + targetPlan={targetsExpected?.calories} unit="ккал" />