"""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, }