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), }