This commit is contained in:
xds
2026-03-16 15:43:20 +03:00
parent 002e4cca31
commit 1d76f29244
14 changed files with 546 additions and 89 deletions

View File

@@ -0,0 +1,30 @@
"""add exercise_sets to activities
Revision ID: 79d3444f9d7d
Revises: 4c6a3c01542f
Create Date: 2026-03-16 14:57:26.988571
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '79d3444f9d7d'
down_revision: Union[str, None] = '4c6a3c01542f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('exercise_sets', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activities', 'exercise_sets')
# ### end Alembic commands ###

View File

@@ -0,0 +1,36 @@
"""add training plan link fields to activities
Revision ID: ab0f6e2939d3
Revises: 79d3444f9d7d
Create Date: 2026-03-16 15:08:33.484799
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ab0f6e2939d3'
down_revision: Union[str, None] = '79d3444f9d7d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activities', sa.Column('training_plan_id', sa.UUID(), nullable=True))
op.add_column('activities', sa.Column('plan_week', sa.Integer(), nullable=True))
op.add_column('activities', sa.Column('plan_day', sa.String(length=20), nullable=True))
op.create_foreign_key(None, 'activities', 'training_plans', ['training_plan_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'activities', type_='foreignkey')
op.drop_column('activities', 'plan_day')
op.drop_column('activities', 'plan_week')
op.drop_column('activities', 'training_plan_id')
# ### end Alembic commands ###

View File

@@ -25,6 +25,7 @@ from backend.app.services.zones import calculate_power_zones, calculate_hr_zones
from backend.app.services.power_curve import calculate_power_curve 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
router = APIRouter() router = APIRouter()
@@ -48,31 +49,38 @@ async def upload_activity(
file_path.write_bytes(content) file_path.write_bytes(content)
# 1. Parse FIT # 1. Parse FIT
activity, data_points = parse_fit_file(content, rider.id, str(file_path)) activity, data_points, exercise_sets = parse_fit_file(content, rider.id, str(file_path))
if exercise_sets:
activity.exercise_sets = exercise_sets
# Auto-link to training plan
await link_activity_to_plan(activity, rider.id, session)
session.add(activity) session.add(activity)
await session.flush() await session.flush()
# 2. Save data points # 2. Save data points (if any — strength workouts may have none)
for dp in data_points: if data_points:
dp.activity_id = activity.id for dp in data_points:
session.add_all(data_points) dp.activity_id = activity.id
session.add_all(data_points)
# 3. Calculate & save metrics (with FTP if available) # 3. Calculate & save metrics (with FTP if available)
metrics = calculate_metrics(data_points, activity, ftp=rider.ftp) metrics = calculate_metrics(data_points, activity, ftp=rider.ftp)
if metrics: if metrics:
session.add(metrics) session.add(metrics)
# 4. Detect & save intervals # 4. Detect & save intervals
intervals = detect_intervals(data_points, ftp=rider.ftp) intervals = detect_intervals(data_points, ftp=rider.ftp)
for interval in intervals: for interval in intervals:
interval.activity_id = activity.id interval.activity_id = activity.id
session.add_all(intervals) session.add_all(intervals)
# 5. Calculate & save power curve # 5. Calculate & save power curve
curve_data = calculate_power_curve(data_points) curve_data = calculate_power_curve(data_points)
if curve_data: if curve_data:
pc = PowerCurve(activity_id=activity.id, curve_data=curve_data) pc = PowerCurve(activity_id=activity.id, curve_data=curve_data)
session.add(pc) session.add(pc)
await session.commit() await session.commit()
await session.refresh(activity) await session.refresh(activity)

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from backend.app.core.auth import get_current_rider from backend.app.core.auth import get_current_rider
from backend.app.core.database import get_session from backend.app.core.database import get_session
from backend.app.models.activity import Activity
from backend.app.models.coaching import CoachingChat from backend.app.models.coaching import CoachingChat
from backend.app.models.rider import Rider from backend.app.models.rider import Rider
from backend.app.models.training import TrainingPlan from backend.app.models.training import TrainingPlan
@@ -243,11 +244,74 @@ async def get_today(
rider: Rider = Depends(get_current_rider), rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""Get today's planned workout.""" """Get today's planned workout with linked activity if any."""
workout = await get_today_workout(rider, session) workout = await get_today_workout(rider, session)
if not workout:
return None
# Check if there's already a linked activity for today
linked_query = (
select(Activity)
.where(Activity.training_plan_id == uuid.UUID(workout["plan_id"]))
.where(Activity.plan_week == workout["week_number"])
.where(Activity.plan_day == workout["day"])
)
linked_result = await session.execute(linked_query)
linked = linked_result.scalar_one_or_none()
workout["linked_activity_id"] = str(linked.id) if linked else None
workout["completed"] = linked is not None
return workout return workout
# --- Activity-Plan linking ---
class LinkRequest(BaseModel):
activity_id: str
plan_id: str
week: int
day: str
@router.post("/link")
async def link_activity(
body: LinkRequest,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Manually link an activity to a planned workout day."""
activity = await session.get(Activity, uuid.UUID(body.activity_id))
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
plan = await session.get(TrainingPlan, uuid.UUID(body.plan_id))
if not plan or plan.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Plan not found")
activity.training_plan_id = plan.id
activity.plan_week = body.week
activity.plan_day = body.day
await session.commit()
return {"ok": True}
@router.post("/unlink/{activity_id}")
async def unlink_activity(
activity_id: uuid.UUID,
rider: Rider = Depends(get_current_rider),
session: AsyncSession = Depends(get_session),
):
"""Remove link between activity and planned workout."""
activity = await session.get(Activity, activity_id)
if not activity or activity.rider_id != rider.id:
raise HTTPException(status_code=404, detail="Activity not found")
activity.training_plan_id = None
activity.plan_week = None
activity.plan_day = None
await session.commit()
return {"ok": True}
# --- Adjustment chat --- # --- Adjustment chat ---
@router.post("/plan/adjust") @router.post("/plan/adjust")

View File

@@ -18,6 +18,7 @@ from backend.app.services.fit_parser import parse_fit_file
from backend.app.services.metrics import calculate_metrics from backend.app.services.metrics import calculate_metrics
from backend.app.services.intervals import detect_intervals from backend.app.services.intervals import detect_intervals
from backend.app.services.power_curve import calculate_power_curve from backend.app.services.power_curve import calculate_power_curve
from backend.app.services.coaching import link_activity_to_plan
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -57,27 +58,34 @@ async def process_fit_upload(content: bytes, rider: Rider) -> Activity:
# Re-attach rider to this session # Re-attach rider to this session
rider = await session.get(Rider, rider.id) rider = await session.get(Rider, rider.id)
activity, data_points = parse_fit_file(content, rider.id, str(file_path)) activity, data_points, exercise_sets = parse_fit_file(content, rider.id, str(file_path))
if exercise_sets:
activity.exercise_sets = exercise_sets
# Auto-link to training plan
await link_activity_to_plan(activity, rider.id, session)
session.add(activity) session.add(activity)
await session.flush() await session.flush()
for dp in data_points: if data_points:
dp.activity_id = activity.id for dp in data_points:
session.add_all(data_points) dp.activity_id = activity.id
session.add_all(data_points)
metrics = calculate_metrics(data_points, activity, ftp=rider.ftp) metrics = calculate_metrics(data_points, activity, ftp=rider.ftp)
if metrics: if metrics:
session.add(metrics) session.add(metrics)
intervals = detect_intervals(data_points, ftp=rider.ftp) intervals = detect_intervals(data_points, ftp=rider.ftp)
for interval in intervals: for interval in intervals:
interval.activity_id = activity.id interval.activity_id = activity.id
session.add_all(intervals) session.add_all(intervals)
curve_data = calculate_power_curve(data_points) curve_data = calculate_power_curve(data_points)
if curve_data: if curve_data:
pc = PowerCurve(activity_id=activity.id, curve_data=curve_data) pc = PowerCurve(activity_id=activity.id, curve_data=curve_data)
session.add(pc) session.add(pc)
await session.commit() await session.commit()
await session.refresh(activity) await session.refresh(activity)
@@ -134,7 +142,7 @@ async def handle_document(message: Message, bot: Bot):
m = activity.metrics m = activity.metrics
lines = [ lines = [
f"*{activity.name or 'Ride'}*", f"*{activity.name or 'Workout'}*",
f"Duration: {format_duration(activity.duration)}", f"Duration: {format_duration(activity.duration)}",
] ]
@@ -156,6 +164,22 @@ async def handle_document(message: Message, bot: Bot):
lines.append(f"Avg HR: {m.avg_hr} bpm") lines.append(f"Avg HR: {m.avg_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")
# Exercise sets for strength workouts
if activity.exercise_sets:
exercises: dict[str, list] = {}
for s in activity.exercise_sets:
name = s.get("exercise_name", "Unknown")
exercises.setdefault(name, []).append(s)
lines.append("")
for name, sets in exercises.items():
reps_str = " / ".join(
f"{s.get('repetitions', '?')}x{s.get('weight', 0):.0f}kg" if s.get('weight') else f"{s.get('repetitions', '?')} reps"
for s in sets
)
lines.append(f" {name}: {reps_str}")
intervals_count = len(activity.intervals or []) intervals_count = len(activity.intervals or [])
if intervals_count > 0: if intervals_count > 0:

View File

@@ -2,7 +2,7 @@ import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Float, Integer, DateTime, ForeignKey, func from sqlalchemy import String, Float, Integer, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base from backend.app.core.database import Base
@@ -20,6 +20,12 @@ class Activity(Base):
distance: Mapped[float | None] = mapped_column(Float, nullable=True) # meters distance: Mapped[float | None] = mapped_column(Float, nullable=True) # meters
elevation_gain: Mapped[float | None] = mapped_column(Float, nullable=True) # meters elevation_gain: Mapped[float | None] = mapped_column(Float, nullable=True) # meters
file_path: Mapped[str | None] = mapped_column(String(500), nullable=True) file_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
exercise_sets: Mapped[list | None] = mapped_column(JSONB, nullable=True) # [{exercise_name, reps, weight, duration}]
# Link to training plan workout
training_plan_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("training_plans.id"), nullable=True)
plan_week: Mapped[int | None] = mapped_column(Integer, nullable=True)
plan_day: Mapped[str | None] = mapped_column(String(20), nullable=True) # monday, tuesday, ...
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from pydantic import BaseModel from pydantic import BaseModel, Field
class ActivityMetricsResponse(BaseModel): class ActivityMetricsResponse(BaseModel):
@@ -45,6 +45,10 @@ class ActivityResponse(BaseModel):
elevation_gain: float | None = None elevation_gain: float | None = None
metrics: ActivityMetricsResponse | None = None metrics: ActivityMetricsResponse | None = None
intervals: list[IntervalResponse] = [] intervals: list[IntervalResponse] = []
exercise_sets: list[dict] | None = None
training_plan_id: UUID | None = None
plan_week: int | None = None
plan_day: str | None = None
class ActivityListResponse(BaseModel): class ActivityListResponse(BaseModel):

View File

@@ -373,7 +373,7 @@ async def get_today_workout(rider: Rider, session: AsyncSession) -> dict | None:
async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> list[dict]: async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> list[dict]:
"""Compare planned vs actual per week.""" """Compare planned vs actual per week, matching linked activities to specific days."""
if not plan.weeks_json: if not plan.weeks_json:
return [] return []
@@ -385,19 +385,33 @@ async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> lis
week_start = plan.start_date + timedelta(weeks=week_num - 1) week_start = plan.start_date + timedelta(weeks=week_num - 1)
week_end = week_start + timedelta(days=7) week_end = week_start + timedelta(days=7)
planned_days = [d for d in week.get("days", []) if d.get("workout_type") != "rest"]
planned_rides = len(planned_days)
planned_tss = week.get("target_tss", 0)
# Skip future weeks # Skip future weeks
if week_start > date.today(): if week_start > date.today():
results.append({ results.append({
"week_number": week_num, "week_number": week_num,
"focus": week.get("focus", ""), "focus": week.get("focus", ""),
"planned_tss": week.get("target_tss", 0), "planned_tss": planned_tss,
"actual_tss": 0, "actual_tss": 0,
"planned_hours": week.get("target_hours", 0), "planned_hours": week.get("target_hours", 0),
"actual_hours": 0, "actual_hours": 0,
"planned_rides": sum(1 for d in week.get("days", []) if d.get("workout_type") != "rest"), "planned_rides": planned_rides,
"actual_rides": 0, "actual_rides": 0,
"adherence_pct": 0, "adherence_pct": 0,
"status": "upcoming", "status": "upcoming",
"days": [
{
"day": d.get("day"),
"planned": d.get("title", d.get("workout_type")),
"workout_type": d.get("workout_type"),
"activity_id": None,
"completed": False,
}
for d in week.get("days", [])
],
}) })
continue continue
@@ -415,12 +429,35 @@ async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> lis
actual_tss = sum(float(r.tss or 0) for r in acts) actual_tss = sum(float(r.tss or 0) for r in acts)
actual_hours = sum(r[0].duration for r in acts) / 3600 actual_hours = sum(r[0].duration for r in acts) / 3600
actual_rides = len(acts) actual_rides = len(acts)
planned_rides = sum(1 for d in week.get("days", []) if d.get("workout_type") != "rest")
planned_tss = week.get("target_tss", 0) # Build per-day status: match linked activities to planned days
linked_activities = {
a[0].plan_day: a[0]
for a in acts
if a[0].training_plan_id == plan.id and a[0].plan_week == week_num and a[0].plan_day
}
day_statuses = []
completed_workouts = 0
for d in week.get("days", []):
day_name = d.get("day")
is_rest = d.get("workout_type") == "rest"
linked = linked_activities.get(day_name)
completed = linked is not None and not is_rest
if completed:
completed_workouts += 1
day_statuses.append({
"day": day_name,
"planned": d.get("title", d.get("workout_type")),
"workout_type": d.get("workout_type"),
"activity_id": str(linked.id) if linked else None,
"completed": completed,
})
adherence = 0 adherence = 0
if planned_rides > 0: if planned_rides > 0:
adherence = min(100, round(actual_rides / planned_rides * 100)) adherence = min(100, round(completed_workouts / planned_rides * 100))
is_current = week_start <= date.today() < week_end is_current = week_start <= date.today() < week_end
@@ -435,11 +472,52 @@ async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> lis
"actual_rides": actual_rides, "actual_rides": actual_rides,
"adherence_pct": adherence, "adherence_pct": adherence,
"status": "current" if is_current else "completed", "status": "current" if is_current else "completed",
"days": day_statuses,
}) })
return results return results
async def link_activity_to_plan(
activity,
rider_id,
session: AsyncSession,
) -> None:
"""Auto-link an activity to the active training plan based on date.
Finds the planned workout for the activity's date and sets
training_plan_id, plan_week, plan_day on the activity.
"""
plan_query = (
select(TrainingPlan)
.where(TrainingPlan.rider_id == rider_id)
.where(TrainingPlan.status == "active")
.order_by(TrainingPlan.created_at.desc())
.limit(1)
)
result = await session.execute(plan_query)
plan = result.scalar_one_or_none()
if not plan or not plan.weeks_json:
return
activity_date = activity.date.date() if hasattr(activity.date, "date") else activity.date
if activity_date < plan.start_date or activity_date > plan.end_date:
return
week_num = (activity_date - plan.start_date).days // 7 + 1
day_name = activity_date.strftime("%A").lower()
weeks = plan.weeks_json.get("weeks", [])
for week in weeks:
if week.get("week_number") == week_num:
for day in week.get("days", []):
if day.get("day") == day_name and day.get("workout_type") != "rest":
activity.training_plan_id = plan.id
activity.plan_week = week_num
activity.plan_day = day_name
return
def _extract_json(text: str) -> dict | None: def _extract_json(text: str) -> dict | None:
"""Extract JSON from AI response text.""" """Extract JSON from AI response text."""
# Try to find JSON in code blocks # Try to find JSON in code blocks

