fixed dynamic TDEE

This commit is contained in:
2026-06-16 08:04:15 +03:00
parent a3f01cd850
commit 0f2827030b
11 changed files with 603 additions and 18 deletions
+4
View File
@@ -24,6 +24,10 @@ class ProfileUpdate(BaseModel):
goal: str | None = None goal: str | None = None
target_weight_kg: float | None = None target_weight_kg: float | None = None
neat_base_kcal: float | None = Field(default=None, ge=200, le=300) 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): class MealCreate(BaseModel):
+107
View File
@@ -21,6 +21,17 @@ PROTEIN_G_PER_KG = {
} }
FAT_G_PER_KG = 1.0 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: def bmr_mifflin(*, sex: str, weight_kg: float, height_cm: float, age: int) -> float:
base = 10 * weight_kg + 6.25 * height_cm - 5 * age 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"], "carbs_g": daily["carbs_g"],
"water_l": daily["water_l"], "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),
}
+153 -1
View File
@@ -9,12 +9,20 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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]] = { METRIC_DEFS: dict[str, dict[str, str]] = {
"weight_kg": {"label": "Вес", "unit": "кг"}, "weight_kg": {"label": "Вес", "unit": "кг"},
"body_fat_pct": {"label": "Жир", "unit": "%"}, "body_fat_pct": {"label": "Жир", "unit": "%"},
"calories": {"label": "Калории", "unit": "ккал/день"}, "calories": {"label": "Калории", "unit": "ккал/день"},
"tdee": {"label": "TDEE факт", "unit": "ккал/день"},
"tdee_expected": {"label": "TDEE план", "unit": "ккал/день"},
"protein_g": {"label": "Белок", "unit": "г/день"}, "protein_g": {"label": "Белок", "unit": "г/день"},
"water_l": {"label": "Вода", "unit": "л/день"}, "water_l": {"label": "Вода", "unit": "л/день"},
"steps": {"label": "Шаги", "unit": "шаг/день"}, "steps": {"label": "Шаги", "unit": "шаг/день"},
@@ -55,6 +63,106 @@ def _last(values: list[tuple[date, float]]) -> float | None:
return values[-1][1] 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( def build_fitness_charts(
db: Session, db: Session,
user_id: int, 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_start = datetime.combine(first_week_start, datetime.min.time(), tzinfo=timezone.utc)
range_end = datetime.combine(end, datetime.max.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: { daily: dict[date, dict[str, float]] = defaultdict(lambda: {
"calories": 0.0, "calories": 0.0,
"protein_g": 0.0, "protein_g": 0.0,
@@ -180,6 +307,14 @@ def build_fitness_charts(
week_daily_values.append(totals["water_ml"] / 1000.0) week_daily_values.append(totals["water_ml"] / 1000.0)
elif metric == "steps" and totals and "steps" in flags: elif metric == "steps" and totals and "steps" in flags:
week_daily_values.append(totals["steps"]) 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) day_cursor += timedelta(days=1)
value: float | None value: float | None
@@ -253,6 +388,9 @@ def build_fitness_charts(
end, end,
trend=trend, trend=trend,
lookback_days=min(30, max(days_with_data, 7)), 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 { return {
@@ -276,6 +414,9 @@ def _build_daily_series(
*, *,
trend: bool, trend: bool,
lookback_days: int, 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]: ) -> dict[str, Any]:
start = end - timedelta(days=lookback_days - 1) start = end - timedelta(days=lookback_days - 1)
day_points: list[date] = [] day_points: list[date] = []
@@ -314,6 +455,17 @@ def _build_daily_series(
elif key == "steps" and totals and "steps" in flags: elif key == "steps" and totals and "steps" in flags:
value = totals["steps"] value = totals["steps"]
has_data = True 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( points.append(
{ {
+39 -8
View File
@@ -36,30 +36,60 @@ def format_fitness_context(snapshot: dict[str, Any]) -> str:
totals = today.get("totals") or {} totals = today.get("totals") or {}
targets = today.get("targets") or {} targets = today.get("targets") or {}
breakdown = today.get("tdee_breakdown") 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 steps_total = today.get("steps_total") or 0
water_l = totals.get("water_ml", 0) / 1000 water_l = totals.get("water_ml", 0) / 1000
water_target = targets.get("water_ml", 2500) / 1000 water_target = targets.get("water_ml", 2500) / 1000
if breakdown: if breakdown:
lines.append( 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('steps_kcal')} ({steps_total} шаг.) + "
f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → " f"тренировки {breakdown.get('workout_kcal')} = {breakdown.get('tdee')} ккал → "
f"цель {breakdown.get('calorie_target')} ккал" f"цель {breakdown.get('calorie_target')} ккал"
) )
elif steps_total == 0: elif steps_total == 0:
lines.append( lines.append(
"Шаги/тренировки не внесены — TDEE считается как BMR + NEAT. " "Шаги/тренировки не внесены — TDEE факт = BMR + NEAT. "
"log_steps / log_workout для точной дневной цели." "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("")
lines.append( if targets_expected and targets_expected.get("carbs_g") != targets.get("carbs_g"):
f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал · " lines.append(
f"Б {totals.get('protein_g', 0):.0f}/{targets.get('protein_g', 0):.0f} · " f"Съедено: {totals.get('calories', 0):.0f}/{targets.get('calories', 0):.0f} ккал "
f"Ж {totals.get('fat_g', 0):.0f}/{targets.get('fat_g', 0):.0f} · " f"(план {targets_expected.get('calories', 0):.0f}) · "
f"У {totals.get('carbs_g', 0):.0f}/{targets.get('carbs_g', 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} л") lines.append(f"Вода: {water_l:.1f}/{water_target:.1f} л")
workouts = today.get("workouts") or [] 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, " "calc_body_composition (расчёт без записи), get_fitness_summary (date/days_ago), get_fitness_history, "
"set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. " "set_fitness_profile, calc_fitness_targets, lookup_food, lookup_exercise. "
"TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. " "TDEE = BMR + NEAT (200 ккал) + шаги + тренировки. "
"TDEE факт — по залогированной активности; TDEE план — среднее за неделю (или baseline) для утреннего бюджета углеводов. "
"БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. " "БЖУ: белок 2.2 г/кг (сушка) / 1.8 г/кг (поддержание/набор), жир 1.0 г/кг, угли — остаток от целевых ккал. "
"Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. " "Скриншоты Mi Fitness: vision уже извлекла данные в блок [Скриншот] с fitness_hints — используй их, не говори что не видишь картинку. "
"Еда — оценка LLM (≈)." "Еда — оценка LLM (≈)."
+118 -3
View File
@@ -1,4 +1,5 @@
import json import json
from collections import defaultdict
from datetime import date, datetime, time, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
from typing import Any 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.activity_budget import estimate_workout_active_kcal
from app.fitness.calculators import ( from app.fitness.calculators import (
compute_daily_targets, compute_daily_targets,
compute_expected_targets,
compute_targets, compute_targets,
one_rep_max, one_rep_max,
targets_to_api, targets_to_api,
tdee_breakdown_to_api, tdee_breakdown_to_api,
tdee_expected_to_api,
EXPECTED_LOOKBACK_DAYS,
) )
from app.fitness.body_composition import compute_body_composition from app.fitness.body_composition import compute_body_composition
@@ -54,6 +58,10 @@ class FitnessService:
"weight_kg": row.weight_kg, "weight_kg": row.weight_kg,
"goal": row.goal, "goal": row.goal,
"neat_base_kcal": row.neat_base_kcal, "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]: def _profile_to_dict(self, row: FitnessProfile) -> dict[str, Any]:
@@ -66,6 +74,10 @@ class FitnessService:
"goal": row.goal, "goal": row.goal,
"target_weight_kg": row.target_weight_kg, "target_weight_kg": row.target_weight_kg,
"neat_base_kcal": row.neat_base_kcal, "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, "calorie_target": row.calorie_target,
"protein_g": row.protein_g, "protein_g": row.protein_g,
"fat_g": row.fat_g, "fat_g": row.fat_g,
@@ -86,6 +98,8 @@ class FitnessService:
for key in ( for key in (
"sex", "age", "height_cm", "weight_kg", "sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "neat_base_kcal", "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: if key in updates and updates[key] is not None:
setattr(row, key, updates[key]) setattr(row, key, updates[key])
@@ -188,8 +202,95 @@ class FitnessService:
"sex": "male", "sex": "male",
"goal": "maintain", "goal": "maintain",
"neat_base_kcal": 200, "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]: def _day_bounds(self, day: date | None = None) -> tuple[datetime, datetime]:
d = day or datetime.now(timezone.utc).date() 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]: def get_daily_summary(self, day: date | None = None) -> dict[str, Any]:
start, end = self._day_bounds(day) start, end = self._day_bounds(day)
profile_row = self.get_profile() profile_row = self._get_profile_row()
profile = self._profile_for_budget(profile_row) profile_dict = self.get_profile()
profile = self._profile_for_budget(profile_dict)
foods = self.db.scalars( foods = self.db.scalars(
select(FoodLog) select(FoodLog)
@@ -241,13 +343,24 @@ class FitnessService:
workouts=workouts, workouts=workouts,
) )
targets = targets_to_api(daily) 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 { return {
"date": (day or datetime.now(timezone.utc).date()).isoformat(), "date": target_day.isoformat(),
"profile_configured": profile_row is not None, "profile_configured": profile_row is not None,
"totals": totals, "totals": totals,
"targets": targets, "targets": targets,
"targets_expected": targets_expected,
"tdee_breakdown": tdee_breakdown_to_api(daily), "tdee_breakdown": tdee_breakdown_to_api(daily),
"tdee_expected": tdee_expected,
"meals": [self._food_to_dict(f) for f in foods], "meals": [self._food_to_dict(f) for f in foods],
"water": [self._water_to_dict(w) for w in waters], "water": [self._water_to_dict(w) for w in waters],
"workouts": workouts, "workouts": workouts,
@@ -603,11 +716,13 @@ class FitnessService:
"has_data": has_data, "has_data": has_data,
"totals": totals, "totals": totals,
"targets": full["targets"], "targets": full["targets"],
"targets_expected": full.get("targets_expected"),
"meal_count": len(full["meals"]), "meal_count": len(full["meals"]),
"workout_count": len(full["workouts"]), "workout_count": len(full["workouts"]),
} }
if include_tdee_breakdown: if include_tdee_breakdown:
item["tdee_breakdown"] = full.get("tdee_breakdown") item["tdee_breakdown"] = full.get("tdee_breakdown")
item["tdee_expected"] = full.get("tdee_expected")
summaries.append(item) summaries.append(item)
return { return {
+18
View File
@@ -350,6 +350,22 @@ TOOL_DEFINITIONS: list[dict[str, Any]] = [
"type": "number", "type": "number",
"description": "NEAT-база 200–300 ккал, по умолчанию 200", "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": [], "required": [],
}, },
@@ -923,6 +939,8 @@ async def execute_tool(
for k in ( for k in (
"sex", "age", "height_cm", "weight_kg", "sex", "age", "height_cm", "weight_kg",
"goal", "target_weight_kg", "neat_base_kcal", "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 if k in arguments and arguments[k] is not None
} }
+80
View File
@@ -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()
+14
View File
@@ -293,6 +293,12 @@ export interface FitnessTdeeBreakdown {
steps: number; steps: number;
} }
export interface FitnessTdeeExpected extends FitnessTdeeBreakdown {
source: "weekly_avg" | "baseline" | "defaults";
lookback_days: number;
days_with_data: number;
}
export interface FitnessTargets { export interface FitnessTargets {
calories: number; calories: number;
protein_g: number; protein_g: number;
@@ -338,6 +344,10 @@ export interface FitnessProfile {
goal?: string; goal?: string;
target_weight_kg?: number | null; target_weight_kg?: number | null;
neat_base_kcal?: number; neat_base_kcal?: number;
activity_level?: string;
weekly_workouts?: number;
baseline_steps?: number | null;
baseline_workout_kcal?: number | null;
calorie_target?: number; calorie_target?: number;
protein_g?: number; protein_g?: number;
fat_g?: number; fat_g?: number;
@@ -387,7 +397,9 @@ export interface FitnessDailySummary {
steps?: number; steps?: number;
}; };
targets: FitnessTargets; targets: FitnessTargets;
targets_expected?: FitnessTargets;
tdee_breakdown?: FitnessTdeeBreakdown; tdee_breakdown?: FitnessTdeeBreakdown;
tdee_expected?: FitnessTdeeExpected;
steps?: StepLogItem[]; steps?: StepLogItem[];
steps_total?: number; steps_total?: number;
meals: FoodLogItem[]; meals: FoodLogItem[];
@@ -434,7 +446,9 @@ export interface FitnessDayOverview {
has_data: boolean; has_data: boolean;
totals: FitnessDailySummary["totals"]; totals: FitnessDailySummary["totals"];
targets: FitnessDailySummary["targets"]; targets: FitnessDailySummary["targets"];
targets_expected?: FitnessTargets;
tdee_breakdown?: FitnessTdeeBreakdown; tdee_breakdown?: FitnessTdeeBreakdown;
tdee_expected?: FitnessTdeeExpected;
meal_count: number; meal_count: number;
workout_count: number; workout_count: number;
} }
+10 -1
View File
@@ -2,7 +2,16 @@ import { useMemo } from "react";
import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client"; import { FitnessChartPoint, FitnessChartSeries, FitnessChartsResponse } from "../api/client";
import "./FitnessCharts.css"; 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 { interface MetricChartProps {
series: FitnessChartSeries; series: FitnessChartSeries;
+12
View File
@@ -385,6 +385,18 @@
border-radius: 0 4px 4px 0; 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 { .fitness-activity-block {
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
font-size: 0.85rem; font-size: 0.85rem;
+48 -5
View File
@@ -38,26 +38,38 @@ function ProgressBar({
label, label,
current, current,
target, target,
targetPlan,
unit, unit,
}: { }: {
label: string; label: string;
current: number; current: number;
target: number; target: number;
targetPlan?: number;
unit: string; 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 fillPct = (Math.min(current, scaleMax) / scaleMax) * 100;
const overflowPct = current > target ? ((current - target) / scaleMax) * 100 : 0; const overflowPct = current > target ? ((current - target) / scaleMax) * 100 : 0;
const planPct = showPlan ? (Math.min(targetPlan!, scaleMax) / scaleMax) * 100 : 0;
return ( return (
<div className="fitness-progress"> <div className="fitness-progress">
<div className="fitness-progress-header"> <div className="fitness-progress-header">
<span>{label}</span> <span>{label}</span>
<span> <span>
{current.toFixed(0)}/{target.toFixed(0)} {unit} {current.toFixed(0)}/{target.toFixed(0)}
{showPlan ? ` · план ${targetPlan!.toFixed(0)}` : ""} {unit}
</span> </span>
</div> </div>
<div className="fitness-progress-track fitness-progress-track-v2"> <div className="fitness-progress-track fitness-progress-track-v2">
<div className="fitness-progress-fill" style={{ width: `${fillPct}%` }} /> <div className="fitness-progress-fill" style={{ width: `${fillPct}%` }} />
{showPlan ? (
<div
className="fitness-progress-plan-marker"
style={{ left: `${planPct}%` }}
title={`План: ${targetPlan!.toFixed(0)} ${unit}`}
/>
) : null}
{overflowPct > 0 ? ( {overflowPct > 0 ? (
<div className="fitness-progress-overflow" style={{ width: `${overflowPct}%` }} /> <div className="fitness-progress-overflow" style={{ width: `${overflowPct}%` }} />
) : null} ) : 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() { export default function Fitness() {
const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null); const [snapshot, setSnapshot] = useState<FitnessSnapshot | null>(null);
const [selectedDate, setSelectedDate] = useState(todayIso); const [selectedDate, setSelectedDate] = useState(todayIso);
@@ -168,7 +189,9 @@ export default function Fitness() {
const totals = daySummary?.totals; const totals = daySummary?.totals;
const targets = daySummary?.targets; const targets = daySummary?.targets;
const targetsExpected = daySummary?.targets_expected;
const tdeeBreakdown = daySummary?.tdee_breakdown; const tdeeBreakdown = daySummary?.tdee_breakdown;
const tdeeExpected = daySummary?.tdee_expected;
const workoutStats = snapshot?.workout_stats; const workoutStats = snapshot?.workout_stats;
const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0]; const latestMetric: BodyMetric | undefined = snapshot?.body_metrics?.[0];
const isToday = selectedDate === todayIso(); const isToday = selectedDate === todayIso();
@@ -248,14 +271,32 @@ export default function Fitness() {
{tdeeBreakdown ? ( {tdeeBreakdown ? (
<div className="fitness-activity-block"> <div className="fitness-activity-block">
<p> <p>
TDEE: BMR {tdeeBreakdown.bmr} + NEAT {tdeeBreakdown.neat_kcal} + шаги{" "} <strong>TDEE факт:</strong> BMR {tdeeBreakdown.bmr} + NEAT {tdeeBreakdown.neat_kcal} + шаги{" "}
{tdeeBreakdown.steps_kcal} ({daySummary?.steps_total ?? tdeeBreakdown.steps}) + тренировки{" "} {tdeeBreakdown.steps_kcal} ({daySummary?.steps_total ?? tdeeBreakdown.steps}) + тренировки{" "}
{tdeeBreakdown.workout_kcal} = {tdeeBreakdown.tdee} ккал {tdeeBreakdown.workout_kcal} = {tdeeBreakdown.tdee} ккал
</p> </p>
<p>Цель ккал: {tdeeBreakdown.calorie_target}</p> <p>Цель ккал (факт): {tdeeBreakdown.calorie_target}</p>
{tdeeExpected ? (
<>
<p>
<strong>TDEE план:</strong> BMR {tdeeExpected.bmr} + NEAT {tdeeExpected.neat_kcal} + шаги{" "}
{tdeeExpected.steps_kcal} (~{tdeeExpected.steps}) + тренировки {tdeeExpected.workout_kcal} ={" "}
{tdeeExpected.tdee} ккал
</p>
<p>
Цель ккал (план): {tdeeExpected.calorie_target}
{" · "}
{expectedSourceLabel(
tdeeExpected.source,
tdeeExpected.days_with_data,
tdeeExpected.lookback_days,
)}
</p>
</>
) : null}
{!daySummary?.steps_total && !daySummary?.workouts?.length ? ( {!daySummary?.steps_total && !daySummary?.workouts?.length ? (
<p className="fitness-hint"> <p className="fitness-hint">
Шаги и тренировки не внесены TDEE = BMR + NEAT. Внесите данные через чат для точной цели. Шаги и тренировки не внесены факт = BMR + NEAT. План основан на средней активности за неделю.
</p> </p>
) : null} ) : null}
</div> </div>
@@ -276,6 +317,7 @@ export default function Fitness() {
label="Калории" label="Калории"
current={totals.calories} current={totals.calories}
target={targets.calories} target={targets.calories}
targetPlan={targetsExpected?.calories}
unit="ккал" unit="ккал"
/> />
<ProgressBar <ProgressBar
@@ -294,6 +336,7 @@ export default function Fitness() {
label="Углеводы" label="Углеводы"
current={totals.carbs_g} current={totals.carbs_g}
target={targets.carbs_g} target={targets.carbs_g}
targetPlan={targetsExpected?.carbs_g}
unit="г" unit="г"
/> />
<ProgressBar <ProgressBar