Files
sport-platform/backend/app/api/rider.py
2026-03-17 10:00:55 +03:00

546 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
}