This commit is contained in:
xds
2026-03-17 10:00:55 +03:00
parent 1d76f29244
commit 2f4a30ccaf
9 changed files with 1404 additions and 87 deletions

View File

@@ -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

View File

@@ -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),
}

View File

@@ -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)

View File

@@ -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 }
}) })

View File

@@ -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

View File

@@ -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,7 +525,10 @@ 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>
<Button icon="pi pi-trash" severity="danger" text rounded size="small" @click="handleDelete" /> <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" />
</div>
</div> </div>
<!-- Key metrics --> <!-- Key metrics -->
@@ -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 gap-2"> <div class="flex items-center justify-between">
<i class="pi pi-sparkles text-primary"></i> <div class="flex items-center gap-2">
AI Coach Summary <i class="pi pi-sparkles text-primary"></i>
{{ 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>

View File

@@ -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()
} }

View File

@@ -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">

View File

@@ -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>