File size: 5,537 Bytes
557ee65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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),
        }