251 lines
7.9 KiB
Python
251 lines
7.9 KiB
Python
"""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,
|
||
}
|