546 lines
20 KiB
Python
546 lines
20 KiB
Python
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),
|
||
}
|