refix
This commit is contained in:
@@ -26,6 +26,7 @@ from backend.app.services.power_curve import calculate_power_curve
|
|||||||
from backend.app.services.intervals import detect_intervals
|
from backend.app.services.intervals import detect_intervals
|
||||||
from backend.app.services.ai_summary import generate_summary
|
from backend.app.services.ai_summary import generate_summary
|
||||||
from backend.app.services.coaching import link_activity_to_plan
|
from backend.app.services.coaching import link_activity_to_plan
|
||||||
|
from backend.app.models.training import TrainingPlan
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -205,6 +206,7 @@ async def get_activity_power_curve(
|
|||||||
@router.post("/{activity_id}/ai-summary")
|
@router.post("/{activity_id}/ai-summary")
|
||||||
async def generate_ai_summary(
|
async def generate_ai_summary(
|
||||||
activity_id: uuid.UUID,
|
activity_id: uuid.UUID,
|
||||||
|
body: dict | None = None,
|
||||||
rider: Rider = Depends(get_current_rider),
|
rider: Rider = Depends(get_current_rider),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
@@ -212,16 +214,31 @@ async def generate_ai_summary(
|
|||||||
if not activity or activity.rider_id != rider.id:
|
if not activity or activity.rider_id != rider.id:
|
||||||
raise HTTPException(status_code=404, detail="Activity not found")
|
raise HTTPException(status_code=404, detail="Activity not found")
|
||||||
|
|
||||||
|
force = (body or {}).get("force", False)
|
||||||
|
|
||||||
# Check for existing diary entry with summary
|
# Check for existing diary entry with summary
|
||||||
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
|
query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id)
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
diary = result.scalar_one_or_none()
|
diary = result.scalar_one_or_none()
|
||||||
|
|
||||||
if diary and diary.ai_summary:
|
if diary and diary.ai_summary and not force:
|
||||||
return {"summary": diary.ai_summary}
|
return {"summary": diary.ai_summary}
|
||||||
|
|
||||||
|
# If linked to a plan, find the planned workout for comparison
|
||||||
|
planned_workout = None
|
||||||
|
if activity.training_plan_id and activity.plan_week and activity.plan_day:
|
||||||
|
plan = await session.get(TrainingPlan, activity.training_plan_id)
|
||||||
|
if plan and plan.weeks_json:
|
||||||
|
for week in plan.weeks_json.get("weeks", []):
|
||||||
|
if week.get("week_number") == activity.plan_week:
|
||||||
|
for day in week.get("days", []):
|
||||||
|
if day.get("day") == activity.plan_day:
|
||||||
|
planned_workout = day
|
||||||
|
break
|
||||||
|
break
|
||||||
|
|
||||||
# Generate new summary
|
# Generate new summary
|
||||||
summary = await generate_summary(activity, rider_ftp=rider.ftp)
|
summary = await generate_summary(activity, rider_ftp=rider.ftp, planned_workout=planned_workout)
|
||||||
|
|
||||||
if diary:
|
if diary:
|
||||||
diary.ai_summary = summary
|
diary.ai_summary = summary
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.app.core.auth import get_current_rider
|
from backend.app.core.auth import get_current_rider
|
||||||
@@ -144,3 +144,402 @@ async def get_personal_records(
|
|||||||
|
|
||||||
# Sort by duration
|
# Sort by duration
|
||||||
return sorted(records.values(), key=lambda r: r["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),
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,11 +10,32 @@ Be specific with numbers. Use a friendly, coaching tone.
|
|||||||
Respond in Russian.
|
Respond in Russian.
|
||||||
Use a HTML text formatting."""
|
Use a HTML text formatting."""
|
||||||
|
|
||||||
|
SYSTEM_PROMPT_WITH_PLAN = """You are VeloBrain, an AI cycling coach.
|
||||||
|
The athlete completed a workout that was part of their training plan.
|
||||||
|
|
||||||
def _build_activity_prompt(activity: Activity, rider_ftp: float | None = None) -> str:
|
Your analysis MUST include two sections:
|
||||||
|
|
||||||
|
1. **Анализ тренировки** — General performance summary (2-3 sentences): highlights, pacing, training effect.
|
||||||
|
2. **Соответствие плану** — How well the actual workout matched the planned workout (2-4 sentences). Compare:
|
||||||
|
- Actual duration vs planned duration
|
||||||
|
- Actual intensity (IF, TSS, avg power) vs planned targets
|
||||||
|
- Workout type match (was it the right type of effort?)
|
||||||
|
- Give a compliance verdict: fully matched / partially matched / deviated significantly
|
||||||
|
- If deviated, explain what was different and whether the deviation was acceptable or needs attention.
|
||||||
|
|
||||||
|
Be specific with numbers. Use a friendly, coaching tone.
|
||||||
|
Respond in Russian.
|
||||||
|
Use HTML text formatting."""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_activity_prompt(
|
||||||
|
activity: Activity,
|
||||||
|
rider_ftp: float | None = None,
|
||||||
|
planned_workout: dict | None = None,
|
||||||
|
) -> str:
|
||||||
m: ActivityMetrics | None = activity.metrics
|
m: ActivityMetrics | None = activity.metrics
|
||||||
lines = [
|
lines = [
|
||||||
f"Activity: {activity.name or 'Ride'}",
|
f"Activity: {activity.name or 'Workout'}",
|
||||||
f"Type: {activity.activity_type}",
|
f"Type: {activity.activity_type}",
|
||||||
f"Duration: {activity.duration // 3600}h {(activity.duration % 3600) // 60}m",
|
f"Duration: {activity.duration // 3600}h {(activity.duration % 3600) // 60}m",
|
||||||
]
|
]
|
||||||
@@ -41,10 +62,25 @@ def _build_activity_prompt(activity: Activity, rider_ftp: float | None = None) -
|
|||||||
lines.append(f"Max HR: {m.max_hr} bpm")
|
lines.append(f"Max HR: {m.max_hr} bpm")
|
||||||
if m.avg_cadence:
|
if m.avg_cadence:
|
||||||
lines.append(f"Avg Cadence: {m.avg_cadence} rpm")
|
lines.append(f"Avg Cadence: {m.avg_cadence} rpm")
|
||||||
|
if m.calories:
|
||||||
|
lines.append(f"Calories: {m.calories} kcal")
|
||||||
|
|
||||||
if rider_ftp:
|
if rider_ftp:
|
||||||
lines.append(f"Rider FTP: {rider_ftp:.0f} W")
|
lines.append(f"Rider FTP: {rider_ftp:.0f} W")
|
||||||
|
|
||||||
|
# Exercise sets for strength workouts
|
||||||
|
if activity.exercise_sets:
|
||||||
|
lines.append(f"\nExercise sets ({len(activity.exercise_sets)} total):")
|
||||||
|
for s in activity.exercise_sets:
|
||||||
|
parts = [s.get("exercise_name", "Unknown")]
|
||||||
|
if s.get("repetitions"):
|
||||||
|
parts.append(f"{s['repetitions']} reps")
|
||||||
|
if s.get("weight"):
|
||||||
|
parts.append(f"{s['weight']} kg")
|
||||||
|
if s.get("duration") and not s.get("repetitions"):
|
||||||
|
parts.append(f"{s['duration']:.0f}s")
|
||||||
|
lines.append(f" - {' / '.join(parts)}")
|
||||||
|
|
||||||
intervals = activity.intervals or []
|
intervals = activity.intervals or []
|
||||||
work_intervals = [i for i in intervals if i.interval_type == "work"]
|
work_intervals = [i for i in intervals if i.interval_type == "work"]
|
||||||
if work_intervals:
|
if work_intervals:
|
||||||
@@ -53,10 +89,29 @@ def _build_activity_prompt(activity: Activity, rider_ftp: float | None = None) -
|
|||||||
if powers:
|
if powers:
|
||||||
lines.append(f"Interval avg powers: {', '.join(f'{p:.0f}W' for p in powers)}")
|
lines.append(f"Interval avg powers: {', '.join(f'{p:.0f}W' for p in powers)}")
|
||||||
|
|
||||||
|
# Planned workout from training plan
|
||||||
|
if planned_workout:
|
||||||
|
lines.append("\n--- PLANNED WORKOUT ---")
|
||||||
|
lines.append(f"Planned type: {planned_workout.get('workout_type', '?')}")
|
||||||
|
lines.append(f"Planned title: {planned_workout.get('title', '?')}")
|
||||||
|
if planned_workout.get("description"):
|
||||||
|
lines.append(f"Planned description: {planned_workout['description']}")
|
||||||
|
if planned_workout.get("duration_minutes"):
|
||||||
|
lines.append(f"Planned duration: {planned_workout['duration_minutes']} min")
|
||||||
|
if planned_workout.get("target_tss"):
|
||||||
|
lines.append(f"Planned TSS: {planned_workout['target_tss']}")
|
||||||
|
if planned_workout.get("target_if"):
|
||||||
|
lines.append(f"Planned IF: {planned_workout['target_if']}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
async def generate_summary(activity: Activity, rider_ftp: float | None = None) -> str:
|
async def generate_summary(
|
||||||
prompt = _build_activity_prompt(activity, rider_ftp)
|
activity: Activity,
|
||||||
|
rider_ftp: float | None = None,
|
||||||
|
planned_workout: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
prompt = _build_activity_prompt(activity, rider_ftp, planned_workout)
|
||||||
messages = [{"role": "user", "text": prompt}]
|
messages = [{"role": "user", "text": prompt}]
|
||||||
return await chat_async(messages, system_instruction=SYSTEM_PROMPT, temperature=0.5)
|
system = SYSTEM_PROMPT_WITH_PLAN if planned_workout else SYSTEM_PROMPT
|
||||||
|
return await chat_async(messages, system_instruction=system, temperature=0.5)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from '../composables/useApi'
|
import { useApi } from '../composables/useApi'
|
||||||
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, FitnessEntry, DiaryEntry, WeeklyStats, PersonalRecord } from '../types/models'
|
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, FitnessEntry, DiaryEntry, WeeklyStats, PersonalRecord, CalendarDay, MonthlyTrend, FtpDetection, WeeklyDigest, ProgressSummary } from '../types/models'
|
||||||
|
|
||||||
export const useActivitiesStore = defineStore('activities', () => {
|
export const useActivitiesStore = defineStore('activities', () => {
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
@@ -49,8 +49,8 @@ export const useActivitiesStore = defineStore('activities', () => {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAiSummary(id: string): Promise<string> {
|
async function fetchAiSummary(id: string, force = false): Promise<string> {
|
||||||
const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`)
|
const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`, { force })
|
||||||
return data.summary
|
return data.summary
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,5 +85,35 @@ export const useActivitiesStore = defineStore('activities', () => {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
return { activities, total, loading, fetchActivities, fetchActivity, fetchStream, fetchZones, fetchPowerCurve, uploadFit, fetchAiSummary, fetchFitness, deleteActivity, fetchDiary, updateDiary, fetchWeeklyStats, fetchPersonalRecords }
|
async function fetchActivityCalendar(months = 12): Promise<CalendarDay[]> {
|
||||||
|
const { data } = await api.get<CalendarDay[]>('/rider/activity-calendar', { params: { months } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMonthlyTrends(months = 12): Promise<MonthlyTrend[]> {
|
||||||
|
const { data } = await api.get<MonthlyTrend[]>('/rider/monthly-trends', { params: { months } })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectFtp(): Promise<FtpDetection> {
|
||||||
|
const { data } = await api.get<FtpDetection>('/rider/detect-ftp')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWeeklyDigest(): Promise<WeeklyDigest> {
|
||||||
|
const { data } = await api.get<WeeklyDigest>('/rider/weekly-digest')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProgressSummary(): Promise<ProgressSummary> {
|
||||||
|
const { data } = await api.get<ProgressSummary>('/rider/progress-summary')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAiProgress(): Promise<string> {
|
||||||
|
const { data } = await api.get<{ analysis: string }>('/rider/ai-progress')
|
||||||
|
return data.analysis
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activities, total, loading, fetchActivities, fetchActivity, fetchStream, fetchZones, fetchPowerCurve, uploadFit, fetchAiSummary, fetchFitness, deleteActivity, fetchDiary, updateDiary, fetchWeeklyStats, fetchPersonalRecords, fetchActivityCalendar, fetchMonthlyTrends, detectFtp, fetchWeeklyDigest, fetchProgressSummary, fetchAiProgress }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -208,6 +208,75 @@ export interface ComplianceWeek {
|
|||||||
days: ComplianceDayStatus[]
|
days: ComplianceDayStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CalendarDay {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
duration: number
|
||||||
|
tss: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyTrend {
|
||||||
|
month: string
|
||||||
|
rides: number
|
||||||
|
hours: number
|
||||||
|
distance_km: number
|
||||||
|
avg_power: number | null
|
||||||
|
avg_np: number | null
|
||||||
|
avg_hr: number | null
|
||||||
|
tss: number
|
||||||
|
w_per_kg: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FtpDetection {
|
||||||
|
detected_ftp: number | null
|
||||||
|
best_20min_power: number | null
|
||||||
|
activity_id: string | null
|
||||||
|
activity_name: string | null
|
||||||
|
date: string | null
|
||||||
|
current_ftp: number | null
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyDigest {
|
||||||
|
digest: string | null
|
||||||
|
message?: string
|
||||||
|
week_start: string
|
||||||
|
week_end: string
|
||||||
|
total_activities: number
|
||||||
|
total_hours: number
|
||||||
|
total_tss: number
|
||||||
|
total_distance_km: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodStats {
|
||||||
|
rides: number
|
||||||
|
hours: number
|
||||||
|
distance_km: number
|
||||||
|
tss: number
|
||||||
|
avg_power: number | null
|
||||||
|
avg_np: number | null
|
||||||
|
avg_hr: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FitnessSnapshot {
|
||||||
|
ctl: number | null
|
||||||
|
atl: number | null
|
||||||
|
tsb: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressSummary {
|
||||||
|
this_week: PeriodStats
|
||||||
|
last_week: PeriodStats
|
||||||
|
this_month: PeriodStats
|
||||||
|
last_month: PeriodStats
|
||||||
|
fitness: FitnessSnapshot
|
||||||
|
fitness_7d_ago: FitnessSnapshot
|
||||||
|
total_activities: number
|
||||||
|
days_since_last_activity: number | null
|
||||||
|
ftp: number | null
|
||||||
|
weight: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface TodayWorkout {
|
export interface TodayWorkout {
|
||||||
plan_id: string
|
plan_id: string
|
||||||
plan_goal: string
|
plan_goal: string
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { onMounted, ref, computed, nextTick } from 'vue'
|
import { onMounted, ref, computed, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, DiaryEntry } from '../types/models'
|
import { useCoachingStore } from '../stores/coaching'
|
||||||
|
import type { Activity, DataPoint, ZonesResponse, PowerCurveResponse, DiaryEntry, TrainingPlan } from '../types/models'
|
||||||
import Card from 'primevue/card'
|
import Card from 'primevue/card'
|
||||||
import Tag from 'primevue/tag'
|
import Tag from 'primevue/tag'
|
||||||
import DataTable from 'primevue/datatable'
|
import DataTable from 'primevue/datatable'
|
||||||
@@ -27,6 +28,7 @@ use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, Lege
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useActivitiesStore()
|
const store = useActivitiesStore()
|
||||||
|
const coaching = useCoachingStore()
|
||||||
|
|
||||||
const activity = ref<Activity | null>(null)
|
const activity = ref<Activity | null>(null)
|
||||||
const stream = ref<DataPoint[]>([])
|
const stream = ref<DataPoint[]>([])
|
||||||
@@ -41,6 +43,62 @@ const diary = ref<DiaryEntry>({ rider_notes: null, mood: null, rpe: null, sleep_
|
|||||||
const diarySaving = ref(false)
|
const diarySaving = ref(false)
|
||||||
const diarySaved = ref(false)
|
const diarySaved = ref(false)
|
||||||
|
|
||||||
|
// Plan linking
|
||||||
|
const activePlan = ref<TrainingPlan | null>(null)
|
||||||
|
const linkMenuOpen = ref(false)
|
||||||
|
const linking = ref(false)
|
||||||
|
|
||||||
|
const planDayOptions = computed(() => {
|
||||||
|
if (!activePlan.value?.weeks) return []
|
||||||
|
const options: { label: string; week: number; day: string }[] = []
|
||||||
|
for (const week of activePlan.value.weeks) {
|
||||||
|
for (const d of week.days) {
|
||||||
|
if (d.workout_type === 'rest') continue
|
||||||
|
options.push({
|
||||||
|
label: `Нед. ${week.week_number}, ${dayLabelMap[d.day] || d.day} — ${d.title || d.workout_type}`,
|
||||||
|
week: week.week_number,
|
||||||
|
day: d.day,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const dayLabelMap: Record<string, string> = {
|
||||||
|
monday: 'Пн', tuesday: 'Вт', wednesday: 'Ср', thursday: 'Чт',
|
||||||
|
friday: 'Пт', saturday: 'Сб', sunday: 'Вс',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkToPlan(option: { week: number; day: string }) {
|
||||||
|
if (!activity.value || !activePlan.value) return
|
||||||
|
linking.value = true
|
||||||
|
try {
|
||||||
|
await coaching.linkActivity(activity.value.id, activePlan.value.id, option.week, option.day)
|
||||||
|
activity.value.training_plan_id = activePlan.value.id
|
||||||
|
activity.value.plan_week = option.week
|
||||||
|
activity.value.plan_day = option.day
|
||||||
|
linkMenuOpen.value = false
|
||||||
|
// Clear cached summary so re-generation picks up plan context
|
||||||
|
aiSummary.value = ''
|
||||||
|
} finally {
|
||||||
|
linking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkFromPlan() {
|
||||||
|
if (!activity.value) return
|
||||||
|
linking.value = true
|
||||||
|
try {
|
||||||
|
await coaching.unlinkActivity(activity.value.id)
|
||||||
|
activity.value.training_plan_id = null
|
||||||
|
activity.value.plan_week = null
|
||||||
|
activity.value.plan_day = null
|
||||||
|
aiSummary.value = ''
|
||||||
|
} finally {
|
||||||
|
linking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const moods = [
|
const moods = [
|
||||||
{ label: 'Great', value: 'great' },
|
{ label: 'Great', value: 'great' },
|
||||||
{ label: 'Good', value: 'good' },
|
{ label: 'Good', value: 'good' },
|
||||||
@@ -281,6 +339,19 @@ async function loadAiSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function regenerateSummary() {
|
||||||
|
if (!activity.value) return
|
||||||
|
aiLoading.value = true
|
||||||
|
aiSummary.value = ''
|
||||||
|
try {
|
||||||
|
aiSummary.value = await store.fetchAiSummary(activity.value.id, true)
|
||||||
|
} catch {
|
||||||
|
aiSummary.value = 'Failed to generate summary. Check your Gemini API key.'
|
||||||
|
} finally {
|
||||||
|
aiLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveDiary() {
|
async function saveDiary() {
|
||||||
if (!activity.value) return
|
if (!activity.value) return
|
||||||
diarySaving.value = true
|
diarySaving.value = true
|
||||||
@@ -301,6 +372,114 @@ async function handleDelete() {
|
|||||||
router.push({ name: 'activities' })
|
router.push({ name: 'activities' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shareActivity() {
|
||||||
|
if (!activity.value) return
|
||||||
|
const a = activity.value
|
||||||
|
const m = a.metrics
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const W = 800, H = 420
|
||||||
|
canvas.width = W
|
||||||
|
canvas.height = H
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
|
||||||
|
// Background gradient
|
||||||
|
const grad = ctx.createLinearGradient(0, 0, W, H)
|
||||||
|
grad.addColorStop(0, '#0f172a')
|
||||||
|
grad.addColorStop(1, '#1e3a5f')
|
||||||
|
ctx.fillStyle = grad
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
// Accent line
|
||||||
|
ctx.fillStyle = '#3b82f6'
|
||||||
|
ctx.fillRect(0, 0, W, 4)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = 'bold 28px system-ui, -apple-system, sans-serif'
|
||||||
|
ctx.fillText(a.name || 'Ride', 40, 60)
|
||||||
|
|
||||||
|
// Date & type
|
||||||
|
ctx.fillStyle = '#94a3b8'
|
||||||
|
ctx.font = '16px system-ui, -apple-system, sans-serif'
|
||||||
|
const dateStr = new Date(a.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
|
ctx.fillText(`${dateStr} • ${a.activity_type}`, 40, 90)
|
||||||
|
|
||||||
|
// Metrics grid
|
||||||
|
const metrics: { label: string; value: string; unit: string }[] = []
|
||||||
|
metrics.push({ label: 'Duration', value: formatDuration(a.duration), unit: '' })
|
||||||
|
if (a.distance) metrics.push({ label: 'Distance', value: (a.distance / 1000).toFixed(1), unit: 'km' })
|
||||||
|
if (a.elevation_gain) metrics.push({ label: 'Elevation', value: `${a.elevation_gain.toFixed(0)}`, unit: 'm' })
|
||||||
|
if (m?.avg_power) metrics.push({ label: 'Avg Power', value: `${m.avg_power}`, unit: 'W' })
|
||||||
|
if (m?.normalized_power) metrics.push({ label: 'NP', value: `${m.normalized_power}`, unit: 'W' })
|
||||||
|
if (m?.tss) metrics.push({ label: 'TSS', value: `${m.tss}`, unit: '' })
|
||||||
|
if (m?.intensity_factor) metrics.push({ label: 'IF', value: `${m.intensity_factor}`, unit: '' })
|
||||||
|
if (m?.avg_hr) metrics.push({ label: 'Avg HR', value: `${m.avg_hr}`, unit: 'bpm' })
|
||||||
|
|
||||||
|
const cols = Math.min(metrics.length, 4)
|
||||||
|
const boxW = (W - 80) / cols
|
||||||
|
const startY = 130
|
||||||
|
|
||||||
|
metrics.forEach((item, i) => {
|
||||||
|
const row = Math.floor(i / cols)
|
||||||
|
const col = i % cols
|
||||||
|
const x = 40 + col * boxW
|
||||||
|
const y = startY + row * 110
|
||||||
|
|
||||||
|
// Box background
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.06)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, boxW - 12, 90, 10)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#94a3b8'
|
||||||
|
ctx.font = '12px system-ui, -apple-system, sans-serif'
|
||||||
|
ctx.fillText(item.label.toUpperCase(), x + 14, y + 28)
|
||||||
|
|
||||||
|
// Value
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = 'bold 28px system-ui, -apple-system, sans-serif'
|
||||||
|
ctx.fillText(item.value, x + 14, y + 62)
|
||||||
|
|
||||||
|
// Unit
|
||||||
|
if (item.unit) {
|
||||||
|
const valWidth = ctx.measureText(item.value).width
|
||||||
|
ctx.fillStyle = '#64748b'
|
||||||
|
ctx.font = '14px system-ui, -apple-system, sans-serif'
|
||||||
|
ctx.fillText(item.unit, x + 14 + valWidth + 4, y + 62)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
ctx.fillStyle = '#475569'
|
||||||
|
ctx.font = '14px system-ui, -apple-system, sans-serif'
|
||||||
|
ctx.fillText('VeloBrain', 40, H - 30)
|
||||||
|
ctx.fillStyle = '#334155'
|
||||||
|
ctx.fillText('velobrain.app', W - 140, H - 30)
|
||||||
|
|
||||||
|
// Export
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (!blob) return
|
||||||
|
if (navigator.share && navigator.canShare?.({ files: [new File([blob], 'activity.png', { type: 'image/png' })] })) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: a.name || 'My Ride',
|
||||||
|
files: [new File([blob], 'activity.png', { type: 'image/png' })],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} catch { /* fallback to download */ }
|
||||||
|
}
|
||||||
|
// Fallback: download
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `${(a.name || 'activity').replace(/\s+/g, '_')}.png`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}, 'image/png')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const id = route.params.id as string
|
const id = route.params.id as string
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +499,11 @@ onMounted(async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load active plan for linking
|
||||||
|
try {
|
||||||
|
activePlan.value = await coaching.fetchActivePlan()
|
||||||
|
} catch { /* no plan */ }
|
||||||
|
|
||||||
// Init map after loading=false triggers DOM render of #route-map
|
// Init map after loading=false triggers DOM render of #route-map
|
||||||
if (stream.value.some(dp => dp.latitude && dp.longitude)) {
|
if (stream.value.some(dp => dp.latitude && dp.longitude)) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -341,8 +525,11 @@ onMounted(async () => {
|
|||||||
<h1 class="text-2xl font-semibold">{{ activity.name || 'Ride' }}</h1>
|
<h1 class="text-2xl font-semibold">{{ activity.name || 'Ride' }}</h1>
|
||||||
<Tag :value="activity.activity_type" severity="info" />
|
<Tag :value="activity.activity_type" severity="info" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button icon="pi pi-share-alt" severity="secondary" text rounded size="small" title="Share activity card" @click="shareActivity" />
|
||||||
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="handleDelete" />
|
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="handleDelete" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Key metrics -->
|
<!-- Key metrics -->
|
||||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-2 md:gap-3 mb-6">
|
<div class="grid grid-cols-3 md:grid-cols-6 gap-2 md:gap-3 mb-6">
|
||||||
@@ -431,22 +618,78 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- Plan link -->
|
||||||
|
<Card v-if="activity.training_plan_id" class="mb-4 bg-blue-50 border-blue-200">
|
||||||
|
<template #content>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i class="pi pi-calendar-check text-blue-500 text-lg"></i>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-blue-800 text-sm">Тренировка по плану</p>
|
||||||
|
<p class="text-blue-600 text-xs">Неделя {{ activity.plan_week }}, {{ dayLabelMap[activity.plan_day || ''] || activity.plan_day }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button icon="pi pi-times" text rounded severity="secondary" size="small" :loading="linking" @click="unlinkFromPlan" title="Отвязать от плана" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card v-else-if="activePlan" class="mb-4">
|
||||||
|
<template #content>
|
||||||
|
<div v-if="!linkMenuOpen" class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<i class="pi pi-link text-surface-400 text-lg"></i>
|
||||||
|
<p class="text-surface-500 text-sm">Активность не привязана к тренировочному плану</p>
|
||||||
|
</div>
|
||||||
|
<Button label="Привязать к плану" icon="pi pi-calendar-plus" size="small" severity="secondary" outlined @click="linkMenuOpen = true" />
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<p class="font-semibold text-sm">Выберите тренировку из плана</p>
|
||||||
|
<Button icon="pi pi-times" text rounded severity="secondary" size="small" @click="linkMenuOpen = false" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1 max-h-60 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="opt in planDayOptions"
|
||||||
|
:key="`${opt.week}-${opt.day}`"
|
||||||
|
class="text-left px-3 py-2 rounded-lg hover:bg-primary/10 text-sm transition-colors border border-transparent hover:border-primary/20"
|
||||||
|
:disabled="linking"
|
||||||
|
@click="linkToPlan(opt)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</button>
|
||||||
|
<p v-if="planDayOptions.length === 0" class="text-surface-400 text-sm px-3 py-2">Нет доступных тренировок в плане</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- AI Summary -->
|
<!-- AI Summary -->
|
||||||
<Card class="mb-6">
|
<Card class="mb-6">
|
||||||
<template #title>
|
<template #title>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="pi pi-sparkles text-primary"></i>
|
<i class="pi pi-sparkles text-primary"></i>
|
||||||
AI Coach Summary
|
{{ activity.training_plan_id ? 'AI Анализ и соответствие плану' : 'AI Coach Summary' }}
|
||||||
|
</div>
|
||||||
|
<Button v-if="aiSummary" icon="pi pi-refresh" text rounded severity="secondary" size="small" @click="regenerateSummary" :loading="aiLoading" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div v-if="aiSummary" v-html="aiSummary" class="text-surface-700 leading-relaxed whitespace-pre-line"></div>
|
<div v-if="aiSummary" v-html="aiSummary" class="text-surface-700 leading-relaxed whitespace-pre-line"></div>
|
||||||
<div v-else-if="aiLoading" class="flex items-center gap-3">
|
<div v-else-if="aiLoading" class="flex items-center gap-3">
|
||||||
<ProgressSpinner style="width: 20px; height: 20px" />
|
<ProgressSpinner style="width: 20px; height: 20px" />
|
||||||
<span class="text-surface-500 text-sm">Analyzing your ride...</span>
|
<span class="text-surface-500 text-sm">{{ activity.training_plan_id ? 'Анализирую соответствие плану...' : 'Analyzing your ride...' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<Button label="Generate AI Analysis" icon="pi pi-sparkles" severity="secondary" outlined size="small" @click="loadAiSummary" />
|
<Button
|
||||||
|
:label="activity.training_plan_id ? 'Анализировать выполнение плана' : 'Generate AI Analysis'"
|
||||||
|
icon="pi pi-sparkles"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
size="small"
|
||||||
|
@click="loadAiSummary"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ onMounted(async () => {
|
|||||||
await coaching.fetchTodayWorkout()
|
await coaching.fetchTodayWorkout()
|
||||||
|
|
||||||
if (plan.value) {
|
if (plan.value) {
|
||||||
|
activeTab.value = 'plan'
|
||||||
await loadCompliance()
|
await loadCompliance()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,19 +3,20 @@ import { onMounted, computed, ref } from 'vue'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useActivitiesStore } from '../stores/activities'
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
import { useCoachingStore } from '../stores/coaching'
|
import { useCoachingStore } from '../stores/coaching'
|
||||||
import type { FitnessEntry, WeeklyStats, PersonalRecord, TodayWorkout } from '../types/models'
|
import type { FitnessEntry, WeeklyStats, PersonalRecord, TodayWorkout, CalendarDay, MonthlyTrend, WeeklyDigest, ProgressSummary } from '../types/models'
|
||||||
import Card from 'primevue/card'
|
import Card from 'primevue/card'
|
||||||
import Tag from 'primevue/tag'
|
import Tag from 'primevue/tag'
|
||||||
|
import Button from 'primevue/button'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { use } from 'echarts/core'
|
import { use } from 'echarts/core'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { LineChart, BarChart } from 'echarts/charts'
|
import { LineChart, BarChart, HeatmapChart, GaugeChart } from 'echarts/charts'
|
||||||
import {
|
import {
|
||||||
TooltipComponent, LegendComponent, GridComponent,
|
TooltipComponent, LegendComponent, GridComponent, VisualMapComponent, CalendarComponent,
|
||||||
} from 'echarts/components'
|
} from 'echarts/components'
|
||||||
import VChart from 'vue-echarts'
|
import VChart from 'vue-echarts'
|
||||||
|
|
||||||
use([CanvasRenderer, LineChart, BarChart, TooltipComponent, LegendComponent, GridComponent])
|
use([CanvasRenderer, LineChart, BarChart, HeatmapChart, GaugeChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent, CalendarComponent])
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const store = useActivitiesStore()
|
const store = useActivitiesStore()
|
||||||
@@ -27,6 +28,44 @@ const fitness = ref<FitnessEntry[]>([])
|
|||||||
const weeklyStats = ref<WeeklyStats[]>([])
|
const weeklyStats = ref<WeeklyStats[]>([])
|
||||||
const records = ref<PersonalRecord[]>([])
|
const records = ref<PersonalRecord[]>([])
|
||||||
const todayWorkout = ref<TodayWorkout | null>(null)
|
const todayWorkout = ref<TodayWorkout | null>(null)
|
||||||
|
const calendarDays = ref<CalendarDay[]>([])
|
||||||
|
const monthlyTrends = ref<MonthlyTrend[]>([])
|
||||||
|
const weeklyDigest = ref<WeeklyDigest | null>(null)
|
||||||
|
const digestLoading = ref(false)
|
||||||
|
const progress = ref<ProgressSummary | null>(null)
|
||||||
|
const aiProgress = ref('')
|
||||||
|
const aiProgressLoading = ref(false)
|
||||||
|
|
||||||
|
// Delta helpers
|
||||||
|
function delta(current: number | null | undefined, previous: number | null | undefined): number | null {
|
||||||
|
if (current == null || previous == null) return null
|
||||||
|
return current - previous
|
||||||
|
}
|
||||||
|
|
||||||
|
function deltaPercent(current: number | null | undefined, previous: number | null | undefined): number | null {
|
||||||
|
if (current == null || previous == null || previous === 0) return null
|
||||||
|
return Math.round(((current - previous) / previous) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendClass(d: number | null, inverted = false): string {
|
||||||
|
if (d == null || d === 0) return 'text-surface-400'
|
||||||
|
const positive = inverted ? d < 0 : d > 0
|
||||||
|
return positive ? 'text-green-600' : 'text-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendIcon(d: number | null): string {
|
||||||
|
if (d == null || d === 0) return ''
|
||||||
|
return d > 0 ? 'pi pi-arrow-up' : 'pi pi-arrow-down'
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitnessZoneLabel(tsb: number | null): { label: string; color: string; description: string } {
|
||||||
|
if (tsb == null) return { label: '—', color: 'text-surface-400', description: '' }
|
||||||
|
if (tsb > 25) return { label: 'Восстановлен', color: 'text-emerald-600', description: 'Высокая свежесть, готовность к интенсивной работе' }
|
||||||
|
if (tsb > 5) return { label: 'Свежий', color: 'text-green-600', description: 'Хорошая форма для качественных тренировок' }
|
||||||
|
if (tsb > -10) return { label: 'Оптимально', color: 'text-blue-600', description: 'Баланс нагрузки и восстановления' }
|
||||||
|
if (tsb > -25) return { label: 'Устал', color: 'text-amber-600', description: 'Накопленная усталость, следите за восстановлением' }
|
||||||
|
return { label: 'Перегрузка', color: 'text-red-600', description: 'Высокий риск перетренированности' }
|
||||||
|
}
|
||||||
|
|
||||||
function formatDurationLabel(seconds: number): string {
|
function formatDurationLabel(seconds: number): string {
|
||||||
if (seconds < 60) return `${seconds}s`
|
if (seconds < 60) return `${seconds}s`
|
||||||
@@ -46,9 +85,11 @@ function formatDistance(meters: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Charts
|
||||||
|
|
||||||
function buildFitnessChart() {
|
function buildFitnessChart() {
|
||||||
if (fitness.value.length === 0) return null
|
if (fitness.value.length === 0) return null
|
||||||
|
|
||||||
@@ -67,7 +108,7 @@ function buildFitnessChart() {
|
|||||||
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value.toFixed(1)}</b>`).join('<br/>')
|
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value.toFixed(1)}</b>`).join('<br/>')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { data: ['Fitness (CTL)', 'Fatigue (ATL)', 'Form (TSB)'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
legend: { data: ['Фитнес (CTL)', 'Усталость (ATL)', 'Форма (TSB)'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||||
grid: { left: 40, right: 20, top: 40, bottom: 30 },
|
grid: { left: 40, right: 20, top: 40, bottom: 30 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -77,10 +118,10 @@ function buildFitnessChart() {
|
|||||||
},
|
},
|
||||||
yAxis: { type: 'value', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
|
yAxis: { type: 'value', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
|
||||||
series: [
|
series: [
|
||||||
{ name: 'Fitness (CTL)', type: 'line', data: ctlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#3b82f6' } },
|
{ name: 'Фитнес (CTL)', type: 'line', data: ctlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#3b82f6' } },
|
||||||
{ name: 'Fatigue (ATL)', type: 'line', data: atlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#ef4444' } },
|
{ name: 'Усталость (ATL)', type: 'line', data: atlData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#ef4444' } },
|
||||||
{
|
{
|
||||||
name: 'Form (TSB)', type: 'line', data: tsbData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#10b981' },
|
name: 'Форма (TSB)', type: 'line', data: tsbData, showSymbol: false, lineStyle: { width: 2 }, itemStyle: { color: '#10b981' },
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: {
|
color: {
|
||||||
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
|
||||||
@@ -111,11 +152,11 @@ function buildWeeklyChart() {
|
|||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
formatter: (params: any) => {
|
formatter: (params: any) => {
|
||||||
const week = params[0].name
|
const week = params[0].name
|
||||||
return `<b>Week of ${week}</b><br/>` +
|
return `<b>Неделя ${week}</b><br/>` +
|
||||||
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value}</b>`).join('<br/>')
|
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value}</b>`).join('<br/>')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { data: ['Distance (km)', 'TSS', 'Hours'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
legend: { data: ['Дистанция (км)', 'TSS', 'Часы'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||||
grid: { left: 40, right: 40, top: 40, bottom: 30 },
|
grid: { left: 40, right: 40, top: 40, bottom: 30 },
|
||||||
xAxis: { type: 'category', data: weeks, axisLabel: { color: '#6b7280', fontSize: 10 } },
|
xAxis: { type: 'category', data: weeks, axisLabel: { color: '#6b7280', fontSize: 10 } },
|
||||||
yAxis: [
|
yAxis: [
|
||||||
@@ -124,7 +165,7 @@ function buildWeeklyChart() {
|
|||||||
],
|
],
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'Distance (km)', type: 'bar', data: weeklyStats.value.map(w => w.distance),
|
name: 'Дистанция (км)', type: 'bar', data: weeklyStats.value.map(w => w.distance),
|
||||||
itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
|
itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -132,7 +173,7 @@ function buildWeeklyChart() {
|
|||||||
itemStyle: { color: '#a855f7', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
|
itemStyle: { color: '#a855f7', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 30,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Hours', type: 'line', yAxisIndex: 1,
|
name: 'Часы', type: 'line', yAxisIndex: 1,
|
||||||
data: weeklyStats.value.map(w => +(w.duration / 3600).toFixed(1)),
|
data: weeklyStats.value.map(w => +(w.duration / 3600).toFixed(1)),
|
||||||
showSymbol: true, symbolSize: 6, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
|
showSymbol: true, symbolSize: 6, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
|
||||||
},
|
},
|
||||||
@@ -140,28 +181,146 @@ function buildWeeklyChart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCalendarChart() {
|
||||||
|
if (calendarDays.value.length === 0) return null
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const yearAgo = new Date(now)
|
||||||
|
yearAgo.setFullYear(yearAgo.getFullYear() - 1)
|
||||||
|
const rangeStart = yearAgo.toISOString().slice(0, 10)
|
||||||
|
const rangeEnd = now.toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
const data = calendarDays.value.map(d => [d.date, d.tss || d.count])
|
||||||
|
const maxVal = Math.max(...data.map(d => d[1] as number), 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tooltip: {
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const d = calendarDays.value.find(c => c.date === params.data[0])
|
||||||
|
if (!d) return params.data[0]
|
||||||
|
return `<b>${params.data[0]}</b><br/>Тренировок: ${d.count}<br/>Время: ${Math.round(d.duration / 60)} мин<br/>TSS: ${d.tss}`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
visualMap: {
|
||||||
|
min: 0,
|
||||||
|
max: maxVal,
|
||||||
|
show: false,
|
||||||
|
inRange: { color: ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'] },
|
||||||
|
},
|
||||||
|
calendar: {
|
||||||
|
range: [rangeStart, rangeEnd],
|
||||||
|
cellSize: [13, 13],
|
||||||
|
top: 30,
|
||||||
|
left: 40,
|
||||||
|
right: 10,
|
||||||
|
itemStyle: { borderWidth: 2, borderColor: '#fff', borderRadius: 2 },
|
||||||
|
yearLabel: { show: false },
|
||||||
|
monthLabel: { color: '#6b7280', fontSize: 10 },
|
||||||
|
dayLabel: { color: '#6b7280', fontSize: 9, firstDay: 1, nameMap: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'] },
|
||||||
|
splitLine: { show: false },
|
||||||
|
},
|
||||||
|
series: [{
|
||||||
|
type: 'heatmap',
|
||||||
|
coordinateSystem: 'calendar',
|
||||||
|
data,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthlyTrendsChart() {
|
||||||
|
if (monthlyTrends.value.length === 0) return null
|
||||||
|
|
||||||
|
const months = monthlyTrends.value.map(m => m.month)
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const month = params[0].name
|
||||||
|
return `<b>${month}</b><br/>` +
|
||||||
|
params.map((p: any) => `${p.marker} ${p.seriesName}: <b>${p.value ?? '—'}</b>`).join('<br/>')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { data: ['Средняя мощность (W)', 'Средний пульс', 'W/kg', 'Часы'], top: 0, textStyle: { color: '#6b7280', fontSize: 11 } },
|
||||||
|
grid: { left: 50, right: 50, top: 40, bottom: 30 },
|
||||||
|
xAxis: { type: 'category', data: months, axisLabel: { color: '#6b7280', fontSize: 10 } },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: 'W / bpm', axisLabel: { color: '#6b7280' }, splitLine: { lineStyle: { color: '#e5e7eb' } } },
|
||||||
|
{ type: 'value', name: 'W/kg / ч', axisLabel: { color: '#6b7280' }, splitLine: { show: false } },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Средняя мощность (W)', type: 'line', data: monthlyTrends.value.map(m => m.avg_power),
|
||||||
|
showSymbol: true, symbolSize: 6, itemStyle: { color: '#3b82f6' }, lineStyle: { width: 2 },
|
||||||
|
connectNulls: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Средний пульс', type: 'line', data: monthlyTrends.value.map(m => m.avg_hr),
|
||||||
|
showSymbol: true, symbolSize: 6, itemStyle: { color: '#ef4444' }, lineStyle: { width: 2 },
|
||||||
|
connectNulls: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'W/kg', type: 'line', yAxisIndex: 1, data: monthlyTrends.value.map(m => m.w_per_kg),
|
||||||
|
showSymbol: true, symbolSize: 6, itemStyle: { color: '#10b981' }, lineStyle: { width: 2 },
|
||||||
|
connectNulls: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Часы', type: 'bar', yAxisIndex: 1, data: monthlyTrends.value.map(m => m.hours),
|
||||||
|
itemStyle: { color: 'rgba(168,85,247,0.3)', borderRadius: [4, 4, 0, 0] }, barMaxWidth: 25,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDigest() {
|
||||||
|
digestLoading.value = true
|
||||||
|
try {
|
||||||
|
weeklyDigest.value = await store.fetchWeeklyDigest()
|
||||||
|
} catch {
|
||||||
|
// digest not available
|
||||||
|
} finally {
|
||||||
|
digestLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAiProgress() {
|
||||||
|
aiProgressLoading.value = true
|
||||||
|
try {
|
||||||
|
aiProgress.value = await store.fetchAiProgress()
|
||||||
|
} catch {
|
||||||
|
aiProgress.value = ''
|
||||||
|
} finally {
|
||||||
|
aiProgressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitnessZone = computed(() => fitnessZoneLabel(progress.value?.fitness?.tsb ?? null))
|
||||||
|
|
||||||
|
const weekVsWeekTss = computed(() => deltaPercent(progress.value?.this_week?.tss, progress.value?.last_week?.tss))
|
||||||
|
const weekVsWeekHours = computed(() => deltaPercent(progress.value?.this_week?.hours, progress.value?.last_week?.hours))
|
||||||
|
const monthVsMonthTss = computed(() => deltaPercent(progress.value?.this_month?.tss, progress.value?.last_month?.tss))
|
||||||
|
const monthVsMonthPower = computed(() => delta(progress.value?.this_month?.avg_power, progress.value?.last_month?.avg_power))
|
||||||
|
const ctlDelta = computed(() => delta(progress.value?.fitness?.ctl, progress.value?.fitness_7d_ago?.ctl))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
store.fetchActivities(5)
|
// Load everything in parallel
|
||||||
try {
|
const promises: Promise<any>[] = [
|
||||||
fitness.value = await store.fetchFitness(90)
|
store.fetchActivities(5),
|
||||||
} catch {
|
]
|
||||||
// Fitness data may not be available yet
|
|
||||||
}
|
promises.push(
|
||||||
try {
|
store.fetchProgressSummary().then(p => { progress.value = p }).catch(() => {}),
|
||||||
weeklyStats.value = await store.fetchWeeklyStats(8)
|
store.fetchFitness(90).then(f => { fitness.value = f }).catch(() => {}),
|
||||||
} catch {
|
store.fetchWeeklyStats(8).then(w => { weeklyStats.value = w }).catch(() => {}),
|
||||||
// Weekly stats may not be available yet
|
store.fetchPersonalRecords().then(r => { records.value = r }).catch(() => {}),
|
||||||
}
|
coaching.fetchTodayWorkout().then(t => { todayWorkout.value = t }).catch(() => {}),
|
||||||
try {
|
store.fetchActivityCalendar(12).then(c => { calendarDays.value = c }).catch(() => {}),
|
||||||
records.value = await store.fetchPersonalRecords()
|
store.fetchMonthlyTrends(12).then(m => { monthlyTrends.value = m }).catch(() => {}),
|
||||||
} catch {
|
)
|
||||||
// PR data may not be available yet
|
|
||||||
}
|
await Promise.all(promises)
|
||||||
try {
|
|
||||||
todayWorkout.value = await coaching.fetchTodayWorkout()
|
|
||||||
} catch {
|
|
||||||
// No active plan
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -169,8 +328,80 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
|
<h1 class="text-2xl font-semibold mb-6">Dashboard</h1>
|
||||||
|
|
||||||
<!-- Rider stats cards -->
|
<!-- ===== FITNESS STATUS BAR ===== -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-8">
|
<div v-if="progress" class="grid grid-cols-2 md:grid-cols-5 gap-3 mb-6">
|
||||||
|
<!-- Current Form -->
|
||||||
|
<Card class="col-span-2 md:col-span-1">
|
||||||
|
<template #content>
|
||||||
|
<p class="text-surface-500 text-xs uppercase mb-1">Состояние</p>
|
||||||
|
<p class="text-lg font-bold" :class="fitnessZone.color">{{ fitnessZone.label }}</p>
|
||||||
|
<p v-if="progress.fitness?.tsb != null" class="text-xs text-surface-400 mt-1">TSB {{ progress.fitness.tsb }}</p>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- CTL -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-surface-500 text-xs uppercase mb-1">Фитнес (CTL)</p>
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<p class="text-2xl font-bold">{{ progress.fitness?.ctl ?? '—' }}</p>
|
||||||
|
<span v-if="ctlDelta != null" class="text-xs font-medium" :class="trendClass(ctlDelta)">
|
||||||
|
<i :class="trendIcon(ctlDelta)" class="text-[10px]"></i>
|
||||||
|
{{ ctlDelta > 0 ? '+' : '' }}{{ ctlDelta.toFixed(1) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-surface-400">за 7 дней</p>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- This week volume -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-surface-500 text-xs uppercase mb-1">Эта неделя</p>
|
||||||
|
<p class="text-2xl font-bold">{{ progress.this_week.hours }}<span class="text-sm font-normal text-surface-500">ч</span></p>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span v-if="weekVsWeekHours != null" class="text-xs font-medium" :class="trendClass(weekVsWeekHours)">
|
||||||
|
<i :class="trendIcon(weekVsWeekHours)" class="text-[10px]"></i>
|
||||||
|
{{ weekVsWeekHours > 0 ? '+' : '' }}{{ weekVsWeekHours }}%
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-surface-400">vs прошлая</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- This week TSS -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-surface-500 text-xs uppercase mb-1">TSS неделя</p>
|
||||||
|
<p class="text-2xl font-bold">{{ progress.this_week.tss }}</p>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span v-if="weekVsWeekTss != null" class="text-xs font-medium" :class="trendClass(weekVsWeekTss)">
|
||||||
|
<i :class="trendIcon(weekVsWeekTss)" class="text-[10px]"></i>
|
||||||
|
{{ weekVsWeekTss > 0 ? '+' : '' }}{{ weekVsWeekTss }}%
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-surface-400">vs прошлая</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Monthly power change -->
|
||||||
|
<Card>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-surface-500 text-xs uppercase mb-1">Мощность (месяц)</p>
|
||||||
|
<p class="text-2xl font-bold">{{ progress.this_month.avg_power ?? '—' }}<span class="text-sm font-normal text-surface-500">W</span></p>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span v-if="monthVsMonthPower != null" class="text-xs font-medium" :class="trendClass(monthVsMonthPower)">
|
||||||
|
<i :class="trendIcon(monthVsMonthPower)" class="text-[10px]"></i>
|
||||||
|
{{ monthVsMonthPower > 0 ? '+' : '' }}{{ monthVsMonthPower }}W
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-surface-400">vs прошлый</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Static fallback cards when no progress data -->
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
<template #content>
|
<template #content>
|
||||||
<p class="text-surface-500 text-xs uppercase mb-1">FTP</p>
|
<p class="text-surface-500 text-xs uppercase mb-1">FTP</p>
|
||||||
@@ -179,13 +410,13 @@ onMounted(async () => {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<template #content>
|
<template #content>
|
||||||
<p class="text-surface-500 text-xs uppercase mb-1">Weight</p>
|
<p class="text-surface-500 text-xs uppercase mb-1">Вес</p>
|
||||||
<p class="text-2xl font-bold">{{ auth.rider?.weight ?? '—' }} <span class="text-sm text-surface-500 font-normal">kg</span></p>
|
<p class="text-2xl font-bold">{{ auth.rider?.weight ?? '—' }} <span class="text-sm text-surface-500 font-normal">кг</span></p>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<template #content>
|
<template #content>
|
||||||
<p class="text-surface-500 text-xs uppercase mb-1">W/kg</p>
|
<p class="text-surface-500 text-xs uppercase mb-1">W/кг</p>
|
||||||
<p class="text-2xl font-bold">
|
<p class="text-2xl font-bold">
|
||||||
{{ auth.rider?.ftp && auth.rider?.weight ? (auth.rider.ftp / auth.rider.weight).toFixed(2) : '—' }}
|
{{ auth.rider?.ftp && auth.rider?.weight ? (auth.rider.ftp / auth.rider.weight).toFixed(2) : '—' }}
|
||||||
</p>
|
</p>
|
||||||
@@ -193,14 +424,14 @@ onMounted(async () => {
|
|||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<template #content>
|
<template #content>
|
||||||
<p class="text-surface-500 text-xs uppercase mb-1">Total Rides</p>
|
<p class="text-surface-500 text-xs uppercase mb-1">Тренировок</p>
|
||||||
<p class="text-2xl font-bold">{{ store.total }}</p>
|
<p class="text-2xl font-bold">{{ store.total }}</p>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Today's Workout -->
|
<!-- ===== TODAY'S WORKOUT ===== -->
|
||||||
<Card v-if="todayWorkout" class="mb-8 border-l-4 border-l-primary cursor-pointer hover:shadow-md transition-shadow" @click="router.push({ name: 'coach' })">
|
<Card v-if="todayWorkout" class="mb-6 border-l-4 border-l-primary cursor-pointer hover:shadow-md transition-shadow" @click="router.push({ name: 'coach' })">
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -223,12 +454,142 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Personal Records -->
|
<!-- ===== WEEK VS WEEK COMPARISON (detailed) ===== -->
|
||||||
<Card v-if="records.length > 0" class="mb-8">
|
<div v-if="progress && (progress.this_week.rides > 0 || progress.last_week.rides > 0)" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<Card>
|
||||||
|
<template #title>
|
||||||
|
<span class="text-sm">Эта неделя</span>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Тренировок</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.this_week.rides }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Часы</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.this_week.hours }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">TSS</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.this_week.tss }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Дистанция</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.this_week.distance_km }} <span class="text-xs font-normal">км</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Ср. мощность</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.this_week.avg_power ?? '—' }} <span class="text-xs font-normal">W</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Ср. пульс</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.this_week.avg_hr ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<template #title>
|
||||||
|
<span class="text-sm">Прошлая неделя</span>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Тренировок</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.last_week.rides }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Часы</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.last_week.hours }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">TSS</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.last_week.tss }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Дистанция</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.last_week.distance_km }} <span class="text-xs font-normal">км</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Ср. мощность</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.last_week.avg_power ?? '—' }} <span class="text-xs font-normal">W</span></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-surface-500 text-xs">Ср. пульс</p>
|
||||||
|
<p class="text-lg font-bold">{{ progress.last_week.avg_hr ?? '—' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== FITNESS TREND ===== -->
|
||||||
|
<Card v-if="fitness.length > 0" class="mb-6">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-chart-line text-blue-500"></i>
|
||||||
|
Тренд фитнеса
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<VChart :option="buildFitnessChart() ?? undefined" style="height: 280px" autoresize />
|
||||||
|
<div class="flex items-center justify-center gap-6 mt-2 text-xs text-surface-500">
|
||||||
|
<span>CTL — хронич. нагрузка (фитнес)</span>
|
||||||
|
<span>ATL — острая нагрузка (усталость)</span>
|
||||||
|
<span>TSB — баланс формы</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ===== AI PROGRESS ANALYSIS ===== -->
|
||||||
|
<Card class="mb-6">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-sparkles text-violet-500"></i>
|
||||||
|
AI Анализ прогресса
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="!aiProgress"
|
||||||
|
label="Анализировать"
|
||||||
|
icon="pi pi-sparkles"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
:loading="aiProgressLoading"
|
||||||
|
@click="loadAiProgress"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
icon="pi pi-refresh"
|
||||||
|
severity="secondary"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
:loading="aiProgressLoading"
|
||||||
|
@click="loadAiProgress"
|
||||||
|
title="Обновить анализ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div v-if="aiProgressLoading" class="flex items-center justify-center py-8 text-surface-400">
|
||||||
|
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
|
||||||
|
Анализирую данные...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="aiProgress" class="prose prose-sm max-w-none" v-html="aiProgress"></div>
|
||||||
|
<p v-else class="text-surface-400 text-sm">
|
||||||
|
AI проанализирует ваши тренировки за последние месяцы, оценит динамику прогресса и даст рекомендации по развитию.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ===== PERSONAL RECORDS ===== -->
|
||||||
|
<Card v-if="records.length > 0" class="mb-6">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<i class="pi pi-trophy text-amber-500"></i>
|
<i class="pi pi-trophy text-amber-500"></i>
|
||||||
Personal Records
|
Личные рекорды
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
@@ -241,37 +602,105 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
<p class="text-surface-500 text-xs uppercase mb-1">{{ formatDurationLabel(pr.duration) }}</p>
|
<p class="text-surface-500 text-xs uppercase mb-1">{{ formatDurationLabel(pr.duration) }}</p>
|
||||||
<p class="text-xl font-bold">{{ pr.power }}<span class="text-sm font-normal text-surface-500">W</span></p>
|
<p class="text-xl font-bold">{{ pr.power }}<span class="text-sm font-normal text-surface-500">W</span></p>
|
||||||
<p v-if="auth.rider?.weight" class="text-xs text-surface-400">{{ (pr.power / auth.rider.weight).toFixed(1) }} W/kg</p>
|
<p v-if="auth.rider?.weight" class="text-xs text-surface-400">{{ (pr.power / auth.rider.weight).toFixed(1) }} W/кг</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Fitness trend chart -->
|
<!-- ===== WEEKLY VOLUME ===== -->
|
||||||
<Card v-if="fitness.length > 0" class="mb-8">
|
<Card v-if="weeklyStats.length > 0" class="mb-6">
|
||||||
<template #title>Fitness Trend</template>
|
<template #title>
|
||||||
<template #content>
|
<div class="flex items-center gap-2">
|
||||||
<VChart :option="buildFitnessChart() ?? undefined" style="height: 280px" autoresize />
|
<i class="pi pi-chart-bar text-indigo-500"></i>
|
||||||
<div class="flex items-center justify-center gap-6 mt-2 text-xs text-surface-500">
|
Нагрузка по неделям
|
||||||
<span>CTL: chronic training load (fitness)</span>
|
|
||||||
<span>ATL: acute training load (fatigue)</span>
|
|
||||||
<span>TSB: training stress balance (form)</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Weekly summary -->
|
|
||||||
<Card v-if="weeklyStats.length > 0" class="mb-8">
|
|
||||||
<template #title>Weekly Summary</template>
|
|
||||||
<template #content>
|
<template #content>
|
||||||
<VChart :option="buildWeeklyChart() ?? undefined" style="height: 280px" autoresize />
|
<VChart :option="buildWeeklyChart() ?? undefined" style="height: 280px" autoresize />
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Recent rides -->
|
<!-- ===== MONTHLY TRENDS ===== -->
|
||||||
<h2 class="text-lg font-semibold mb-4">Recent Rides</h2>
|
<Card v-if="monthlyTrends.length > 0" class="mb-6">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-chart-line text-blue-500"></i>
|
||||||
|
Тренды по месяцам
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<VChart :option="buildMonthlyTrendsChart() ?? undefined" style="height: 300px" autoresize />
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ===== ACTIVITY HEATMAP ===== -->
|
||||||
|
<Card v-if="calendarDays.length > 0" class="mb-6">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-calendar text-green-600"></i>
|
||||||
|
Календарь активности
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<VChart :option="buildCalendarChart() ?? undefined" style="height: 180px" autoresize />
|
||||||
|
<div class="flex items-center justify-end gap-1 mt-2 text-xs text-surface-400">
|
||||||
|
<span>Меньше</span>
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm" style="background: #ebedf0"></span>
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm" style="background: #9be9a8"></span>
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm" style="background: #40c463"></span>
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm" style="background: #30a14e"></span>
|
||||||
|
<span class="inline-block w-3 h-3 rounded-sm" style="background: #216e39"></span>
|
||||||
|
<span>Больше</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ===== WEEKLY AI DIGEST ===== -->
|
||||||
|
<Card class="mb-6">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<i class="pi pi-sparkles text-purple-500"></i>
|
||||||
|
Еженедельный дайджест
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
v-if="!weeklyDigest"
|
||||||
|
label="Сгенерировать"
|
||||||
|
icon="pi pi-sparkles"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
:loading="digestLoading"
|
||||||
|
@click="loadDigest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div v-if="digestLoading" class="flex items-center justify-center py-8 text-surface-400">
|
||||||
|
<i class="pi pi-spin pi-spinner text-2xl mr-2"></i>
|
||||||
|
Генерирую дайджест...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="weeklyDigest?.digest">
|
||||||
|
<div class="flex items-center gap-4 text-sm text-surface-500 mb-4 flex-wrap">
|
||||||
|
<span>{{ weeklyDigest.week_start }} — {{ weeklyDigest.week_end }}</span>
|
||||||
|
<span>{{ weeklyDigest.total_activities }} трен.</span>
|
||||||
|
<span>{{ weeklyDigest.total_hours }}ч</span>
|
||||||
|
<span>TSS {{ weeklyDigest.total_tss }}</span>
|
||||||
|
<span>{{ weeklyDigest.total_distance_km }} км</span>
|
||||||
|
</div>
|
||||||
|
<div class="prose prose-sm max-w-none" v-html="weeklyDigest.digest"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="weeklyDigest && !weeklyDigest.digest" class="text-surface-400 text-center py-4">
|
||||||
|
{{ weeklyDigest.message || 'Нет тренировок за прошлую неделю' }}
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-surface-400 text-sm">Итоги прошлой недели: объём, ключевые метрики, рекомендации AI-тренера.</p>
|
||||||
|
</template>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- ===== RECENT ACTIVITIES ===== -->
|
||||||
|
<h2 class="text-lg font-semibold mb-4">Последние тренировки</h2>
|
||||||
<div v-if="recentActivities.length === 0" class="text-surface-500">
|
<div v-if="recentActivities.length === 0" class="text-surface-500">
|
||||||
No activities yet. Upload your first .FIT file!
|
Нет тренировок. Загрузите первый .FIT файл!
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col gap-3">
|
<div v-else class="flex flex-col gap-3">
|
||||||
<Card
|
<Card
|
||||||
@@ -284,22 +713,22 @@ onMounted(async () => {
|
|||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold">{{ a.name || 'Ride' }}</p>
|
<p class="font-semibold">{{ a.name || 'Тренировка' }}</p>
|
||||||
<p class="text-surface-500 text-sm">{{ formatDate(a.date) }}</p>
|
<p class="text-surface-500 text-sm">{{ formatDate(a.date) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<Tag :value="a.activity_type" severity="info" class="text-xs" />
|
<Tag :value="a.activity_type" severity="info" class="text-xs" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4 md:gap-6 text-sm flex-wrap">
|
<div class="flex items-center gap-4 md:gap-6 text-sm flex-wrap">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-surface-500 text-xs">Duration</p>
|
<p class="text-surface-500 text-xs">Время</p>
|
||||||
<p class="font-semibold">{{ formatDuration(a.duration) }}</p>
|
<p class="font-semibold">{{ formatDuration(a.duration) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div v-if="a.distance" class="text-center">
|
||||||
<p class="text-surface-500 text-xs">Distance</p>
|
<p class="text-surface-500 text-xs">Дистанция</p>
|
||||||
<p class="font-semibold">{{ formatDistance(a.distance) }}</p>
|
<p class="font-semibold">{{ formatDistance(a.distance) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="a.metrics?.avg_power" class="text-center">
|
<div v-if="a.metrics?.avg_power" class="text-center">
|
||||||
<p class="text-surface-500 text-xs">Power</p>
|
<p class="text-surface-500 text-xs">Мощность</p>
|
||||||
<p class="font-semibold">{{ a.metrics.avg_power }}W</p>
|
<p class="font-semibold">{{ a.metrics.avg_power }}W</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="a.metrics?.tss" class="text-center">
|
<div v-if="a.metrics?.tss" class="text-center">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useActivitiesStore } from '../stores/activities'
|
||||||
|
import type { FtpDetection } from '../types/models'
|
||||||
import Card from 'primevue/card'
|
import Card from 'primevue/card'
|
||||||
import InputNumber from 'primevue/inputnumber'
|
import InputNumber from 'primevue/inputnumber'
|
||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
@@ -8,8 +10,10 @@ import Select from 'primevue/select'
|
|||||||
import Textarea from 'primevue/textarea'
|
import Textarea from 'primevue/textarea'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Message from 'primevue/message'
|
import Message from 'primevue/message'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const store = useActivitiesStore()
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -22,6 +26,27 @@ const form = ref({
|
|||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const success = ref(false)
|
const success = ref(false)
|
||||||
|
const detectingFtp = ref(false)
|
||||||
|
const ftpDetection = ref<FtpDetection | null>(null)
|
||||||
|
const showFtpDialog = ref(false)
|
||||||
|
|
||||||
|
async function detectFtp() {
|
||||||
|
detectingFtp.value = true
|
||||||
|
try {
|
||||||
|
ftpDetection.value = await store.detectFtp()
|
||||||
|
showFtpDialog.value = true
|
||||||
|
} finally {
|
||||||
|
detectingFtp.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyDetectedFtp() {
|
||||||
|
if (ftpDetection.value?.detected_ftp) {
|
||||||
|
form.value.ftp = ftpDetection.value.detected_ftp
|
||||||
|
showFtpDialog.value = false
|
||||||
|
await save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const experienceLevels = [
|
const experienceLevels = [
|
||||||
{ label: 'Beginner', value: 'beginner' },
|
{ label: 'Beginner', value: 'beginner' },
|
||||||
@@ -105,6 +130,15 @@ async function save() {
|
|||||||
<InputNumber v-model="form.ftp" :min="50" :max="600" class="w-full" placeholder="e.g. 250" />
|
<InputNumber v-model="form.ftp" :min="50" :max="600" class="w-full" placeholder="e.g. 250" />
|
||||||
<span class="text-surface-500 text-sm font-medium">W</span>
|
<span class="text-surface-500 text-sm font-medium">W</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
label="Auto-detect FTP"
|
||||||
|
icon="pi pi-sparkles"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
class="mt-2"
|
||||||
|
:loading="detectingFtp"
|
||||||
|
@click="detectFtp"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">LTHR (Lactate Threshold Heart Rate)</label>
|
<label class="text-surface-500 text-xs uppercase font-medium mb-1 block">LTHR (Lactate Threshold Heart Rate)</label>
|
||||||
@@ -135,5 +169,45 @@ async function save() {
|
|||||||
<Message v-if="success" severity="success" :closable="false" class="m-0">Settings saved successfully</Message>
|
<Message v-if="success" severity="success" :closable="false" class="m-0">Settings saved successfully</Message>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP Detection Dialog -->
|
||||||
|
<Dialog v-model:visible="showFtpDialog" header="FTP Auto-Detection" :modal="true" :style="{ width: '420px' }">
|
||||||
|
<div v-if="ftpDetection">
|
||||||
|
<div v-if="ftpDetection.detected_ftp" class="flex flex-col gap-4">
|
||||||
|
<div class="bg-primary/10 rounded-lg p-4 text-center">
|
||||||
|
<p class="text-surface-500 text-xs uppercase mb-1">Detected FTP</p>
|
||||||
|
<p class="text-3xl font-bold text-primary">{{ ftpDetection.detected_ftp }} <span class="text-lg font-normal">W</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div class="bg-surface-50 rounded-lg p-3">
|
||||||
|
<p class="text-surface-500 text-xs">Best 20min Power</p>
|
||||||
|
<p class="font-semibold">{{ ftpDetection.best_20min_power }} W</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-50 rounded-lg p-3">
|
||||||
|
<p class="text-surface-500 text-xs">Current FTP</p>
|
||||||
|
<p class="font-semibold">{{ ftpDetection.current_ftp ?? '—' }} W</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-50 rounded-lg p-3">
|
||||||
|
<p class="text-surface-500 text-xs">Activity</p>
|
||||||
|
<p class="font-semibold text-xs">{{ ftpDetection.activity_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-surface-50 rounded-lg p-3">
|
||||||
|
<p class="text-surface-500 text-xs">Date</p>
|
||||||
|
<p class="font-semibold text-xs">{{ ftpDetection.date }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-surface-500 text-xs">FTP = Best 20min Power x 0.95. This will recalculate TSS/IF for all your activities.</p>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button label="Cancel" severity="secondary" @click="showFtpDialog = false" />
|
||||||
|
<Button label="Apply FTP" icon="pi pi-check" @click="applyDetectedFtp" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-4">
|
||||||
|
<i class="pi pi-info-circle text-2xl text-surface-400 mb-2"></i>
|
||||||
|
<p class="text-surface-500">{{ ftpDetection.message || 'No 20-minute power data found. Upload more rides!' }}</p>
|
||||||
|
<Button label="Close" severity="secondary" class="mt-4" @click="showFtpDialog = false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user