193 lines
6.1 KiB
Python
193 lines
6.1 KiB
Python
"""Fitness gamification: streaks, cumulative energy balance vs factual TDEE, milestone medals."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
from datetime import date, datetime, timezone
|
||
from typing import Any
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.daily.metrics import DayMetricsService
|
||
from app.fitness.service import FitnessService
|
||
|
||
_CACHE_TTL_SEC = 300
|
||
_gamification_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||
|
||
|
||
def clear_gamification_cache() -> None:
|
||
_gamification_cache.clear()
|
||
|
||
|
||
def _cache_key(user_id: int, end: date) -> str:
|
||
return f"{user_id}:{end.isoformat()}"
|
||
|
||
|
||
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]:
|
||
end = end_day or datetime.now(timezone.utc).date()
|
||
key = _cache_key(user_id, end)
|
||
now = time.time()
|
||
cached = _gamification_cache.get(key)
|
||
if cached and now - cached[0] < _CACHE_TTL_SEC:
|
||
return cached[1]
|
||
|
||
fitness = FitnessService(db, user_id)
|
||
profile = fitness.get_profile() or {}
|
||
goal = str(profile.get("goal") or "lose")
|
||
weekly_workout_target = int(profile.get("weekly_workouts") or 3)
|
||
|
||
bundle = DayMetricsService(db, user_id).metrics_bundle(end)
|
||
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": bundle["meal_log_streak"],
|
||
"meal_log_best_streak": bundle["meal_log_best_streak"],
|
||
"plan_hit_streak": bundle["plan_hit_streak"],
|
||
"plan_hit_total": bundle["plan_hit_total"],
|
||
"plan_hit_week": bundle["plan_hit_week"],
|
||
"workout_streak": workout_streak,
|
||
"workouts_this_week": workouts_this_week,
|
||
"weekly_workout_target": weekly_workout_target,
|
||
"deficit_week": bundle["deficit_week"],
|
||
"deficit_month": bundle["deficit_month"],
|
||
"deficit_all": bundle["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,
|
||
}
|
||
)
|
||
|
||
if goal == "gain":
|
||
balance_label = "профицит"
|
||
elif goal == "maintain":
|
||
balance_label = "баланс"
|
||
else:
|
||
balance_label = "дефицит"
|
||
payload = {
|
||
"end_date": end.isoformat(),
|
||
"goal": goal,
|
||
"balance_label": balance_label,
|
||
"streaks": {
|
||
"meal_log": bundle["meal_log_streak"],
|
||
"meal_log_best": bundle["meal_log_best_streak"],
|
||
"plan_hit": bundle["plan_hit_streak"],
|
||
"workout": workout_streak,
|
||
},
|
||
"totals": {
|
||
"plan_hit_days": bundle["plan_hit_total"],
|
||
"plan_hit_week": bundle["plan_hit_week"],
|
||
},
|
||
"balance": {
|
||
"week": bundle["deficit_week"],
|
||
"month": bundle["deficit_month"],
|
||
"all": bundle["deficit_all"],
|
||
"label": balance_label,
|
||
},
|
||
"workouts": {
|
||
"count_week": workouts_this_week,
|
||
"weekly_target": weekly_workout_target,
|
||
"streak": workout_streak,
|
||
},
|
||
"medals": medals,
|
||
"metrics": metrics,
|
||
}
|
||
_gamification_cache[key] = (now, payload)
|
||
return payload
|