View File

@@ -7,14 +7,30 @@ import fitdecode
from backend.app.models.activity import Activity, DataPoint from backend.app.models.activity import Activity, DataPoint
# Map FIT sport enum values to human-readable names
SPORT_NAMES = {
"cycling": "Cycling",
"running": "Running",
"swimming": "Swimming",
"training": "Strength",
"strength_training": "Strength",
"cardio_training": "Cardio",
"walking": "Walking",
"hiking": "Hiking",
"generic": "Workout",
}
def parse_fit_file( def parse_fit_file(
file_content: bytes, file_content: bytes,
rider_id: uuid.UUID, rider_id: uuid.UUID,
file_path: str, file_path: str,
) -> tuple[Activity, list[DataPoint]]: ) -> tuple[Activity, list[DataPoint], list[dict]]:
"""Parse a .FIT file and return an Activity with its DataPoints.""" """Parse a .FIT file and return an Activity with its DataPoints and exercise sets."""
data_points: list[DataPoint] = [] data_points: list[DataPoint] = []
session_data: dict = {} session_data: dict = {}
exercise_sets: list[dict] = []
current_exercise: str | None = None
with fitdecode.FitReader(BytesIO(file_content)) as fit: with fitdecode.FitReader(BytesIO(file_content)) as fit:
for frame in fit: for frame in fit:
@@ -29,14 +45,43 @@ def parse_fit_file(
elif frame.name == "session": elif frame.name == "session":
session_data = _parse_session(frame) session_data = _parse_session(frame)
start_time = data_points[0].timestamp if data_points else datetime.now(timezone.utc) elif frame.name == "workout":
end_time = data_points[-1].timestamp if data_points else start_time # Workout-level info (name etc.)
duration = int((end_time - start_time).total_seconds()) if data_points else 0 wkt_name = _get_field_str(frame, "wkt_name")
if wkt_name:
session_data.setdefault("workout_name", wkt_name)
elif frame.name == "exercise_title":
title = _get_field_str(frame, "exercise_name")
if title:
current_exercise = title
elif frame.name == "set":
ex_set = _parse_set(frame, current_exercise)
if ex_set:
exercise_sets.append(ex_set)
# Determine timing
sport = session_data.get("sport", "")
sport_name = SPORT_NAMES.get(sport.lower() if sport else "", sport or "Workout")
if data_points:
start_time = data_points[0].timestamp
end_time = data_points[-1].timestamp
duration = int((end_time - start_time).total_seconds())
else:
# No record data (strength, etc.) — use session timestamps
start_time = session_data.get("start_time") or datetime.now(timezone.utc)
elapsed = session_data.get("total_elapsed_time")
duration = int(elapsed) if elapsed else 0
end_time = start_time
name = session_data.get("workout_name") or sport_name
activity = Activity( activity = Activity(
rider_id=rider_id, rider_id=rider_id,
name=session_data.get("sport", "Ride"), name=name,
activity_type=session_data.get("sub_sport", "road"), activity_type=session_data.get("sub_sport") or sport or "generic",
date=start_time, date=start_time,
duration=duration, duration=duration,
distance=session_data.get("total_distance"), distance=session_data.get("total_distance"),
@@ -44,7 +89,7 @@ def parse_fit_file(
file_path=file_path, file_path=file_path,
) )
return activity, data_points return activity, data_points, exercise_sets
def _parse_record(frame: fitdecode.FitDataMessage) -> DataPoint | None: def _parse_record(frame: fitdecode.FitDataMessage) -> DataPoint | None:
@@ -71,12 +116,44 @@ def _parse_record(frame: fitdecode.FitDataMessage) -> DataPoint | None:
def _parse_session(frame: fitdecode.FitDataMessage) -> dict: def _parse_session(frame: fitdecode.FitDataMessage) -> dict:
"""Extract session-level data from FIT session message.""" """Extract session-level data from FIT session message."""
start_time = _get_field(frame, "start_time")
if isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
return { return {
"sport": _get_field_str(frame, "sport"), "sport": _get_field_str(frame, "sport"),
"sub_sport": _get_field_str(frame, "sub_sport"), "sub_sport": _get_field_str(frame, "sub_sport"),
"total_distance": _get_field(frame, "total_distance"), "total_distance": _get_field(frame, "total_distance"),
"total_ascent": _get_field(frame, "total_ascent"), "total_ascent": _get_field(frame, "total_ascent"),
"total_elapsed_time": _get_field(frame, "total_elapsed_time"), "total_elapsed_time": _get_field(frame, "total_elapsed_time"),
"total_calories": _get_field(frame, "total_calories"),
"avg_heart_rate": _get_field(frame, "avg_heart_rate"),
"max_heart_rate": _get_field(frame, "max_heart_rate"),
"start_time": start_time,
}
def _parse_set(frame: fitdecode.FitDataMessage, exercise_name: str | None) -> dict | None:
"""Parse a set message from a strength/cardio workout."""
set_type = _get_field_str(frame, "set_type")
# set_type: 0=active, 1=rest
if set_type is not None and str(set_type) in ("1", "rest"):
return None # Skip rest sets
repetitions = _get_field(frame, "repetitions")
weight = _get_field(frame, "weight")
duration = _get_field(frame, "duration")
start_time = _get_field(frame, "start_time") or _get_field(frame, "timestamp")
category = _get_field_str(frame, "exercise_category")
exercise = _get_field_str(frame, "exercise_name")
return {
"exercise_name": exercise or exercise_name or category or "Unknown",
"exercise_category": category,
"repetitions": int(repetitions) if repetitions is not None else None,
"weight": round(float(weight), 1) if weight is not None else None,
"duration": round(float(duration), 1) if duration is not None else None,
"start_time": start_time.isoformat() if isinstance(start_time, datetime) else None,
} }

View File

@@ -94,9 +94,18 @@ export const useCoachingStore = defineStore('coaching', () => {
} }
} }
async function linkActivity(activityId: string, planId: string, week: number, day: string): Promise<void> {
await api.post('/coaching/link', { activity_id: activityId, plan_id: planId, week, day })
}
async function unlinkActivity(activityId: string): Promise<void> {
await api.post(`/coaching/unlink/${activityId}`)
}
return { return {
currentChat, activePlan, todayWorkout, loading, sending, currentChat, activePlan, todayWorkout, loading, sending,
startOnboarding, getOnboardingStatus, sendMessage, createChat, getChat, listChats, startOnboarding, getOnboardingStatus, sendMessage, createChat, getChat, listChats,
generatePlan, fetchActivePlan, fetchCompliance, fetchTodayWorkout, startPlanAdjustment, generatePlan, fetchActivePlan, fetchCompliance, fetchTodayWorkout, startPlanAdjustment,
linkActivity, unlinkActivity,
} }
}) })

