added RAG, Multiuser, TG bot
This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
"""Weekly fitness chart data and least-squares trend lines."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import BodyMetric, FoodLog, StepLog, WaterLog
|
||||
|
||||
METRIC_DEFS: dict[str, dict[str, str]] = {
|
||||
"weight_kg": {"label": "Вес", "unit": "кг"},
|
||||
"body_fat_pct": {"label": "Жир", "unit": "%"},
|
||||
"calories": {"label": "Калории", "unit": "ккал/день"},
|
||||
"protein_g": {"label": "Белок", "unit": "г/день"},
|
||||
"water_l": {"label": "Вода", "unit": "л/день"},
|
||||
"steps": {"label": "Шаги", "unit": "шаг/день"},
|
||||
}
|
||||
|
||||
|
||||
def week_start(day: date) -> date:
|
||||
return day - timedelta(days=day.weekday())
|
||||
|
||||
|
||||
def linear_regression(points: list[tuple[float, float]]) -> dict[str, float] | None:
|
||||
"""Ordinary least squares y = slope * x + intercept."""
|
||||
n = len(points)
|
||||
if n < 2:
|
||||
return None
|
||||
sum_x = sum(x for x, _ in points)
|
||||
sum_y = sum(y for _, y in points)
|
||||
sum_xx = sum(x * x for x, _ in points)
|
||||
sum_xy = sum(x * y for x, y in points)
|
||||
denom = n * sum_xx - sum_x * sum_x
|
||||
if abs(denom) < 1e-12:
|
||||
return None
|
||||
slope = (n * sum_xy - sum_x * sum_y) / denom
|
||||
intercept = (sum_y - slope * sum_x) / n
|
||||
return {"slope": slope, "intercept": intercept}
|
||||
|
||||
|
||||
def _avg(values: list[float]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
return sum(values) / len(values)
|
||||
|
||||
|
||||
def _last(values: list[tuple[date, float]]) -> float | None:
|
||||
if not values:
|
||||
return None
|
||||
values.sort(key=lambda item: item[0])
|
||||
return values[-1][1]
|
||||
|
||||
|
||||
def build_fitness_charts(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
weeks: int = 52,
|
||||
trend: bool = True,
|
||||
end_day: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
weeks = max(4, min(int(weeks), 52))
|
||||
end = end_day or datetime.now(timezone.utc).date()
|
||||
last_week_start = week_start(end)
|
||||
first_week_start = last_week_start - timedelta(weeks=weeks - 1)
|
||||
|
||||
range_start = datetime.combine(first_week_start, datetime.min.time(), tzinfo=timezone.utc)
|
||||
range_end = datetime.combine(end, datetime.max.time(), tzinfo=timezone.utc)
|
||||
|
||||
daily: dict[date, dict[str, float]] = defaultdict(lambda: {
|
||||
"calories": 0.0,
|
||||
"protein_g": 0.0,
|
||||
"fat_g": 0.0,
|
||||
"carbs_g": 0.0,
|
||||
"water_ml": 0.0,
|
||||
"steps": 0.0,
|
||||
})
|
||||
daily_flags: dict[date, set[str]] = defaultdict(set)
|
||||
|
||||
foods = db.scalars(
|
||||
select(FoodLog).where(
|
||||
FoodLog.user_id == user_id,
|
||||
FoodLog.logged_at >= range_start,
|
||||
FoodLog.logged_at <= range_end,
|
||||
)
|
||||
).all()
|
||||
for row in foods:
|
||||
d = row.logged_at.date()
|
||||
daily[d]["calories"] += row.calories
|
||||
daily[d]["protein_g"] += row.protein_g
|
||||
daily[d]["fat_g"] += row.fat_g
|
||||
daily[d]["carbs_g"] += row.carbs_g
|
||||
daily_flags[d].add("nutrition")
|
||||
|
||||
waters = db.scalars(
|
||||
select(WaterLog).where(
|
||||
WaterLog.user_id == user_id,
|
||||
WaterLog.logged_at >= range_start,
|
||||
WaterLog.logged_at <= range_end,
|
||||
)
|
||||
).all()
|
||||
for row in waters:
|
||||
d = row.logged_at.date()
|
||||
daily[d]["water_ml"] += float(row.amount_ml)
|
||||
daily_flags[d].add("water")
|
||||
|
||||
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:
|
||||
d = row.logged_at.date()
|
||||
daily[d]["steps"] += float(row.steps)
|
||||
daily_flags[d].add("steps")
|
||||
|
||||
body_rows = db.scalars(
|
||||
select(BodyMetric).where(
|
||||
BodyMetric.user_id == user_id,
|
||||
BodyMetric.recorded_at >= range_start,
|
||||
BodyMetric.recorded_at <= range_end,
|
||||
)
|
||||
).all()
|
||||
body_by_day: dict[date, list[tuple[date, float, float | None]]] = defaultdict(list)
|
||||
for row in body_rows:
|
||||
d = row.recorded_at.date()
|
||||
body_by_day[d].append((d, row.weight_kg, row.body_fat_pct))
|
||||
daily_flags[d].add("body")
|
||||
|
||||
week_slots: list[dict[str, Any]] = []
|
||||
cursor = first_week_start
|
||||
while cursor <= last_week_start:
|
||||
week_slots.append(
|
||||
{
|
||||
"week_start": cursor.isoformat(),
|
||||
"week_end": (cursor + timedelta(days=6)).isoformat(),
|
||||
}
|
||||
)
|
||||
cursor += timedelta(weeks=1)
|
||||
|
||||
days_with_data = len(daily_flags)
|
||||
weeks_with_data = 0
|
||||
|
||||
def rollup_week(metric: str) -> list[dict[str, Any]]:
|
||||
nonlocal weeks_with_data
|
||||
points: list[dict[str, Any]] = []
|
||||
local_weeks_with_data = 0
|
||||
|
||||
for idx, slot in enumerate(week_slots):
|
||||
ws = date.fromisoformat(slot["week_start"])
|
||||
we = date.fromisoformat(slot["week_end"])
|
||||
day_cursor = ws
|
||||
week_daily_values: list[float] = []
|
||||
week_body_weight: list[tuple[date, float]] = []
|
||||
week_body_fat: list[tuple[date, float]] = []
|
||||
|
||||
while day_cursor <= we:
|
||||
if day_cursor > end:
|
||||
break
|
||||
flags = daily_flags.get(day_cursor, set())
|
||||
totals = daily.get(day_cursor)
|
||||
if metric == "weight_kg":
|
||||
for _, w, _ in body_by_day.get(day_cursor, []):
|
||||
week_body_weight.append((day_cursor, w))
|
||||
elif metric == "body_fat_pct":
|
||||
for _, _, bf in body_by_day.get(day_cursor, []):
|
||||
if bf is not None:
|
||||
week_body_fat.append((day_cursor, bf))
|
||||
elif metric == "calories" and totals and "nutrition" in flags:
|
||||
week_daily_values.append(totals["calories"])
|
||||
elif metric == "protein_g" and totals and "nutrition" in flags:
|
||||
week_daily_values.append(totals["protein_g"])
|
||||
elif metric == "water_l" and totals and "water" in flags:
|
||||
week_daily_values.append(totals["water_ml"] / 1000.0)
|
||||
elif metric == "steps" and totals and "steps" in flags:
|
||||
week_daily_values.append(totals["steps"])
|
||||
day_cursor += timedelta(days=1)
|
||||
|
||||
value: float | None
|
||||
days_in_week = 0
|
||||
if metric == "weight_kg":
|
||||
value = _last(week_body_weight)
|
||||
days_in_week = len(week_body_weight)
|
||||
elif metric == "body_fat_pct":
|
||||
value = _last(week_body_fat)
|
||||
days_in_week = len(week_body_fat)
|
||||
else:
|
||||
value = _avg(week_daily_values)
|
||||
days_in_week = len(week_daily_values)
|
||||
|
||||
has_data = value is not None
|
||||
if has_data:
|
||||
local_weeks_with_data += 1
|
||||
|
||||
points.append(
|
||||
{
|
||||
"index": idx,
|
||||
"week_start": slot["week_start"],
|
||||
"week_end": slot["week_end"],
|
||||
"value": round(value, 2) if value is not None else None,
|
||||
"days_with_data": days_in_week,
|
||||
"has_data": has_data,
|
||||
}
|
||||
)
|
||||
|
||||
weeks_with_data = max(weeks_with_data, local_weeks_with_data)
|
||||
return points
|
||||
|
||||
series: dict[str, Any] = {}
|
||||
for key, meta in METRIC_DEFS.items():
|
||||
points = rollup_week(key)
|
||||
reg_points = [(float(p["index"]), float(p["value"])) for p in points if p["has_data"] and p["value"] is not None]
|
||||
trend_payload: dict[str, Any] | None = None
|
||||
if trend and len(reg_points) >= 2:
|
||||
fit = linear_regression(reg_points)
|
||||
if fit:
|
||||
line = [
|
||||
{
|
||||
"index": p["index"],
|
||||
"week_start": p["week_start"],
|
||||
"value": round(fit["slope"] * p["index"] + fit["intercept"], 2),
|
||||
}
|
||||
for p in points
|
||||
]
|
||||
trend_payload = {
|
||||
"slope_per_week": round(fit["slope"], 4),
|
||||
"intercept": round(fit["intercept"], 2),
|
||||
"points_with_data": len(reg_points),
|
||||
"line": line,
|
||||
}
|
||||
series[key] = {
|
||||
"key": key,
|
||||
"label": meta["label"],
|
||||
"unit": meta["unit"],
|
||||
"points": points,
|
||||
"trend": trend_payload,
|
||||
"data_points": sum(1 for p in points if p["has_data"]),
|
||||
}
|
||||
|
||||
use_daily = days_with_data > 0 and days_with_data <= 14 and weeks_with_data <= 2
|
||||
daily_series: dict[str, Any] | None = None
|
||||
if use_daily:
|
||||
daily_series = _build_daily_series(
|
||||
daily,
|
||||
daily_flags,
|
||||
body_by_day,
|
||||
end,
|
||||
trend=trend,
|
||||
lookback_days=min(30, max(days_with_data, 7)),
|
||||
)
|
||||
|
||||
return {
|
||||
"end_date": end.isoformat(),
|
||||
"weeks": weeks,
|
||||
"granularity": "day" if use_daily else "week",
|
||||
"first_week_start": first_week_start.isoformat(),
|
||||
"last_week_start": last_week_start.isoformat(),
|
||||
"days_with_data": days_with_data,
|
||||
"weeks_with_data": weeks_with_data,
|
||||
"series": series,
|
||||
"daily_series": daily_series,
|
||||
}
|
||||
|
||||
|
||||
def _build_daily_series(
|
||||
daily: dict[date, dict[str, float]],
|
||||
daily_flags: dict[date, set[str]],
|
||||
body_by_day: dict[date, list[tuple[date, float, float | None]]],
|
||||
end: date,
|
||||
*,
|
||||
trend: bool,
|
||||
lookback_days: int,
|
||||
) -> dict[str, Any]:
|
||||
start = end - timedelta(days=lookback_days - 1)
|
||||
day_points: list[date] = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
day_points.append(cursor)
|
||||
cursor += timedelta(days=1)
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for key, meta in METRIC_DEFS.items():
|
||||
points: list[dict[str, Any]] = []
|
||||
for idx, d in enumerate(day_points):
|
||||
value: float | None = None
|
||||
has_data = False
|
||||
if key == "weight_kg":
|
||||
body = body_by_day.get(d, [])
|
||||
pairs = [(x, w) for x, w, _ in body]
|
||||
value = _last(pairs) if pairs else None
|
||||
has_data = value is not None
|
||||
elif key == "body_fat_pct":
|
||||
fat_vals = [(x, bf) for x, _, bf in body_by_day.get(d, []) if bf is not None]
|
||||
value = _last(fat_vals) if fat_vals else None
|
||||
has_data = value is not None
|
||||
else:
|
||||
flags = daily_flags.get(d, set())
|
||||
totals = daily.get(d)
|
||||
if key == "calories" and totals and "nutrition" in flags:
|
||||
value = totals["calories"]
|
||||
has_data = True
|
||||
elif key == "protein_g" and totals and "nutrition" in flags:
|
||||
value = totals["protein_g"]
|
||||
has_data = True
|
||||
elif key == "water_l" and totals and "water" in flags:
|
||||
value = totals["water_ml"] / 1000.0
|
||||
has_data = True
|
||||
elif key == "steps" and totals and "steps" in flags:
|
||||
value = totals["steps"]
|
||||
has_data = True
|
||||
|
||||
points.append(
|
||||
{
|
||||
"index": idx,
|
||||
"date": d.isoformat(),
|
||||
"value": round(value, 2) if value is not None else None,
|
||||
"has_data": has_data,
|
||||
}
|
||||
)
|
||||
|
||||
reg_points = [(float(p["index"]), float(p["value"])) for p in points if p["has_data"] and p["value"] is not None]
|
||||
trend_payload: dict[str, Any] | None = None
|
||||
if trend and len(reg_points) >= 2:
|
||||
fit = linear_regression(reg_points)
|
||||
if fit:
|
||||
trend_payload = {
|
||||
"slope_per_day": round(fit["slope"], 4),
|
||||
"intercept": round(fit["intercept"], 2),
|
||||
"points_with_data": len(reg_points),
|
||||
"line": [
|
||||
{
|
||||
"index": p["index"],
|
||||
"date": p["date"],
|
||||
"value": round(fit["slope"] * p["index"] + fit["intercept"], 2),
|
||||
}
|
||||
for p in points
|
||||
],
|
||||
}
|
||||
|
||||
result[key] = {
|
||||
"key": key,
|
||||
"label": meta["label"],
|
||||
"unit": meta["unit"],
|
||||
"points": points,
|
||||
"trend": trend_payload,
|
||||
"data_points": sum(1 for p in points if p["has_data"]),
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -625,6 +625,23 @@ class FitnessService:
|
||||
"reminders": self.list_reminders(),
|
||||
}
|
||||
|
||||
def get_charts(
|
||||
self,
|
||||
*,
|
||||
weeks: int = 52,
|
||||
trend: bool = True,
|
||||
end_day: date | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from app.fitness.charts import build_fitness_charts
|
||||
|
||||
return build_fitness_charts(
|
||||
self.db,
|
||||
self.user_id,
|
||||
weeks=weeks,
|
||||
trend=trend,
|
||||
end_day=end_day,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _food_to_dict(row: FoodLog) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user