fixed dynamic TDEE
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 (≈)."
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user