View File

@@ -38,6 +38,15 @@ export interface Interval {
duration: number | null duration: number | null
} }
export interface ExerciseSet {
exercise_name: string
exercise_category: string | null
repetitions: number | null
weight: number | null
duration: number | null
start_time: string | null
}
export interface Activity { export interface Activity {
id: string id: string
rider_id: string rider_id: string
@@ -49,6 +58,10 @@ export interface Activity {
elevation_gain: number | null elevation_gain: number | null
metrics: ActivityMetrics | null metrics: ActivityMetrics | null
intervals: Interval[] intervals: Interval[]
exercise_sets: ExerciseSet[] | null
training_plan_id: string | null
plan_week: number | null
plan_day: string | null
} }
export interface DataPoint { export interface DataPoint {
@@ -173,6 +186,14 @@ export interface TrainingPlan {
weeks: TrainingPlanWeek[] weeks: TrainingPlanWeek[]
} }
export interface ComplianceDayStatus {
day: string
planned: string
workout_type: string
activity_id: string | null
completed: boolean
}
export interface ComplianceWeek { export interface ComplianceWeek {
week_number: number week_number: number
focus: string focus: string
@@ -184,6 +205,7 @@ export interface ComplianceWeek {
actual_rides: number actual_rides: number
adherence_pct: number adherence_pct: number
status: string status: string
days: ComplianceDayStatus[]
} }
export interface TodayWorkout { export interface TodayWorkout {
@@ -198,4 +220,6 @@ export interface TodayWorkout {
duration_minutes: number duration_minutes: number
target_tss: number target_tss: number
target_if: number target_if: number
linked_activity_id: string | null
completed: boolean
} }

View File

@@ -52,6 +52,22 @@ const moods = [
const hasGps = computed(() => stream.value.some(dp => dp.latitude && dp.longitude)) const hasGps = computed(() => stream.value.some(dp => dp.latitude && dp.longitude))
const hasAltitude = computed(() => stream.value.some(dp => dp.altitude != null)) const hasAltitude = computed(() => stream.value.some(dp => dp.altitude != null))
const hasSpeed = computed(() => stream.value.some(dp => dp.speed != null && dp.speed > 0)) const hasSpeed = computed(() => stream.value.some(dp => dp.speed != null && dp.speed > 0))
const hasPower = computed(() => stream.value.some(dp => dp.power != null))
const hasStream = computed(() => stream.value.length > 0)
const isStrength = computed(() => {
const t = activity.value?.activity_type?.toLowerCase() || ''
return t.includes('strength') || t.includes('training') || (activity.value?.exercise_sets?.length ?? 0) > 0
})
const groupedExercises = computed(() => {
const sets = activity.value?.exercise_sets
if (!sets?.length) return []
const groups: Record<string, typeof sets> = {}
for (const s of sets) {
const name = s.exercise_name || 'Unknown'
;(groups[name] ??= []).push(s)
}
return Object.entries(groups).map(([name, sets]) => ({ name, sets }))
})
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600) const h = Math.floor(seconds / 3600)
@@ -336,7 +352,7 @@ onMounted(async () => {
<p class="text-lg font-bold">{{ formatDuration(activity.duration) }}</p> <p class="text-lg font-bold">{{ formatDuration(activity.duration) }}</p>
</template> </template>
</Card> </Card>
<Card> <Card v-if="activity.distance">
<template #content> <template #content>
<p class="text-surface-500 text-xs uppercase">Distance</p> <p class="text-surface-500 text-xs uppercase">Distance</p>
<p class="text-lg font-bold">{{ formatDistance(activity.distance) }}</p> <p class="text-lg font-bold">{{ formatDistance(activity.distance) }}</p>
@@ -366,8 +382,55 @@ onMounted(async () => {
<p class="text-lg font-bold">{{ activity.elevation_gain }}m</p> <p class="text-lg font-bold">{{ activity.elevation_gain }}m</p>
</template> </template>
</Card> </Card>
<Card v-if="activity.metrics?.calories">
<template #content>
<p class="text-surface-500 text-xs uppercase">Calories</p>
<p class="text-lg font-bold">{{ activity.metrics.calories }} kcal</p>
</template>
</Card>
<Card v-if="isStrength && groupedExercises.length">
<template #content>
<p class="text-surface-500 text-xs uppercase">Exercises</p>
<p class="text-lg font-bold">{{ groupedExercises.length }}</p>
</template>
</Card>
<Card v-if="isStrength && activity.exercise_sets?.length">
<template #content>
<p class="text-surface-500 text-xs uppercase">Sets</p>
<p class="text-lg font-bold">{{ activity.exercise_sets.length }}</p>
</template>
</Card>
</div> </div>
<!-- Exercise Sets (Strength) -->
<Card v-if="groupedExercises.length > 0" class="mb-6">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-list text-primary"></i>
Exercises
</div>
</template>
<template #content>
<div class="space-y-4">
<div v-for="group in groupedExercises" :key="group.name">
<h4 class="font-semibold text-surface-800 mb-2">{{ group.name }}</h4>
<div class="flex flex-wrap gap-2">
<div
v-for="(set, i) in group.sets"
:key="i"
class="bg-surface-50 border border-surface-200 rounded-lg px-3 py-2 text-sm"
>
<span class="text-surface-400 text-xs mr-1">Set {{ i + 1 }}</span>
<span v-if="set.repetitions" class="font-semibold">{{ set.repetitions }} reps</span>
<span v-if="set.weight" class="text-surface-500"> x {{ set.weight }} kg</span>
<span v-if="set.duration && !set.repetitions" class="font-semibold">{{ Math.round(set.duration) }}s</span>
</div>
</div>
</div>
</div>
</template>
</Card>
<!-- AI Summary --> <!-- AI Summary -->
<Card class="mb-6"> <Card class="mb-6">
<template #title> <template #title>
@@ -413,8 +476,8 @@ onMounted(async () => {
</Card> </Card>
<!-- Power/HR/Cadence chart --> <!-- Power/HR/Cadence chart -->
<Card v-if="stream.length > 0" class="mb-6"> <Card v-if="hasStream && (hasPower || stream.some(dp => dp.heart_rate != null))" class="mb-6">
<template #title>Power / HR / Cadence</template> <template #title>{{ hasPower ? 'Power / HR / Cadence' : 'Heart Rate' }}</template>
<template #content> <template #content>
<VChart :option="buildStreamChart()" style="height: 300px" autoresize /> <VChart :option="buildStreamChart()" style="height: 300px" autoresize />
</template> </template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed, nextTick } from 'vue' import { onMounted, ref, computed, nextTick } from 'vue'
import { RouterLink } from 'vue-router'
import { useCoachingStore } from '../stores/coaching' import { useCoachingStore } from '../stores/coaching'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import type { ChatMessage, TrainingPlan, ComplianceWeek, TodayWorkout } from '../types/models' import type { ChatMessage, TrainingPlan, ComplianceWeek, TodayWorkout } from '../types/models'
@@ -186,11 +187,14 @@ onMounted(async () => {
</div> </div>
<!-- Today's workout card --> <!-- Today's workout card -->
<Card v-if="todayWorkout && onboardingCompleted" class="mb-6 border-l-4 border-l-primary"> <Card v-if="todayWorkout && onboardingCompleted" :class="['mb-6 border-l-4', todayWorkout.completed ? 'border-l-green-500' : 'border-l-primary']">
<template #content> <template #content>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-surface-500 text-xs uppercase mb-1">Тренировка на сегодня</p> <div class="flex items-center gap-2 mb-1">
<p class="text-surface-500 text-xs uppercase">Тренировка на сегодня</p>
<Tag v-if="todayWorkout.completed" value="Выполнено" severity="success" class="text-xs" />
</div>
<p class="text-lg font-bold">{{ todayWorkout.title }}</p> <p class="text-lg font-bold">{{ todayWorkout.title }}</p>
<p class="text-surface-600 text-sm mt-1">{{ todayWorkout.description }}</p> <p class="text-surface-600 text-sm mt-1">{{ todayWorkout.description }}</p>
</div> </div>
@@ -351,44 +355,74 @@ onMounted(async () => {
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<Card v-for="week in compliance" :key="week.week_number"> <Card v-for="week in compliance" :key="week.week_number">
<template #content> <template #content>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div class="flex flex-col gap-4">
<div class="flex-1"> <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center gap-3 mb-2"> <div class="flex-1">
<h3 class="font-semibold">Неделя {{ week.week_number }}</h3> <div class="flex items-center gap-3 mb-2">
<Tag <h3 class="font-semibold">Неделя {{ week.week_number }}</h3>
:value="week.status === 'upcoming' ? 'Впереди' : week.status === 'current' ? 'Текущая' : 'Завершена'" <Tag
:severity="week.status === 'upcoming' ? 'secondary' : week.status === 'current' ? 'info' : 'success'" :value="week.status === 'upcoming' ? 'Впереди' : week.status === 'current' ? 'Текущая' : 'Завершена'"
class="text-xs" :severity="week.status === 'upcoming' ? 'secondary' : week.status === 'current' ? 'info' : 'success'"
/> class="text-xs"
<span class="text-sm text-surface-500">{{ week.focus }}</span> />
<span class="text-sm text-surface-500">{{ week.focus }}</span>
</div>
<div class="mb-2">
<div class="flex items-center justify-between text-sm mb-1">
<span class="text-surface-500">Выполнение</span>
<span class="font-semibold">{{ week.adherence_pct }}%</span>
</div>
<ProgressBar
:value="week.adherence_pct"
:showValue="false"
style="height: 8px"
:class="week.adherence_pct >= 80 ? '' : week.adherence_pct >= 50 ? '[&_.p-progressbar-value]:!bg-amber-500' : '[&_.p-progressbar-value]:!bg-red-500'"
/>
</div>
</div> </div>
<div class="mb-2"> <div class="grid grid-cols-3 gap-4 text-center text-sm">
<div class="flex items-center justify-between text-sm mb-1"> <div>
<span class="text-surface-500">Выполнение</span> <p class="text-surface-500 text-xs">Тренировки</p>
<span class="font-semibold">{{ week.adherence_pct }}%</span> <p class="font-semibold">{{ week.actual_rides }}/{{ week.planned_rides }}</p>
</div>
<div>
<p class="text-surface-500 text-xs">TSS</p>
<p class="font-semibold">{{ week.actual_tss }}/{{ week.planned_tss }}</p>
</div>
<div>
<p class="text-surface-500 text-xs">Часы</p>
<p class="font-semibold">{{ week.actual_hours }}/{{ week.planned_hours }}</p>
</div> </div>
<ProgressBar
:value="week.adherence_pct"
:showValue="false"
style="height: 8px"
:class="week.adherence_pct >= 80 ? '' : week.adherence_pct >= 50 ? '[&_.p-progressbar-value]:!bg-amber-500' : '[&_.p-progressbar-value]:!bg-red-500'"
/>
</div> </div>
</div> </div>
<div class="grid grid-cols-3 gap-4 text-center text-sm"> <!-- Per-day breakdown -->
<div> <div v-if="week.days?.length" class="flex flex-wrap gap-2">
<p class="text-surface-500 text-xs">Тренировки</p> <div
<p class="font-semibold">{{ week.actual_rides }}/{{ week.planned_rides }}</p> v-for="d in week.days"
</div> :key="d.day"
<div> :class="[
<p class="text-surface-500 text-xs">TSS</p> 'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs border',
<p class="font-semibold">{{ week.actual_tss }}/{{ week.planned_tss }}</p> d.completed
</div> ? 'bg-green-50 border-green-200 text-green-700'
<div> : d.workout_type === 'rest'
<p class="text-surface-500 text-xs">Часы</p> ? 'bg-surface-50 border-surface-200 text-surface-400'
<p class="font-semibold">{{ week.actual_hours }}/{{ week.planned_hours }}</p> : 'bg-white border-surface-200 text-surface-600'
]"
>
<i :class="['pi text-xs', d.completed ? 'pi-check-circle text-green-500' : d.workout_type === 'rest' ? 'pi-minus text-surface-300' : 'pi-circle text-surface-300']"></i>
<span class="font-semibold">{{ dayLabel(d.day) }}</span>
<span>{{ d.planned }}</span>
<RouterLink
v-if="d.activity_id"
:to="{ name: 'activity-detail', params: { id: d.activity_id } }"
class="text-primary hover:underline ml-1"
@click.stop
>
<i class="pi pi-external-link text-xs"></i>
</RouterLink>
</div> </div>
</div> </div>
</div> </div>

Binary file not shown.