runner-ai-intelligence / src /services /goal_service.py
avfranco's picture
HF Space deploy snapshot (minimal allow-list)
d64fd55
from typing import Optional, List, Dict, Any, Union
import uuid
from datetime import datetime, date
from domain.runner.goal import Goal
from domain.training.weekly_snapshot import WeeklySnapshot
from domain.training.period_comparison import PeriodComparison
from persistence.repositories.goal_repo import SqlGoalRepository
from persistence.repositories.null_goal_repo import NullGoalRepository
from observability import logger as obs_logger
from observability import components as obs_components
class GoalService:
"""Service for deterministic goal progress calculation and lifecycle management."""
def __init__(self, goal_repo: Union[SqlGoalRepository, NullGoalRepository]):
self.goal_repo = goal_repo
def create_goal(self, runner_id: uuid.UUID, goal_type: str, target_value: float, unit: str, target_date: Optional[datetime] = None) -> Goal:
"""Creates a new goal, archiving the existing active one."""
obs_logger.log_event(
"info",
"Step progress computed",
component=obs_components.SERVICE,
fields={"runner_id": str(runner_id), "type": goal_type}
)
# Archive current active goal
self.goal_repo.archive_active_goals(runner_id)
obs_logger.log_event(
"info",
"goal_archived",
component=obs_components.SERVICE,
fields={"runner_id": str(runner_id)}
)
new_goal = Goal(
id=uuid.uuid4(),
runner_id=runner_id,
type=goal_type,
target_value=target_value,
unit=unit,
target_date=target_date,
status="active"
)
self.goal_repo.create_goal(new_goal)
obs_logger.log_event(
"info",
"goal_activated",
component=obs_components.SERVICE,
fields={"goal_id": str(new_goal.id)}
)
return new_goal
def archive_goal(self, goal_id: uuid.UUID) -> None:
"""Manually archives a goal."""
# This would require a get_goal_by_id in repo, but for now we work with active goals
# Simplified for this refactor as per objectives
pass
def get_active_goal(self, runner_id: uuid.UUID) -> Optional[Goal]:
"""Retrieves the active goal for a runner."""
return self.goal_repo.get_active_goal(runner_id)
def compute_goal_progress(
self,
goal: Goal,
weekly_snapshot: Optional[WeeklySnapshot] = None,
period_comparison: Optional[PeriodComparison] = None,
) -> Dict[str, Any]:
"""
Computes progress percentage and status for a goal.
Rules:
- Distance >= 100% -> On Track
- 80-99% -> Slightly Behind
- < 80% -> Behind
"""
progress_percentage = 0.0
status = "behind"
if goal.type == "volume":
if weekly_snapshot and goal.target_value > 0:
progress_percentage = (weekly_snapshot.total_distance_km / goal.target_value) * 100
elif goal.type == "pace":
if weekly_snapshot and weekly_snapshot.avg_pace_sec_per_km > 0:
# Target Pace / Current Pace (lower pace is better)
# Example: Target 300s/km, Current 330s/km -> 300/330 = 90.9%
progress_percentage = (
goal.target_value / weekly_snapshot.avg_pace_sec_per_km
) * 100
elif goal.type == "race":
# Simple V1: based on weekly volume increase (growth trend toward race date)
if period_comparison:
growth = getattr(
period_comparison,
"distance_delta_pct",
getattr(period_comparison, "distance_delta_km", 0.0),
)
if not isinstance(growth, (int, float)):
progress_percentage = 0.0
elif growth >= 5.0:
progress_percentage = 100.0
elif growth >= 0:
progress_percentage = 80.0 + (growth * 4.0) # 0% -> 80%, 5% -> 100%
else:
progress_percentage = max(0.0, 80.0 + growth * 2.0) # -10% -> 60%
elif weekly_snapshot:
# No comparison? fallback to 50% if we have recent data
progress_percentage = 50.0
# Clamp progress
progress_percentage = min(100.0, max(0.0, progress_percentage))
if progress_percentage >= 100.0:
status = "on_track"
elif progress_percentage >= 80.0:
status = "slightly_behind"
else:
status = "behind"
return {
"progress_percentage": round(progress_percentage, 1),
"status": status,
"goal_type": goal.type,
"target_value": goal.target_value,
"target_unit": goal.unit,
}