Spaces:
Running
Running
| from datetime import date, timedelta | |
| from typing import List, Dict, Any, Optional, Union | |
| import uuid | |
| import config | |
| from domain.training.planned_session import PlannedSession | |
| from domain.training.run import Run | |
| from persistence.repositories.planned_session_repository import PlannedSessionRepository | |
| from persistence.repositories.goal_repo import SqlGoalRepository | |
| from persistence.repositories.runner_repo import RunnerRepository | |
| from engines import structure_engine | |
| from observability import logger as obs_logger | |
| from observability import components as obs_components | |
| class StructureService: | |
| """ | |
| Orchestrates weekly structure integrity tracking. | |
| Gated by storage capability. | |
| """ | |
| def __init__( | |
| self, | |
| planned_repo: PlannedSessionRepository, | |
| goal_repo: SqlGoalRepository, | |
| runner_repo: RunnerRepository, | |
| ): | |
| self.planned_repo = planned_repo | |
| self.goal_repo = goal_repo | |
| self.runner_repo = runner_repo | |
| def ensure_week_initialized( | |
| self, runner_id: uuid.UUID, week_start: date | |
| ) -> List[PlannedSession]: | |
| """ | |
| Generates and persists a template if no sessions exist for the week. | |
| """ | |
| with obs_logger.start_span("structure_service.ensure_week_initialized", component=obs_components.SERVICE): | |
| sessions = self.planned_repo.get_sessions_for_week(runner_id, week_start) | |
| if not sessions: | |
| obs_logger.log_event( | |
| "info", | |
| "No sessions found for week, generating template", | |
| event="week_template_needed", | |
| ) | |
| # Fetch context for template generation | |
| goal = self.goal_repo.get_active_goal(runner_id) | |
| profile = self.runner_repo.get_runner_profile(runner_id) | |
| sessions = structure_engine.generate_week_template( | |
| runner_id=runner_id, week_start=week_start, goal=goal, profile=profile | |
| ) | |
| self.planned_repo.create_sessions_bulk(sessions) | |
| obs_logger.log_event( | |
| "info", | |
| "Week template generated and persisted", | |
| event="week_template_generated", | |
| fields={"runner_id": str(runner_id), "week_start": week_start.isoformat()}, | |
| ) | |
| return sessions | |
| def process_run_for_structure(self, run: Run, runner_id: uuid.UUID) -> None: | |
| """ | |
| Matches a new run to planned sessions and updates status. | |
| """ | |
| with obs_logger.start_span("structure_service.process_run", component=obs_components.SERVICE): | |
| if not run.start_time: | |
| obs_logger.log_event( | |
| "warning", | |
| "Skipping structure processing: run missing start_time", | |
| fields={"run_id": str(run.id)}, | |
| ) | |
| return | |
| # Determine week_start (Monday) | |
| run_date = run.start_time.date() | |
| week_start = run_date - timedelta(days=run_date.weekday()) | |
| sessions = self.ensure_week_initialized(runner_id, week_start) | |
| match_id = structure_engine.match_run_to_session(run, sessions) | |
| if match_id: | |
| self.planned_repo.mark_completed(match_id, run.id) | |
| obs_logger.log_event( | |
| "info", | |
| "Run matched to planned session", | |
| event="session_matched", | |
| fields={"run_id": str(run.id), "session_id": str(match_id)}, | |
| ) | |
| def compute_structure_status( | |
| self, runner_id: uuid.UUID, week_start: date, weekly_volume: float, goal_volume: float | |
| ) -> Dict[str, Any]: | |
| """ | |
| Computes structure DTO for UI/Snapshot. | |
| Does not persist. | |
| """ | |
| # Always return a DTO, even if storage is disabled (will be empty) | |
| sessions = self.planned_repo.get_sessions_for_week(runner_id, week_start) | |
| if not sessions: | |
| return { | |
| "weekday_completed": 0, | |
| "weekday_total": 0, | |
| "long_run_completed": False, | |
| "classification": "reset_week", | |
| "km_remaining": max(0.0, goal_volume - weekly_volume), | |
| } | |
| weekday_sessions = [s for s in sessions if s.session_type == "weekday"] | |
| long_run_session = next((s for s in sessions if s.session_type == "long_run"), None) | |
| weekday_completed = sum(1 for s in weekday_sessions if s.completed_run_id) | |
| long_run_completed = ( | |
| long_run_session.completed_run_id is not None if long_run_session else False | |
| ) | |
| with obs_logger.start_span("structure_engine.classify_week", component=obs_components.DOMAIN): | |
| classification = structure_engine.classify_week(sessions, weekly_volume, goal_volume) | |
| obs_logger.log_event( | |
| "info", | |
| "Week structure classified", | |
| event="week_classified", | |
| fields={ | |
| "runner_id": str(runner_id), | |
| "week_start": week_start.isoformat(), | |
| "classification": classification, | |
| }, | |
| ) | |
| return { | |
| "weekday_completed": weekday_completed, | |
| "weekday_total": len(weekday_sessions), | |
| "long_run_completed": long_run_completed, | |
| "classification": classification, | |
| "km_remaining": max(0.0, goal_volume - weekly_volume), | |
| } | |