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

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