Spaces:
Running
Running
| # app/api/plans.py | |
| """ | |
| Daily plan, task completion, skip, history, and behavior endpoints. | |
| Production-hardened v2.1.0. | |
| Endpoints: | |
| GET /api/plan β get today's adaptive plan | |
| POST /api/complete-task β mark task complete (XP + streak + RL update) | |
| POST /api/skip-task β explicit skip (negative RL signal) | |
| POST /api/update-state β update behavioral state | |
| GET /api/behavior β get current RL behavior signal | |
| GET /api/lesson/{topic} β AI-generated lesson (cached) | |
| GET /api/stats β user progress analytics | |
| GET /api/stats/{goal_id} β per-goal analytics | |
| GET /api/plan/history β past plans (last N days) | |
| """ | |
| import logging | |
| from datetime import date, datetime, timezone | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, Query, status | |
| from sqlalchemy import func | |
| from sqlalchemy.orm import Session | |
| from app.core.database import get_db | |
| from app.core.security import get_current_user | |
| from app.models.goal import Goal | |
| from app.models.progress import Progress | |
| from app.models.user import User | |
| from app.schemas.goals import ( | |
| CompleteTaskRequest, | |
| DailyPlanResponse, | |
| DayCompleteResponse, | |
| PlanHistoryResponse, | |
| PlanHistoryEntry, | |
| SkipTaskRequest, | |
| UpdateStateRequest, | |
| ) | |
| from app.services.notifications import get_pending_notifications, goal_completion_signal | |
| from app.services.planner import ( | |
| award_xp, | |
| generate_daily_plan, | |
| generate_unified_daily_plan, | |
| reschedule_skipped, | |
| update_streak, | |
| ) | |
| from app.services.day_closer import close_day_for_user, get_day_status | |
| from app.services.rl_engine import get_stable_signal, refresh_signal, update_reward, reset_daily_state, _compute_historical_features | |
| from app.services.llm_service import generate_lesson_text | |
| logger = logging.getLogger("hale.api.plans") | |
| router = APIRouter(prefix="/api", tags=["Plans & Progress"]) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Helpers | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _get_primary_goal(goal_id: Optional[int], user: User, db: Session) -> Goal: | |
| """Fetch the user's primary active goal or raise 404.""" | |
| if goal_id: | |
| goal = db.query(Goal).filter( | |
| Goal.id == goal_id, | |
| Goal.user_id == user.user_id, | |
| Goal.is_active == True, | |
| ).first() | |
| else: | |
| goal = ( | |
| db.query(Goal) | |
| .filter( | |
| Goal.user_id == user.user_id, | |
| Goal.is_active == True, | |
| Goal.is_completed == False, | |
| ) | |
| .order_by(Goal.priority.asc()) | |
| .first() | |
| ) | |
| if not goal: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail="No active goal found. Create a goal first via POST /api/goals/", | |
| ) | |
| return goal | |
| def _get_extra_goals(primary_id: int, user: User, db: Session, limit: int = 2) -> list[Goal]: | |
| """Fetch secondary active goals for multi-goal plan interleaving.""" | |
| return ( | |
| db.query(Goal) | |
| .filter( | |
| Goal.user_id == user.user_id, | |
| Goal.is_active == True, | |
| Goal.is_completed == False, | |
| Goal.id != primary_id, | |
| ) | |
| .order_by(Goal.priority.asc()) | |
| .limit(limit) | |
| .all() | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/plan | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_plan( | |
| goal_id: Optional[int] = Query(None, description="Specific goal ID (default: all active goals)"), | |
| multi_goal: bool = Query(True, description="Interleave tasks from all active goals (default: True)"), | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ) -> DailyPlanResponse: | |
| if goal_id: | |
| # Single-goal mode (explicit) | |
| goal = _get_primary_goal(goal_id, current_user, db) | |
| reschedule_skipped(db, current_user, goal) | |
| plan = generate_daily_plan(db, current_user, goal) | |
| else: | |
| # Unified multi-goal mode (default) | |
| goals = ( | |
| db.query(Goal) | |
| .filter(Goal.user_id == current_user.user_id, Goal.is_active == True, Goal.is_completed == False) | |
| .order_by(Goal.priority.asc()) | |
| .all() | |
| ) | |
| if not goals: | |
| # BUG FIX: Return graceful empty plan instead of 404 when user has no active goals. | |
| # This allows the frontend to show an onboarding prompt rather than an error screen. | |
| from app.services.rl_engine import get_stable_signal | |
| import datetime as _dt | |
| behavior = get_stable_signal(current_user, db=db) | |
| empty_plan = { | |
| "date": _dt.date.today().isoformat(), | |
| "tasks": [], | |
| "tasks_detail": [], | |
| "behavior": behavior, | |
| "module": "", | |
| "topic": "", | |
| "coach_message": "Create your first goal to get started! Head to the Goals section.", | |
| "streak": current_user.current_streak or 0, | |
| "total_minutes": 0, | |
| "goals_count": 0, | |
| "next_day_preview": None, | |
| "progress_context": {"is_falling_behind": False, "is_overdue": False, "days_extended": 0}, | |
| "notifications": [], | |
| } | |
| return DailyPlanResponse(**empty_plan) | |
| for g in goals: | |
| reschedule_skipped(db, current_user, g) | |
| plan = generate_unified_daily_plan(db, current_user, goals) | |
| notifs = get_pending_notifications(current_user, db) | |
| plan["notifications"] = notifs | |
| return DailyPlanResponse(**plan) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # POST /api/complete-task | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def complete_task( | |
| payload: CompleteTaskRequest, | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| # Build filter β optionally narrowed by goal_id for multi-goal support | |
| query = db.query(Progress).filter( | |
| Progress.user_id == current_user.user_id, | |
| Progress.date == payload.date, | |
| ) | |
| if payload.goal_id is not None: | |
| query = query.filter(Progress.goal_id == payload.goal_id) | |
| # Disambiguate if there are multiple tasks for the date and goal_id was omitted | |
| if payload.goal_id is None and query.count() > 1: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=f"Multiple plans found for {payload.date}. Please specify goal_id.", | |
| ) | |
| row = query.first() | |
| if not row: | |
| detail = f"No plan found for {payload.date}" | |
| if payload.goal_id: | |
| detail += f" (goal_id={payload.goal_id})" | |
| detail += ". Call GET /api/plan first." | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail=detail, | |
| ) | |
| # Guard: duplicate completion | |
| if row.completed: | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail=f"Task for {payload.date} is already completed.", | |
| ) | |
| # Update progress row | |
| row.completed = True | |
| row.completed_at = datetime.now(timezone.utc) | |
| if payload.actual_duration_min is not None: | |
| row.actual_duration_min = payload.actual_duration_min | |
| if payload.satisfaction is not None: | |
| row.satisfaction = payload.satisfaction | |
| if payload.notes: | |
| row.notes = ((row.notes or "") + f"\n{payload.notes}").strip() | |
| # Capture pre-update values for milestone detection | |
| xp_before = current_user.xp or 0 | |
| level_before = current_user.level or 1 | |
| # Update streak | |
| streak = update_streak(db, current_user) | |
| # Award XP | |
| xp_result = award_xp(db, current_user) | |
| # Feature 2: Satisfaction Imputation | |
| base_reward = 1.0 | |
| if payload.satisfaction is not None: | |
| base_reward = 0.6 + 0.4 * payload.satisfaction | |
| else: | |
| # Impute missing satisfaction using 7-day moving average | |
| hist = _compute_historical_features(current_user, db) | |
| imputed_sat = hist.get("avg_satisfaction_7d", 0.6) | |
| base_reward = 0.6 + 0.4 * imputed_sat | |
| logger.info("Imputed satisfaction %.2f for %s", imputed_sat, current_user.user_id) | |
| # Feature 5: Passive Friction Sensing | |
| # Penalize reward if user paused a lot or took much longer than estimated. | |
| # This teaches the RL engine the task was harder than anticipated. | |
| if payload.pause_count is not None and payload.pause_count > 2: | |
| friction_penalty = min(0.3, payload.pause_count * 0.04) | |
| base_reward = max(0.1, base_reward - friction_penalty) | |
| logger.info("Friction penalty %.2f applied for %s (pauses=%d)", | |
| friction_penalty, current_user.user_id, payload.pause_count) | |
| if (payload.actual_duration_min is not None | |
| and payload.estimated_duration_min is not None | |
| and payload.estimated_duration_min > 0): | |
| overrun_ratio = payload.actual_duration_min / payload.estimated_duration_min | |
| if overrun_ratio > 1.8: # Took 80%+ longer than estimated | |
| time_friction = min(0.25, (overrun_ratio - 1.0) * 0.15) | |
| base_reward = max(0.1, base_reward - time_friction) | |
| logger.info("Time overrun penalty %.2f applied for %s (ratio=%.1fx)", | |
| time_friction, current_user.user_id, overrun_ratio) | |
| action_taken = row.action_label or "Normal" | |
| update_reward(db, current_user, action_taken, base_reward, goal_id=row.goal_id) | |
| # Feature 7: Passive Fatigue Accumulation | |
| duration = payload.actual_duration_min or 30 | |
| fatigue_delta = (duration / 60.0) * 0.10 # Base 0.1 fatigue per hour | |
| if action_taken == "Deep Work": | |
| fatigue_delta *= 1.5 | |
| elif action_taken in ("Recovery", "Rest"): | |
| fatigue_delta = -(duration / 60.0) * 0.15 # Recovery restores fatigue | |
| current_user.fatigue = max(0.0, min(1.0, (current_user.fatigue or 0.0) + fatigue_delta)) | |
| logger.info("Passive fatigue delta %.3f applied to %s (now %.2f)", fatigue_delta, current_user.user_id, current_user.fatigue) | |
| # BUG FIX: Eagerly recalculate completion_pct immediately after marking a task complete | |
| # so GET /api/goals/{id} reflects accurate progress without requiring a plan re-fetch. | |
| if row.goal_id: | |
| _goal_for_pct = db.query(Goal).filter(Goal.id == row.goal_id).first() | |
| if _goal_for_pct: | |
| from app.services.planner import _build_schedule, _load_plan_json | |
| _schedule = _build_schedule(_load_plan_json(_goal_for_pct, db, current_user)) | |
| _total = len(_schedule) | |
| if _total > 0: | |
| from app.models.progress import Progress as _Prog | |
| _done = db.query(_Prog).filter( | |
| _Prog.user_id == current_user.user_id, | |
| _Prog.goal_id == row.goal_id, | |
| _Prog.completed == True, | |
| ).count() | |
| _goal_for_pct.completion_pct = min(100.0, round((_done / _total) * 100, 1)) | |
| if _goal_for_pct.completion_pct >= 100.0 and not _goal_for_pct.is_completed: | |
| _goal_for_pct.is_completed = True | |
| import datetime as _dt2 | |
| _goal_for_pct.completed_at = _dt2.datetime.now(_dt2.timezone.utc) | |
| db.commit() | |
| # Check if this completion finished a goal | |
| goal_notif = None | |
| if row.goal_id: | |
| goal = db.query(Goal).filter(Goal.id == row.goal_id).first() | |
| if goal and goal.is_completed: | |
| goal_notif = goal_completion_signal(goal) | |
| # Check if ALL of today's tasks are now complete | |
| today_status = get_day_status(db, current_user.user_id) | |
| all_today_done = today_status.get("all_completed", False) | |
| # If all done, reset daily state so next GET /api/plan serves fresh next-day content | |
| if all_today_done: | |
| reset_daily_state(db, current_user) | |
| # Collect milestone notifications | |
| notifs = get_pending_notifications( | |
| current_user, db, | |
| xp_before=xp_before, | |
| level_before=level_before, | |
| ) | |
| if goal_notif: | |
| notifs.append(goal_notif) | |
| return { | |
| "message": "Task completed! π", | |
| "streak": streak, | |
| "all_completed": all_today_done, | |
| "notifications": notifs, | |
| **xp_result, | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # POST /api/skip-task | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def skip_task( | |
| payload: SkipTaskRequest, | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| row = ( | |
| db.query(Progress) | |
| .filter( | |
| Progress.user_id == current_user.user_id, | |
| Progress.date == payload.date, | |
| ) | |
| .first() | |
| ) | |
| if not row: | |
| raise HTTPException( | |
| status_code=status.HTTP_404_NOT_FOUND, | |
| detail=f"No plan found for {payload.date}.", | |
| ) | |
| if row.completed: | |
| raise HTTPException( | |
| status_code=status.HTTP_409_CONFLICT, | |
| detail="Cannot skip an already-completed task.", | |
| ) | |
| # Record skip reason | |
| skip_note = "[SKIPPED]" | |
| if payload.reason: | |
| skip_note += f" Reason: {payload.reason}" | |
| row.notes = ((row.notes or "") + f"\n{skip_note}").strip() | |
| # Negative RL signal (0.0 reward = skip) | |
| action_taken = row.action_label or "Normal" | |
| update_reward(db, current_user, action_taken, 0.0) | |
| db.commit() | |
| logger.info("Task skipped for %s on %s", current_user.user_id, payload.date) | |
| return {"message": "Task skipped. We'll adjust your next plan accordingly.", "date": payload.date} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # POST /api/update-state | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def update_state( | |
| payload: UpdateStateRequest, | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| if payload.mood is not None: current_user.mood = payload.mood | |
| if payload.fatigue is not None: current_user.fatigue = payload.fatigue | |
| if payload.stress is not None: current_user.stress = payload.stress | |
| if payload.sleep_quality is not None: current_user.sleep_quality = payload.sleep_quality | |
| if payload.sick is not None: current_user.sick = payload.sick | |
| db.flush() # flush state changes before RL re-samples | |
| behavior = refresh_signal(current_user, db=db) # force fresh RL sample + persist | |
| db.commit() | |
| return {"message": "State updated", "behavior": behavior} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/behavior | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_behavior( | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| return get_stable_signal(current_user, db=db) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/timetable | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_timetable( | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| from app.services.planner import generate_daily_plan, generate_timetable | |
| from app.models.goal import Goal | |
| # 1. Try to get the active goal | |
| goal = db.query(Goal).filter( | |
| Goal.user_id == current_user.user_id, | |
| Goal.is_active == True, | |
| Goal.is_completed == False, | |
| ).order_by(Goal.priority.asc(), Goal.created_at.desc()).first() | |
| if not goal: | |
| return {"timetable": []} | |
| # 2. Get today's plan | |
| try: | |
| plan = generate_daily_plan(db, current_user, goal) | |
| except Exception as e: | |
| logger.error("Failed to generate plan for timetable: %s", e) | |
| return {"timetable": []} | |
| # 3. Generate timetable from the plan | |
| timetable, nudge = generate_timetable(current_user, db, plan) | |
| return { | |
| "timetable": timetable, | |
| "date": plan.get("date"), | |
| "lifestyle": current_user.schedule_json.get("lifestyle", "student") if current_user.schedule_json else "student", | |
| "circadian_nudge": nudge | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # POST /api/day/complete (manual "Done for Today") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def complete_day( | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| result = close_day_for_user(db, current_user.user_id, reason="manual") | |
| streak = update_streak(db, current_user) | |
| xp = award_xp(db, current_user, base_xp=15 * result.get("closed", 1)) | |
| return DayCompleteResponse( | |
| closed=result.get("closed", 0), | |
| message=result.get("message", "Day closed."), | |
| date=result.get("date", ""), | |
| streak=streak, | |
| next_day_preview=result.get("next_day_preview"), | |
| **xp, | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/plan/next (tomorrow's plan preview) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_next_plan( | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| from app.services.rl_engine import get_next_day_signal | |
| import datetime as dt | |
| tomorrow = (dt.date.today() + dt.timedelta(days=1)).isoformat() | |
| rl_preview = get_next_day_signal(current_user, db=db) | |
| # Check what goals will be active tomorrow | |
| goals = ( | |
| db.query(Goal) | |
| .filter(Goal.user_id == current_user.user_id, Goal.is_active == True, Goal.is_completed == False) | |
| .order_by(Goal.priority.asc()) | |
| .all() | |
| ) | |
| goal_previews = [] | |
| for goal in goals: | |
| goal_previews.append({ | |
| "goal_id": goal.id, | |
| "goal_text": goal.goal_text[:60], | |
| "priority": goal.priority, | |
| "completion_pct": goal.completion_pct or 0.0, | |
| }) | |
| return { | |
| "date": tomorrow, | |
| "rl_preview": rl_preview, | |
| "goals": goal_previews, | |
| "message": f"Tomorrow's mode: {rl_preview['action_label']} at {rl_preview['intensity']:.0%} intensity", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/day/status | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def day_status( | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| return get_day_status(db, current_user.user_id) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/lesson/{topic} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_lesson( | |
| topic: str, | |
| current_user: User = Depends(get_current_user), | |
| ): | |
| if not topic or len(topic.strip()) < 2: | |
| raise HTTPException( | |
| status_code=status.HTTP_400_BAD_REQUEST, | |
| detail="Topic must be at least 2 characters.", | |
| ) | |
| topic = topic.strip()[:200] # cap length | |
| # generate_lesson_text() is Redis-backed (7-day TTL) β no local cache needed | |
| content, is_cached = generate_lesson_text(topic) | |
| if not content: | |
| raise HTTPException( | |
| status_code=status.HTTP_503_SERVICE_UNAVAILABLE, | |
| detail="Lesson generation temporarily unavailable.", | |
| ) | |
| return {"topic": topic, "content": content, "cached": is_cached} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/stats | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_stats( | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| total_completed = db.query(Progress).filter( | |
| Progress.user_id == current_user.user_id, | |
| Progress.completed == True, | |
| ).count() | |
| total_incomplete = db.query(Progress).filter( | |
| Progress.user_id == current_user.user_id, | |
| Progress.completed == False, | |
| ).count() | |
| total_goals_completed = db.query(Goal).filter( | |
| Goal.user_id == current_user.user_id, | |
| Goal.is_completed == True, | |
| ).count() | |
| total_goals = db.query(Goal).filter( | |
| Goal.user_id == current_user.user_id, | |
| ).count() | |
| avg_minutes = db.query(func.avg(Progress.actual_duration_min)).filter( | |
| Progress.user_id == current_user.user_id, | |
| Progress.completed == True, | |
| Progress.actual_duration_min.isnot(None), | |
| ).scalar() or 0.0 | |
| recent = ( | |
| db.query(Progress) | |
| .filter(Progress.user_id == current_user.user_id, Progress.completed == True) | |
| .order_by(Progress.completed_at.desc()) | |
| .limit(10) | |
| .all() | |
| ) | |
| recent_activity = [ | |
| { | |
| "date": r.date, | |
| "topic": r.topic, | |
| "module": r.module, | |
| "duration_min": r.actual_duration_min, | |
| "action_label": r.action_label, | |
| "satisfaction": r.satisfaction, | |
| } | |
| for r in recent | |
| ] | |
| mood_entries = ( | |
| db.query(Progress.date, Progress.mood, Progress.fatigue) | |
| .filter(Progress.user_id == current_user.user_id) | |
| .order_by(Progress.date.desc()) | |
| .limit(14) | |
| .all() | |
| ) | |
| mood_trend = [ | |
| {"date": m.date, "mood": m.mood, "fatigue": m.fatigue} | |
| for m in mood_entries | |
| ] | |
| completion_rate = ( | |
| round(total_completed / max(total_completed + total_incomplete, 1) * 100, 1) | |
| ) | |
| from app.services.rl_engine import _calculate_weekly_strain, _compute_historical_features | |
| strain_ratio, is_deload = _calculate_weekly_strain(current_user, db) | |
| hist = _compute_historical_features(current_user, db) | |
| return { | |
| "total_tasks_completed": total_completed, | |
| "total_goals_completed": total_goals_completed, | |
| "total_goals": total_goals, | |
| "current_streak": current_user.current_streak or 0, | |
| "longest_streak": current_user.longest_streak or 0, | |
| "xp": current_user.xp or 0, | |
| "level": current_user.level or 1, | |
| "completion_rate": completion_rate, | |
| "avg_daily_minutes": round(float(avg_minutes), 1), | |
| "mood_trend": mood_trend, | |
| "recent_activity": recent_activity, | |
| "recovery_health": { | |
| "strain_ratio": strain_ratio, | |
| "is_deload_state": is_deload | |
| }, | |
| "rl_analysis": { | |
| "burnout_warning": hist.get("burnout_warning", False), | |
| "overwork_signal": hist.get("overwork_signal", 0.0), | |
| "weekend_warrior_flag": hist.get("weekend_rate", 0) > hist.get("weekday_rate", 0) + 0.05, | |
| "max_goal_completion": hist.get("max_goal_completion", 0.0), | |
| "ema_fatigue": hist.get("ema_fatigue", 0.0), | |
| } | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/stats/{goal_id} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_stats_for_goal( | |
| goal_id: int, | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ): | |
| goal = db.query(Goal).filter( | |
| Goal.id == goal_id, | |
| Goal.user_id == current_user.user_id, | |
| ).first() | |
| if not goal: | |
| raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Goal not found") | |
| sessions = ( | |
| db.query(Progress) | |
| .filter(Progress.user_id == current_user.user_id, Progress.goal_id == goal_id) | |
| .all() | |
| ) | |
| completed = [s for s in sessions if s.completed] | |
| incomplete = [s for s in sessions if not s.completed] | |
| total_min = sum(s.actual_duration_min or 0 for s in completed) | |
| avg_sat = ( | |
| sum(s.satisfaction for s in completed if s.satisfaction is not None) | |
| / max(1, sum(1 for s in completed if s.satisfaction is not None)) | |
| if any(s.satisfaction is not None for s in completed) else None | |
| ) | |
| action_dist: dict = {} | |
| for s in sessions: | |
| lbl = s.action_label or "Unknown" | |
| action_dist[lbl] = action_dist.get(lbl, 0) + 1 | |
| return { | |
| "goal_id": goal.id, | |
| "goal_text": goal.goal_text, | |
| "completion_pct": goal.completion_pct or 0.0, | |
| "is_completed": goal.is_completed, | |
| "total_sessions": len(sessions), | |
| "completed_sessions": len(completed), | |
| "skipped_sessions": len(incomplete), | |
| "total_minutes_spent": total_min, | |
| "avg_satisfaction": round(avg_sat, 3) if avg_sat is not None else None, | |
| "action_distribution": action_dist, | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GET /api/plan/history | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_plan_history( | |
| days: int = Query(7, ge=1, le=90, description="Number of past days to retrieve (min 1, max 90)"), | |
| current_user: User = Depends(get_current_user), | |
| db: Session = Depends(get_db), | |
| ) -> PlanHistoryResponse: | |
| from datetime import timedelta | |
| cutoff = (date.today() - timedelta(days=days)).isoformat() | |
| rows = ( | |
| db.query(Progress) | |
| .filter( | |
| Progress.user_id == current_user.user_id, | |
| Progress.date >= cutoff, | |
| ) | |
| .order_by(Progress.date.desc()) | |
| .all() | |
| ) | |
| entries = [ | |
| PlanHistoryEntry( | |
| date = r.date, | |
| module = r.module or "", | |
| topic = r.topic or "", | |
| completed = r.completed, | |
| action_label = r.action_label or "Unknown", | |
| duration_min = r.actual_duration_min, | |
| satisfaction = r.satisfaction, | |
| ) | |
| for r in rows | |
| ] | |
| return PlanHistoryResponse(days=days, entries=entries) | |