fix
This commit is contained in:
@@ -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]:
|
||||
"""Compare planned vs actual per week."""
|
||||
"""Compare planned vs actual per week, matching linked activities to specific days."""
|
||||
if not plan.weeks_json:
|
||||
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_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
|
||||
if week_start > date.today():
|
||||
results.append({
|
||||
"week_number": week_num,
|
||||
"focus": week.get("focus", ""),
|
||||
"planned_tss": week.get("target_tss", 0),
|
||||
"planned_tss": planned_tss,
|
||||
"actual_tss": 0,
|
||||
"planned_hours": week.get("target_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,
|
||||
"adherence_pct": 0,
|
||||
"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
|
||||
|
||||
@@ -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_hours = sum(r[0].duration for r in acts) / 3600
|
||||
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
|
||||
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
|
||||
|
||||
@@ -435,11 +472,52 @@ async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> lis
|
||||
"actual_rides": actual_rides,
|
||||
"adherence_pct": adherence,
|
||||
"status": "current" if is_current else "completed",
|
||||
"days": day_statuses,
|
||||
})
|
||||
|
||||
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:
|
||||
"""Extract JSON from AI response text."""
|
||||
# Try to find JSON in code blocks
|
||||
|
||||
@@ -7,14 +7,30 @@ import fitdecode
|
||||
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(
|
||||
file_content: bytes,
|
||||
rider_id: uuid.UUID,
|
||||
file_path: str,
|
||||
) -> tuple[Activity, list[DataPoint]]:
|
||||
"""Parse a .FIT file and return an Activity with its DataPoints."""
|
||||
) -> tuple[Activity, list[DataPoint], list[dict]]:
|
||||
"""Parse a .FIT file and return an Activity with its DataPoints and exercise sets."""
|
||||
data_points: list[DataPoint] = []
|
||||
session_data: dict = {}
|
||||
exercise_sets: list[dict] = []
|
||||
current_exercise: str | None = None
|
||||
|
||||
with fitdecode.FitReader(BytesIO(file_content)) as fit:
|
||||
for frame in fit:
|
||||
@@ -29,14 +45,43 @@ def parse_fit_file(
|
||||
elif frame.name == "session":
|
||||
session_data = _parse_session(frame)
|
||||
|
||||
start_time = data_points[0].timestamp if data_points else datetime.now(timezone.utc)
|
||||
end_time = data_points[-1].timestamp if data_points else start_time
|
||||
duration = int((end_time - start_time).total_seconds()) if data_points else 0
|
||||
elif frame.name == "workout":
|
||||
# Workout-level info (name etc.)
|
||||
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(
|
||||
rider_id=rider_id,
|
||||
name=session_data.get("sport", "Ride"),
|
||||
activity_type=session_data.get("sub_sport", "road"),
|
||||
name=name,
|
||||
activity_type=session_data.get("sub_sport") or sport or "generic",
|
||||
date=start_time,
|
||||
duration=duration,
|
||||
distance=session_data.get("total_distance"),
|
||||
@@ -44,7 +89,7 @@ def parse_fit_file(
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
return activity, data_points
|
||||
return activity, data_points, exercise_sets
|
||||
|
||||
|
||||
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:
|
||||
"""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 {
|
||||
"sport": _get_field_str(frame, "sport"),
|
||||
"sub_sport": _get_field_str(frame, "sub_sport"),
|
||||
"total_distance": _get_field(frame, "total_distance"),
|
||||
"total_ascent": _get_field(frame, "total_ascent"),
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user