fix
This commit is contained in:
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
BIN
uploads/a17da6a1-29be-48a6-9601-8c07f2a90246.fit
Normal file
BIN
uploads/a17da6a1-29be-48a6-9601-8c07f2a90246.fit
Normal file
Binary file not shown.
Reference in New Issue
Block a user