Files
Home_assistant/backend/app/fitness/gamification.py
T
2026-07-01 08:21:48 +03:00

251 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Fitness gamification: streaks, cumulative deficit, milestone medals."""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.fitness.service import FitnessService
def _plan_hit(summary: dict[str, Any], goal: str) -> bool:
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
actual = float(totals.get("calories", 0))
target = float(targets.get("calories", 0))
if target <= 0:
return False
meals = summary.get("meals") or []
if not meals:
return False
if goal == "lose":
return actual <= target * 1.05
if goal == "gain":
return actual >= target * 0.95
return abs(actual - target) <= target * 0.1
def _signed_balance(summary: dict[str, Any], goal: str) -> float:
totals = summary.get("totals") or {}
targets = summary.get("targets") or {}
actual = float(totals.get("calories", 0))
target = float(targets.get("calories", 0))
if goal == "gain":
return actual - target
return target - actual
def _streak_from_flags(flags_by_day: dict[date, bool], end: date) -> int:
streak = 0
cursor = end
while flags_by_day.get(cursor):
streak += 1
cursor -= timedelta(days=1)
return streak
def _best_streak(flags_by_day: dict[date, bool]) -> int:
if not flags_by_day:
return 0
days = sorted(flags_by_day.keys())
best = 0
run = 0
prev: date | None = None
for d in days:
if not flags_by_day[d]:
run = 0
prev = d
continue
if prev and (d - prev).days == 1:
run += 1
else:
run = 1
best = max(best, run)
prev = d
return best
MEDAL_DEFS: list[dict[str, Any]] = [
{
"id": "meal_streak_3",
"title": "Трекер еды",
"description": "3 дня подряд с логом питания",
"icon": "🍽",
"metric": "meal_log_streak",
"target": 3,
},
{
"id": "meal_streak_7",
"title": "Неделя еды",
"description": "7 дней подряд с логом питания",
"icon": "🥗",
"metric": "meal_log_streak",
"target": 7,
},
{
"id": "meal_streak_30",
"title": "Дисциплина",
"description": "30 дней подряд с логом питания",
"icon": "🏆",
"metric": "meal_log_best_streak",
"target": 30,
},
{
"id": "plan_hit_5_week",
"title": "План недели",
"description": "5 дней за неделю в целевых калориях",
"icon": "🎯",
"metric": "plan_hit_week",
"target": 5,
},
{
"id": "plan_hit_30_total",
"title": "Стабильность",
"description": "30 дней всего в плане по калориям",
"icon": "📈",
"metric": "plan_hit_total",
"target": 30,
},
{
"id": "workout_streak_3",
"title": "В движении",
"description": "3 дня подряд с тренировкой",
"icon": "💪",
"metric": "workout_streak",
"target": 3,
},
{
"id": "workout_week_goal",
"title": "Неделя силы",
"description": "Выполнена недельная цель тренировок",
"icon": "🏋",
"metric": "workouts_this_week",
"target_metric": "weekly_workout_target",
},
{
"id": "deficit_week_3500",
"title": "Минус кило",
"description": "Дефицит ~3500 ккал за неделю",
"icon": "🔥",
"metric": "deficit_week",
"target": 3500,
},
{
"id": "deficit_month_7000",
"title": "Марафон",
"description": "Дефицит 7000 ккал за месяц",
"icon": "⭐",
"metric": "deficit_month",
"target": 7000,
},
]
def build_gamification(db: Session, user_id: int, *, end_day: date | None = None) -> dict[str, Any]:
fitness = FitnessService(db, user_id)
end = end_day or datetime.now(timezone.utc).date()
profile = fitness.get_profile() or {}
goal = str(profile.get("goal") or "lose")
weekly_workout_target = int(profile.get("weekly_workouts") or 3)
first_activity = end - timedelta(days=365)
meal_flags: dict[date, bool] = {}
plan_flags: dict[date, bool] = {}
balance_by_day: dict[date, float] = {}
cursor = first_activity
while cursor <= end:
summary = fitness.get_daily_summary(cursor)
meals = summary.get("meals") or []
meal_flags[cursor] = len(meals) > 0
plan_flags[cursor] = _plan_hit(summary, goal)
if meals:
balance_by_day[cursor] = _signed_balance(summary, goal)
cursor += timedelta(days=1)
meal_log_streak = _streak_from_flags(meal_flags, end)
meal_log_best_streak = _best_streak(meal_flags)
plan_hit_streak = _streak_from_flags(plan_flags, end)
plan_hit_total = sum(1 for hit in plan_flags.values() if hit)
week_start = end - timedelta(days=6)
plan_hit_week = sum(1 for d, hit in plan_flags.items() if hit and d >= week_start)
deficit_week = round(sum(v for d, v in balance_by_day.items() if d >= week_start))
month_start = end - timedelta(days=29)
deficit_month = round(sum(v for d, v in balance_by_day.items() if d >= month_start))
deficit_all = round(sum(balance_by_day.values()))
workout_stats = fitness.get_workout_stats(days=7, end_day=end)
workout_streak = int(workout_stats.get("streak") or 0)
workouts_this_week = int(workout_stats.get("count") or 0)
metrics: dict[str, int] = {
"meal_log_streak": meal_log_streak,
"meal_log_best_streak": meal_log_best_streak,
"plan_hit_streak": plan_hit_streak,
"plan_hit_total": plan_hit_total,
"plan_hit_week": plan_hit_week,
"workout_streak": workout_streak,
"workouts_this_week": workouts_this_week,
"weekly_workout_target": weekly_workout_target,
"deficit_week": deficit_week,
"deficit_month": deficit_month,
"deficit_all": deficit_all,
}
medals: list[dict[str, Any]] = []
for spec in MEDAL_DEFS:
target_metric = spec.get("target_metric")
if target_metric:
target = int(metrics.get(target_metric, 0))
progress = int(metrics.get(spec["metric"], 0))
earned = progress >= target and target > 0
else:
target = int(spec["target"])
progress = int(metrics.get(spec["metric"], 0))
earned = progress >= target
medals.append(
{
"id": spec["id"],
"title": spec["title"],
"description": spec["description"],
"icon": spec["icon"],
"earned": earned,
"progress": progress,
"target": target,
}
)
balance_label = "дефицит" if goal != "gain" else "профицит"
return {
"end_date": end.isoformat(),
"goal": goal,
"balance_label": balance_label,
"streaks": {
"meal_log": meal_log_streak,
"meal_log_best": meal_log_best_streak,
"plan_hit": plan_hit_streak,
"workout": workout_streak,
},
"totals": {
"plan_hit_days": plan_hit_total,
"plan_hit_week": plan_hit_week,
},
"balance": {
"week": deficit_week,
"month": deficit_month,
"all": deficit_all,
"label": balance_label,
},
"workouts": {
"count_week": workouts_this_week,
"weekly_target": weekly_workout_target,
"streak": workout_streak,
},
"medals": medals,
"metrics": metrics,
}