diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 7a08616..ed547e9 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -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.ai_summary import generate_summary from backend.app.services.coaching import link_activity_to_plan +from backend.app.models.training import TrainingPlan router = APIRouter() @@ -205,6 +206,7 @@ async def get_activity_power_curve( @router.post("/{activity_id}/ai-summary") async def generate_ai_summary( activity_id: uuid.UUID, + body: dict | None = None, rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): @@ -212,16 +214,31 @@ async def generate_ai_summary( if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") + force = (body or {}).get("force", False) + # Check for existing diary entry with summary query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) result = await session.execute(query) 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} + # 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 - summary = await generate_summary(activity, rider_ftp=rider.ftp) + summary = await generate_summary(activity, rider_ftp=rider.ftp, planned_workout=planned_workout) if diary: diary.ai_summary = summary diff --git a/backend/app/api/rider.py b/backend/app/api/rider.py index 8cde000..63dd6c0 100644 --- a/backend/app/api/rider.py +++ b/backend/app/api/rider.py @@ -1,7 +1,7 @@ from datetime import date, timedelta from fastapi import APIRouter, Depends -from sqlalchemy import select, func +from sqlalchemy import select, func, extract, cast, Date from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.auth import get_current_rider @@ -144,3 +144,402 @@ async def get_personal_records( # Sort by duration return sorted(records.values(), key=lambda r: r["duration"]) + + +@router.get("/detect-ftp") +async def detect_ftp( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Auto-detect FTP from best 20min power × 0.95 across all activities.""" + query = ( + select(PowerCurve.curve_data, Activity.date, Activity.name, Activity.id) + .join(Activity, Activity.id == PowerCurve.activity_id) + .where(Activity.rider_id == rider.id) + ) + result = await session.execute(query) + + best_20min = 0 + best_activity_id = None + best_activity_name = None + best_date = None + + for row in result: + power_20 = row.curve_data.get("1200") # 20 min = 1200s + if power_20 and power_20 > best_20min: + best_20min = power_20 + best_activity_id = str(row.id) + best_activity_name = row.name or "Ride" + best_date = row.date.isoformat() + + if best_20min == 0: + return {"detected_ftp": None, "best_20min_power": None, "message": "No 20-minute power data found"} + + detected_ftp = round(best_20min * 0.95) + return { + "detected_ftp": detected_ftp, + "best_20min_power": best_20min, + "activity_id": best_activity_id, + "activity_name": best_activity_name, + "date": best_date, + "current_ftp": rider.ftp, + } + + +@router.get("/activity-calendar") +async def get_activity_calendar( + months: int = 12, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Activity heatmap data: date → TSS for the last N months.""" + start_date = date.today() - timedelta(days=months * 30) + day_col = cast(Activity.date, Date).label("day") + + query = ( + select( + day_col, + func.count(Activity.id).label("count"), + func.sum(Activity.duration).label("duration"), + func.sum(ActivityMetrics.tss).label("tss"), + ) + .select_from(Activity) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= start_date) + .group_by(day_col) + .order_by(day_col) + ) + result = await session.execute(query) + + return [ + { + "date": row.day.isoformat(), + "count": row.count, + "duration": row.duration or 0, + "tss": round(float(row.tss or 0), 0), + } + for row in result + ] + + +@router.get("/monthly-trends") +async def get_monthly_trends( + months: int = 12, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Monthly trends: avg power, avg HR, volume, W/kg.""" + start_date = date.today() - timedelta(days=months * 30) + month_col = func.date_trunc("month", Activity.date).label("month") + + query = ( + select( + month_col, + func.count(Activity.id).label("rides"), + func.sum(Activity.duration).label("duration"), + func.sum(Activity.distance).label("distance"), + func.avg(ActivityMetrics.avg_power).label("avg_power"), + func.avg(ActivityMetrics.normalized_power).label("avg_np"), + func.avg(ActivityMetrics.avg_hr).label("avg_hr"), + func.sum(ActivityMetrics.tss).label("tss"), + ) + .select_from(Activity) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= start_date) + .group_by(month_col) + .order_by(month_col) + ) + result = await session.execute(query) + + weight = rider.weight + return [ + { + "month": row.month.strftime("%Y-%m") if row.month else None, + "rides": row.rides, + "hours": round((row.duration or 0) / 3600, 1), + "distance_km": round(float(row.distance or 0) / 1000, 0), + "avg_power": round(float(row.avg_power), 0) if row.avg_power else None, + "avg_np": round(float(row.avg_np), 0) if row.avg_np else None, + "avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None, + "tss": round(float(row.tss or 0), 0), + "w_per_kg": round(float(row.avg_power) / weight, 2) if row.avg_power and weight else None, + } + for row in result + ] + + +@router.get("/progress-summary") +async def get_progress_summary( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Compare this week vs last week, this month vs last month. Current fitness state.""" + today = date.today() + start_of_week = today - timedelta(days=today.weekday()) # Monday + last_week_start = start_of_week - timedelta(weeks=1) + + start_of_month = today.replace(day=1) + last_month_start = (start_of_month - timedelta(days=1)).replace(day=1) + + async def _period_stats(start: date, end: date): + query = ( + select( + func.count(Activity.id).label("rides"), + func.sum(Activity.duration).label("duration"), + func.sum(Activity.distance).label("distance"), + func.sum(ActivityMetrics.tss).label("tss"), + func.avg(ActivityMetrics.avg_power).label("avg_power"), + func.avg(ActivityMetrics.normalized_power).label("avg_np"), + func.avg(ActivityMetrics.avg_hr).label("avg_hr"), + ) + .select_from(Activity) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= start) + .where(Activity.date < end) + ) + row = (await session.execute(query)).first() + if not row or row.rides == 0: + return {"rides": 0, "hours": 0, "distance_km": 0, "tss": 0, + "avg_power": None, "avg_np": None, "avg_hr": None} + return { + "rides": row.rides, + "hours": round((row.duration or 0) / 3600, 1), + "distance_km": round(float(row.distance or 0) / 1000, 1), + "tss": round(float(row.tss or 0), 0), + "avg_power": round(float(row.avg_power), 0) if row.avg_power else None, + "avg_np": round(float(row.avg_np), 0) if row.avg_np else None, + "avg_hr": round(float(row.avg_hr), 0) if row.avg_hr else None, + } + + this_week = await _period_stats(start_of_week, today + timedelta(days=1)) + last_week = await _period_stats(last_week_start, start_of_week) + this_month = await _period_stats(start_of_month, today + timedelta(days=1)) + last_month = await _period_stats(last_month_start, start_of_month) + + # Current fitness (latest CTL/ATL/TSB) + from backend.app.models.fitness import FitnessHistory + fh_query = ( + select(FitnessHistory) + .where(FitnessHistory.rider_id == rider.id) + .order_by(FitnessHistory.date.desc()) + .limit(1) + ) + fh = (await session.execute(fh_query)).scalar_one_or_none() + + # Fitness 7 days ago for trend + fh_prev_query = ( + select(FitnessHistory) + .where(FitnessHistory.rider_id == rider.id) + .where(FitnessHistory.date <= today - timedelta(days=7)) + .order_by(FitnessHistory.date.desc()) + .limit(1) + ) + fh_prev = (await session.execute(fh_prev_query)).scalar_one_or_none() + + fitness_now = { + "ctl": round(fh.ctl, 1) if fh else None, + "atl": round(fh.atl, 1) if fh else None, + "tsb": round(fh.tsb, 1) if fh else None, + } + fitness_prev = { + "ctl": round(fh_prev.ctl, 1) if fh_prev else None, + "atl": round(fh_prev.atl, 1) if fh_prev else None, + "tsb": round(fh_prev.tsb, 1) if fh_prev else None, + } + + # Total activity count + total_count = (await session.execute( + select(func.count(Activity.id)).where(Activity.rider_id == rider.id) + )).scalar() or 0 + + # Days since last activity + last_act = (await session.execute( + select(Activity.date) + .where(Activity.rider_id == rider.id) + .order_by(Activity.date.desc()) + .limit(1) + )).scalar_one_or_none() + + if last_act is not None: + from datetime import datetime as dt_type + last_date = last_act.date() if isinstance(last_act, dt_type) else last_act + days_since_last = (today - last_date).days + else: + days_since_last = None + + return { + "this_week": this_week, + "last_week": last_week, + "this_month": this_month, + "last_month": last_month, + "fitness": fitness_now, + "fitness_7d_ago": fitness_prev, + "total_activities": total_count, + "days_since_last_activity": days_since_last, + "ftp": rider.ftp, + "weight": rider.weight, + } + + +@router.get("/ai-progress") +async def get_ai_progress( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """AI analysis of long-term progress with actionable recommendations.""" + from backend.app.services.coaching import build_rider_context + from backend.app.services.gemini_client import chat_async + from backend.app.models.fitness import FitnessHistory + + today = date.today() + + rider_context = await build_rider_context(rider, session) + + # Collect monthly stats for the last 6 months + lines = [rider_context, "\n--- Monthly breakdown (last 6 months) ---"] + for i in range(6): + m_start = (today.replace(day=1) - timedelta(days=30 * i)).replace(day=1) + m_end = (m_start + timedelta(days=32)).replace(day=1) + query = ( + select( + func.count(Activity.id).label("rides"), + func.sum(Activity.duration).label("duration"), + func.sum(Activity.distance).label("distance"), + func.sum(ActivityMetrics.tss).label("tss"), + func.avg(ActivityMetrics.avg_power).label("avg_power"), + func.avg(ActivityMetrics.avg_hr).label("avg_hr"), + ) + .select_from(Activity) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= m_start) + .where(Activity.date < m_end) + ) + row = (await session.execute(query)).first() + if row and row.rides: + lines.append( + f"{m_start.strftime('%Y-%m')}: {row.rides} rides, " + f"{(row.duration or 0) / 3600:.1f}h, " + f"TSS={float(row.tss or 0):.0f}, " + f"AP={float(row.avg_power):.0f}W" if row.avg_power else f"{m_start.strftime('%Y-%m')}: {row.rides} rides" + ) + + # Fitness trend: sample every 7 days for last 90 days + fh_query = ( + select(FitnessHistory.date, FitnessHistory.ctl, FitnessHistory.atl, FitnessHistory.tsb) + .where(FitnessHistory.rider_id == rider.id) + .where(FitnessHistory.date >= today - timedelta(days=90)) + .order_by(FitnessHistory.date) + ) + fh_rows = (await session.execute(fh_query)).all() + if fh_rows: + lines.append("\n--- Fitness trend (sampled weekly, last 90d) ---") + for idx, fh in enumerate(fh_rows): + if idx % 7 == 0 or idx == len(fh_rows) - 1: + lines.append(f"{fh.date}: CTL={fh.ctl:.0f} ATL={fh.atl:.0f} TSB={fh.tsb:.0f}") + + system = """You are VeloBrain AI Coach performing a comprehensive progress analysis. + +Analyze the rider's data and produce a structured report with these sections: + +1. **Текущее состояние** — Current fitness level, form, fatigue. Is the rider fresh, building, overreaching? +2. **Динамика прогресса** — How fitness, power, and volume have changed over the last months. Specific numbers and trends. +3. **Сильные стороны** — What the rider is doing well (consistency, intensity, volume, specific power durations). +4. **Зоны роста** — Specific weaknesses or areas for improvement. Be constructive. +5. **Рекомендации** — 3-5 specific, actionable recommendations for the next 4 weeks. Include target numbers where possible. +6. **Риски** — Any warning signs: overtraining, stagnation, imbalance. Only include if relevant. + +Be specific with numbers. Compare periods where possible. +Keep each section 2-4 sentences. +Respond in Russian. Use HTML formatting.""" + + analysis = await chat_async( + [{"role": "user", "text": "\n".join(lines)}], + system_instruction=system, + temperature=0.5, + ) + + return {"analysis": analysis} + + +@router.get("/weekly-digest") +async def get_weekly_digest( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Get data for AI weekly digest generation.""" + from backend.app.services.coaching import build_rider_context + from backend.app.services.gemini_client import chat_async + + today = date.today() + week_start = today - timedelta(days=today.weekday() + 7) # Last Monday + week_end = week_start + timedelta(days=7) + + # Activities from last week + act_query = ( + select(Activity, ActivityMetrics) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= week_start) + .where(Activity.date < week_end) + .order_by(Activity.date) + ) + result = await session.execute(act_query) + activities = list(result.all()) + + if not activities: + return {"digest": None, "message": "No activities last week"} + + # Build prompt + lines = [f"Weekly digest for {week_start.isoformat()} — {week_end.isoformat()}"] + total_duration = 0 + total_tss = 0 + total_distance = 0 + + for act, metrics in activities: + total_duration += act.duration + total_distance += act.distance or 0 + tss = float(metrics.tss or 0) if metrics else 0 + total_tss += tss + lines.append( + f"- {act.date.strftime('%a')}: {act.name or 'Workout'}, " + f"{act.duration // 60}min, " + f"{f'{act.distance / 1000:.1f}km, ' if act.distance else ''}" + f"{f'AP={metrics.avg_power:.0f}W, NP={metrics.normalized_power:.0f}W, ' if metrics and metrics.avg_power else ''}" + f"TSS={tss:.0f}" + ) + + lines.append(f"\nTotals: {len(activities)} workouts, {total_duration / 3600:.1f}h, " + f"{total_distance / 1000:.1f}km, TSS={total_tss:.0f}") + + rider_context = await build_rider_context(rider, session) + lines.append(f"\n--- Rider ---\n{rider_context}") + + system = """You are VeloBrain AI Coach generating a weekly training digest. +Provide a concise summary with these sections: +1. **Итоги недели** — what was done, key numbers +2. **Что получилось хорошо** — highlights, PRs, consistency +3. **На что обратить внимание** — areas for improvement, recovery needs +4. **Рекомендации на следующую неделю** — specific actionable advice + +Keep it 8-12 sentences total. Be specific with numbers. +Respond in Russian. Use HTML formatting.""" + + digest = await chat_async( + [{"role": "user", "text": "\n".join(lines)}], + system_instruction=system, + temperature=0.5, + ) + + return { + "digest": digest, + "week_start": week_start.isoformat(), + "week_end": week_end.isoformat(), + "total_activities": len(activities), + "total_hours": round(total_duration / 3600, 1), + "total_tss": round(total_tss, 0), + "total_distance_km": round(total_distance / 1000, 1), + } diff --git a/backend/app/services/ai_summary.py b/backend/app/services/ai_summary.py index afc9bc5..ce08f76 100644 --- a/backend/app/services/ai_summary.py +++ b/backend/app/services/ai_summary.py @@ -10,11 +10,32 @@ Be specific with numbers. Use a friendly, coaching tone. Respond in Russian. 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 lines = [ - f"Activity: {activity.name or 'Ride'}", + f"Activity: {activity.name or 'Workout'}", f"Type: {activity.activity_type}", 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") if m.avg_cadence: lines.append(f"Avg Cadence: {m.avg_cadence} rpm") + if m.calories: + lines.append(f"Calories: {m.calories} kcal") if rider_ftp: 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 [] work_intervals = [i for i in intervals if i.interval_type == "work"] if work_intervals: @@ -53,10 +89,29 @@ def _build_activity_prompt(activity: Activity, rider_ftp: float | None = None) - if 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) -async def generate_summary(activity: Activity, rider_ftp: float | None = None) -> str: - prompt = _build_activity_prompt(activity, rider_ftp) +async def generate_summary( + 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}] - 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) diff --git a/frontend/src/stores/activities.ts b/frontend/src/stores/activities.ts index fbcf17e..5328747 100644 --- a/frontend/src/stores/activities.ts +++ b/frontend/src/stores/activities.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' 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', () => { const { api } = useApi() @@ -49,8 +49,8 @@ export const useActivitiesStore = defineStore('activities', () => { return data } - async function fetchAiSummary(id: string): Promise { - const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`) + async function fetchAiSummary(id: string, force = false): Promise { + const { data } = await api.post<{ summary: string }>(`/activities/${id}/ai-summary`, { force }) return data.summary } @@ -85,5 +85,35 @@ export const useActivitiesStore = defineStore('activities', () => { 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 { + const { data } = await api.get('/rider/activity-calendar', { params: { months } }) + return data + } + + async function fetchMonthlyTrends(months = 12): Promise { + const { data } = await api.get('/rider/monthly-trends', { params: { months } }) + return data + } + + async function detectFtp(): Promise { + const { data } = await api.get('/rider/detect-ftp') + return data + } + + async function fetchWeeklyDigest(): Promise { + const { data } = await api.get('/rider/weekly-digest') + return data + } + + async function fetchProgressSummary(): Promise { + const { data } = await api.get('/rider/progress-summary') + return data + } + + async function fetchAiProgress(): Promise { + 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 } }) diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index 7c57529..3871131 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -208,6 +208,75 @@ export interface ComplianceWeek { 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 { plan_id: string plan_goal: string diff --git a/frontend/src/views/ActivityDetailView.vue b/frontend/src/views/ActivityDetailView.vue index 90e1cf4..6ed24e8 100644 --- a/frontend/src/views/ActivityDetailView.vue +++ b/frontend/src/views/ActivityDetailView.vue @@ -2,7 +2,8 @@ import { onMounted, ref, computed, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' 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 Tag from 'primevue/tag' import DataTable from 'primevue/datatable' @@ -27,6 +28,7 @@ use([CanvasRenderer, LineChart, BarChart, TitleComponent, TooltipComponent, Lege const route = useRoute() const router = useRouter() const store = useActivitiesStore() +const coaching = useCoachingStore() const activity = ref(null) const stream = ref([]) @@ -41,6 +43,62 @@ const diary = ref({ rider_notes: null, mood: null, rpe: null, sleep_ const diarySaving = ref(false) const diarySaved = ref(false) +// Plan linking +const activePlan = ref(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 = { + 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 = [ { label: 'Great', value: 'great' }, { 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() { if (!activity.value) return diarySaving.value = true @@ -301,6 +372,114 @@ async function handleDelete() { 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 () => { const id = route.params.id as string try { @@ -320,6 +499,11 @@ onMounted(async () => { 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 if (stream.value.some(dp => dp.latitude && dp.longitude)) { await nextTick() @@ -341,7 +525,10 @@ onMounted(async () => {

{{ activity.name || 'Ride' }}

-