refix
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy import select, func, extract, cast, Date
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.core.auth import get_current_rider
|
||||
@@ -144,3 +144,402 @@ async def get_personal_records(
|
||||
|
||||
# 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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user