from datetime import date, timedelta from fastapi import APIRouter, Depends from sqlalchemy import select, func, extract, cast, Date from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.auth import get_current_rider from backend.app.core.database import get_session from backend.app.models.activity import Activity, ActivityMetrics from backend.app.models.fitness import PowerCurve from backend.app.models.rider import Rider from backend.app.schemas.rider import RiderUpdate, RiderResponse, FitnessHistoryResponse from backend.app.services.fitness import get_fitness_history router = APIRouter() @router.get("/profile", response_model=RiderResponse) async def get_rider(rider: Rider = Depends(get_current_rider)): return rider @router.put("/profile", response_model=RiderResponse) async def update_rider( data: RiderUpdate, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): update_data = data.model_dump(exclude_unset=True) ftp_changed = "ftp" in update_data and update_data["ftp"] != rider.ftp for key, value in update_data.items(): setattr(rider, key, value) # Recalculate TSS/IF for all activities when FTP changes if ftp_changed and rider.ftp: query = ( select(ActivityMetrics) .join(Activity, Activity.id == ActivityMetrics.activity_id) .where(Activity.rider_id == rider.id) .where(ActivityMetrics.normalized_power.isnot(None)) ) result = await session.execute(query) for metrics in result.scalars().all(): np_val = metrics.normalized_power duration_q = await session.execute( select(Activity.duration).where(Activity.id == metrics.activity_id) ) duration = duration_q.scalar() if np_val and duration: metrics.intensity_factor = round(np_val / rider.ftp, 2) metrics.tss = round( (duration * np_val * (np_val / rider.ftp)) / (rider.ftp * 3600) * 100, 1, ) await session.commit() await session.refresh(rider) return rider @router.get("/fitness", response_model=list[FitnessHistoryResponse]) async def get_fitness( days: int = 90, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): entries = await get_fitness_history(rider.id, session, days=days) return entries @router.get("/weekly-stats") async def get_weekly_stats( weeks: int = 8, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): today = date.today() # Start from Monday of current week start_of_week = today - timedelta(days=today.weekday()) start_date = start_of_week - timedelta(weeks=weeks - 1) week_col = func.date_trunc('week', Activity.date).label("week") query = ( select( week_col, func.count(Activity.id).label("rides"), func.sum(Activity.duration).label("duration"), func.sum(Activity.distance).label("distance"), func.sum(ActivityMetrics.tss).label("tss"), ) .select_from(Activity) .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) .where(Activity.rider_id == rider.id) .where(Activity.date >= start_date) .group_by(week_col) .order_by(week_col) ) result = await session.execute(query) return [ { "week": row.week.strftime("%Y-%m-%d") if row.week else None, "rides": row.rides, "duration": row.duration or 0, "distance": round(float(row.distance or 0) / 1000, 1), "tss": round(float(row.tss or 0), 0), } for row in result ] @router.get("/personal-records") async def get_personal_records( rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """Best power at each standard duration across all activities.""" query = ( select(PowerCurve.curve_data, Activity.date, Activity.name, Activity.id) .join(Activity, Activity.id == PowerCurve.activity_id) .where(Activity.rider_id == rider.id) ) result = await session.execute(query) # Merge all curves: keep best power + source activity for each duration records: dict[int, dict] = {} for row in result: curve_data = row.curve_data for dur_str, power in curve_data.items(): dur = int(dur_str) if dur not in records or power > records[dur]["power"]: records[dur] = { "duration": dur, "power": power, "activity_id": str(row.id), "activity_name": row.name or "Ride", "date": row.date.isoformat(), } # Sort by duration return sorted(records.values(), key=lambda r: r["duration"]) @router.get("/detect-ftp") async def detect_ftp( rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """Auto-detect FTP from best 20min power × 0.95 across all activities.""" query = ( select(PowerCurve.curve_data, Activity.date, Activity.name, Activity.id) .join(Activity, Activity.id == PowerCurve.activity_id) .where(Activity.rider_id == rider.id) ) result = await session.execute(query) best_20min = 0 best_activity_id = None best_activity_name = None best_date = None for row in result: power_20 = row.curve_data.get("1200") # 20 min = 1200s if power_20 and power_20 > best_20min: best_20min = power_20 best_activity_id = str(row.id) best_activity_name = row.name or "Ride" best_date = row.date.isoformat() if best_20min == 0: return {"detected_ftp": None, "best_20min_power": None, "message": "No 20-minute power data found"} detected_ftp = round(best_20min * 0.95) return { "detected_ftp": detected_ftp, "best_20min_power": best_20min, "activity_id": best_activity_id, "activity_name": best_activity_name, "date": best_date, "current_ftp": rider.ftp, } @router.get("/activity-calendar") async def get_activity_calendar( months: int = 12, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """Activity heatmap data: date → TSS for the last N months.""" start_date = date.today() - timedelta(days=months * 30) day_col = cast(Activity.date, Date).label("day") query = ( select( day_col, func.count(Activity.id).label("count"), func.sum(Activity.duration).label("duration"), func.sum(ActivityMetrics.tss).label("tss"), ) .select_from(Activity) .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) .where(Activity.rider_id == rider.id) .where(Activity.date >= start_date) .group_by(day_col) .order_by(day_col) ) result = await session.execute(query) return [ { "date": row.day.isoformat(), "count": row.count, "duration": row.duration or 0, "tss": round(float(row.tss or 0), 0), } for row in result ] @router.get("/monthly-trends") async def get_monthly_trends( months: int = 12, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """Monthly trends: avg power, avg HR, volume, W/kg.""" start_date = date.today() - timedelta(days=months * 30) month_col = func.date_trunc("month", Activity.date).label("month") query = ( select( month_col, func.count(Activity.id).label("rides"), func.sum(Activity.duration).label("duration"), func.sum(Activity.distance).label("distance"), func.avg(ActivityMetrics.avg_power).label("avg_power"), func.avg(ActivityMetrics.normalized_power).label("avg_np"), func.avg(ActivityMetrics.avg_hr).label("avg_hr"), func.sum(ActivityMetrics.tss).label("tss"), ) .select_from(Activity) .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) .where(Activity.rider_id == rider.id) .where(Activity.date >= start_date) .group_by(month_col) .order_by(month_col) ) result = await session.execute(query) weight = rider.weight return [ { "month": row.month.strftime("%Y-%m") if row.month else None, "rides": row.rides, "hours": round((row.duration or 0) / 3600, 1), "distance_km": round(float(row.distance or 0) / 1000, 0), "avg_power": round(float(row.avg_power), 0) if row.avg_power else None, "avg_np": round(float(row.avg_np), 0) if row.avg_np else None, "avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None, "tss": round(float(row.tss or 0), 0), "w_per_kg": round(float(row.avg_power) / weight, 2) if row.avg_power and weight else None, } for row in result ] @router.get("/progress-summary") async def get_progress_summary( rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """Compare this week vs last week, this month vs last month. Current fitness state.""" today = date.today() start_of_week = today - timedelta(days=today.weekday()) # Monday last_week_start = start_of_week - timedelta(weeks=1) start_of_month = today.replace(day=1) last_month_start = (start_of_month - timedelta(days=1)).replace(day=1) async def _period_stats(start: date, end: date): query = ( select( func.count(Activity.id).label("rides"), func.sum(Activity.duration).label("duration"), func.sum(Activity.distance).label("distance"), func.sum(ActivityMetrics.tss).label("tss"), func.avg(ActivityMetrics.avg_power).label("avg_power"), func.avg(ActivityMetrics.normalized_power).label("avg_np"), func.avg(ActivityMetrics.avg_hr).label("avg_hr"), ) .select_from(Activity) .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) .where(Activity.rider_id == rider.id) .where(Activity.date >= start) .where(Activity.date < end) ) row = (await session.execute(query)).first() if not row or row.rides == 0: return {"rides": 0, "hours": 0, "distance_km": 0, "tss": 0, "avg_power": None, "avg_np": None, "avg_hr": None} return { "rides": row.rides, "hours": round((row.duration or 0) / 3600, 1), "distance_km": round(float(row.distance or 0) / 1000, 1), "tss": round(float(row.tss or 0), 0), "avg_power": round(float(row.avg_power), 0) if row.avg_power else None, "avg_np": round(float(row.avg_np), 0) if row.avg_np else None, "avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None, } this_week = await _period_stats(start_of_week, today + timedelta(days=1)) last_week = await _period_stats(last_week_start, start_of_week) this_month = await _period_stats(start_of_month, today + timedelta(days=1)) last_month = await _period_stats(last_month_start, start_of_month) # Current fitness (latest CTL/ATL/TSB) from backend.app.models.fitness import FitnessHistory fh_query = ( select(FitnessHistory) .where(FitnessHistory.rider_id == rider.id) .order_by(FitnessHistory.date.desc()) .limit(1) ) fh = (await session.execute(fh_query)).scalar_one_or_none() # Fitness 7 days ago for trend fh_prev_query = ( select(FitnessHistory) .where(FitnessHistory.rider_id == rider.id) .where(FitnessHistory.date <= today - timedelta(days=7)) .order_by(FitnessHistory.date.desc()) .limit(1) ) fh_prev = (await session.execute(fh_prev_query)).scalar_one_or_none() fitness_now = { "ctl": round(fh.ctl, 1) if fh else None, "atl": round(fh.atl, 1) if fh else None, "tsb": round(fh.tsb, 1) if fh else None, } fitness_prev = { "ctl": round(fh_prev.ctl, 1) if fh_prev else None, "atl": round(fh_prev.atl, 1) if fh_prev else None, "tsb": round(fh_prev.tsb, 1) if fh_prev else None, } # Total activity count total_count = (await session.execute( select(func.count(Activity.id)).where(Activity.rider_id == rider.id) )).scalar() or 0 # Days since last activity last_act = (await session.execute( select(Activity.date) .where(Activity.rider_id == rider.id) .order_by(Activity.date.desc()) .limit(1) )).scalar_one_or_none() if last_act is not None: from datetime import datetime as dt_type last_date = last_act.date() if isinstance(last_act, dt_type) else last_act days_since_last = (today - last_date).days else: days_since_last = None return { "this_week": this_week, "last_week": last_week, "this_month": this_month, "last_month": last_month, "fitness": fitness_now, "fitness_7d_ago": fitness_prev, "total_activities": total_count, "days_since_last_activity": days_since_last, "ftp": rider.ftp, "weight": rider.weight, } @router.get("/ai-progress") async def get_ai_progress( rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """AI analysis of long-term progress with actionable recommendations.""" from backend.app.services.coaching import build_rider_context from backend.app.services.gemini_client import chat_async from backend.app.models.fitness import FitnessHistory today = date.today() rider_context = await build_rider_context(rider, session) # Collect monthly stats for the last 6 months lines = [rider_context, "\n--- Monthly breakdown (last 6 months) ---"] for i in range(6): m_start = (today.replace(day=1) - timedelta(days=30 * i)).replace(day=1) m_end = (m_start + timedelta(days=32)).replace(day=1) query = ( select( func.count(Activity.id).label("rides"), func.sum(Activity.duration).label("duration"), func.sum(Activity.distance).label("distance"), func.sum(ActivityMetrics.tss).label("tss"), func.avg(ActivityMetrics.avg_power).label("avg_power"), func.avg(ActivityMetrics.avg_hr).label("avg_hr"), ) .select_from(Activity) .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) .where(Activity.rider_id == rider.id) .where(Activity.date >= m_start) .where(Activity.date < m_end) ) row = (await session.execute(query)).first() if row and row.rides: lines.append( f"{m_start.strftime('%Y-%m')}: {row.rides} rides, " f"{(row.duration or 0) / 3600:.1f}h, " f"TSS={float(row.tss or 0):.0f}, " f"AP={float(row.avg_power):.0f}W" if row.avg_power else f"{m_start.strftime('%Y-%m')}: {row.rides} rides" ) # Fitness trend: sample every 7 days for last 90 days fh_query = ( select(FitnessHistory.date, FitnessHistory.ctl, FitnessHistory.atl, FitnessHistory.tsb) .where(FitnessHistory.rider_id == rider.id) .where(FitnessHistory.date >= today - timedelta(days=90)) .order_by(FitnessHistory.date) ) fh_rows = (await session.execute(fh_query)).all() if fh_rows: lines.append("\n--- Fitness trend (sampled weekly, last 90d) ---") for idx, fh in enumerate(fh_rows): if idx % 7 == 0 or idx == len(fh_rows) - 1: lines.append(f"{fh.date}: CTL={fh.ctl:.0f} ATL={fh.atl:.0f} TSB={fh.tsb:.0f}") system = """You are VeloBrain AI Coach performing a comprehensive progress analysis. Analyze the rider's data and produce a structured report with these sections: 1. **Текущее состояние** — Current fitness level, form, fatigue. Is the rider fresh, building, overreaching? 2. **Динамика прогресса** — How fitness, power, and volume have changed over the last months. Specific numbers and trends. 3. **Сильные стороны** — What the rider is doing well (consistency, intensity, volume, specific power durations). 4. **Зоны роста** — Specific weaknesses or areas for improvement. Be constructive. 5. **Рекомендации** — 3-5 specific, actionable recommendations for the next 4 weeks. Include target numbers where possible. 6. **Риски** — Any warning signs: overtraining, stagnation, imbalance. Only include if relevant. Be specific with numbers. Compare periods where possible. Keep each section 2-4 sentences. Respond in Russian. Use HTML formatting.""" analysis = await chat_async( [{"role": "user", "text": "\n".join(lines)}], system_instruction=system, temperature=0.5, ) return {"analysis": analysis} @router.get("/weekly-digest") async def get_weekly_digest( rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): """Get data for AI weekly digest generation.""" from backend.app.services.coaching import build_rider_context from backend.app.services.gemini_client import chat_async today = date.today() week_start = today - timedelta(days=today.weekday() + 7) # Last Monday week_end = week_start + timedelta(days=7) # Activities from last week act_query = ( select(Activity, ActivityMetrics) .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) .where(Activity.rider_id == rider.id) .where(Activity.date >= week_start) .where(Activity.date < week_end) .order_by(Activity.date) ) result = await session.execute(act_query) activities = list(result.all()) if not activities: return {"digest": None, "message": "No activities last week"} # Build prompt lines = [f"Weekly digest for {week_start.isoformat()} — {week_end.isoformat()}"] total_duration = 0 total_tss = 0 total_distance = 0 for act, metrics in activities: total_duration += act.duration total_distance += act.distance or 0 tss = float(metrics.tss or 0) if metrics else 0 total_tss += tss lines.append( f"- {act.date.strftime('%a')}: {act.name or 'Workout'}, " f"{act.duration // 60}min, " f"{f'{act.distance / 1000:.1f}km, ' if act.distance else ''}" f"{f'AP={metrics.avg_power:.0f}W, NP={metrics.normalized_power:.0f}W, ' if metrics and metrics.avg_power else ''}" f"TSS={tss:.0f}" ) lines.append(f"\nTotals: {len(activities)} workouts, {total_duration / 3600:.1f}h, " f"{total_distance / 1000:.1f}km, TSS={total_tss:.0f}") rider_context = await build_rider_context(rider, session) lines.append(f"\n--- Rider ---\n{rider_context}") system = """You are VeloBrain AI Coach generating a weekly training digest. Provide a concise summary with these sections: 1. **Итоги недели** — what was done, key numbers 2. **Что получилось хорошо** — highlights, PRs, consistency 3. **На что обратить внимание** — areas for improvement, recovery needs 4. **Рекомендации на следующую неделю** — specific actionable advice Keep it 8-12 sentences total. Be specific with numbers. Respond in Russian. Use HTML formatting.""" digest = await chat_async( [{"role": "user", "text": "\n".join(lines)}], system_instruction=system, temperature=0.5, ) return { "digest": digest, "week_start": week_start.isoformat(), "week_end": week_end.isoformat(), "total_activities": len(activities), "total_hours": round(total_duration / 3600, 1), "total_tss": round(total_tss, 0), "total_distance_km": round(total_distance / 1000, 1), }