feat: enhance UI, fix backend routes, and add E2E quality gates

- UI: Polish PlanView toggles, buttons, and fix missing icons (running/activity).
- Backend: Fix route shadowing in main.py, add startup route check, and improve API test coverage.
- Tests: Add Playwright E2E smoke test, eslint-plugin-import, and integrate all into 'make check'.
This commit is contained in:
Moritz Graf
2026-01-02 09:40:37 +01:00
parent a2c86dfea7
commit 715da2a816
26 changed files with 3753 additions and 605 deletions
@@ -0,0 +1,184 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"workoutName": {
"type": "string",
"description": "Name of the workout"
},
"description": {
"type": [
"string",
"null"
],
"description": "Description of the workout"
},
"sportTypeId": {
"type": "integer",
"description": "ID of the sport type (e.g., 1 for Running)",
"enum": [
1,
2,
3,
5,
9
]
},
"subSportTypeId": {
"type": [
"integer",
"null"
],
"description": "Sub-sport type ID (optional)"
},
"workoutSegments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"segmentOrder": {
"type": "integer"
},
"sportTypeId": {
"type": "integer"
},
"workoutSteps": {
"type": "array",
"items": {
"$ref": "#/definitions/workoutStep"
}
}
},
"required": [
"workoutSteps"
]
}
}
},
"required": [
"workoutName",
"sportTypeId",
"workoutSegments"
],
"definitions": {
"workoutStep": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"ExecutableStepDTO",
"RepeatGroupDTO"
]
},
"stepId": {
"type": [
"integer",
"null"
]
},
"stepOrder": {
"type": [
"integer",
"null"
]
},
"childStepId": {
"type": [
"integer",
"null"
],
"description": "Used if part of a repeat group? (Exact usage varies)"
},
"stepTypeId": {
"type": [
"integer",
"null"
],
"description": "Type of step (Warmup, Interval, etc.)",
"enum": [
0,
1,
2,
3,
4,
5
]
},
"endConditionId": {
"type": [
"integer",
"null"
],
"description": "Condition to end the step (Time, Distance, etc.)",
"enum": [
1,
2,
3,
4
]
},
"endConditionValue": {
"type": [
"number",
"null"
],
"description": "Value for the end condition (e.g., seconds or meters). Null if lap button."
},
"targetTypeId": {
"type": [
"integer",
"null"
],
"description": "Metric to target (Speed, Heart Rate, etc.)",
"enum": [
1,
2,
3,
4,
5,
6
]
},
"targetValueOne": {
"type": [
"number",
"null"
],
"description": "Lower bound or specific value for target"
},
"targetValueTwo": {
"type": [
"number",
"null"
],
"description": "Upper bound for target (if range)"
},
"zoneNumber": {
"type": [
"integer",
"null"
],
"description": "If target is a zone (1-5)"
},
"description": {
"type": [
"string",
"null"
]
},
"smartRepeat": {
"type": "boolean",
"description": "For Repeat Groups"
},
"numberOfIterations": {
"type": [
"integer",
"null"
],
"description": "For Repeat Groups"
}
}
}
}
}
+44 -1
View File
@@ -1,4 +1,6 @@
import json
import logging
import os
from typing import Any, Dict, List, Optional
from garmin.validator import WorkoutValidator
@@ -9,8 +11,16 @@ logger = logging.getLogger(__name__)
class WorkoutManager:
"""Manages workout generation and modification."""
def __init__(self, ai_engine=None):
def __init__(self, ai_engine=None, storage_dir=None):
self.ai_engine = ai_engine if ai_engine is not None else RecommendationEngine()
# Default local storage
if storage_dir is None:
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
storage_dir = os.path.join(base_dir, "data/local/workouts")
self.storage_dir = storage_dir
os.makedirs(self.storage_dir, exist_ok=True)
def validate_workout_json(self, workout_data: Dict[str, Any]) -> List[str]:
"""Validate a workout structure against Garmin schema."""
@@ -20,6 +30,37 @@ class WorkoutManager:
"""Get Garmin constants for frontend."""
return WorkoutValidator.get_constants()
def list_local_workouts(self) -> List[str]:
"""List available local workout files."""
files = []
if os.path.exists(self.storage_dir):
for f in os.listdir(self.storage_dir):
if f.endswith(".json"):
files.append(f)
return sorted(files)
def save_local_workout(self, filename: str, data: Dict[str, Any]) -> str:
"""Save workout JSON to local storage."""
if not filename.endswith(".json"):
filename += ".json"
path = os.path.join(self.storage_dir, filename)
with open(path, "w") as f:
json.dump(data, f, indent=2)
return filename
def load_local_workout(self, filename: str) -> Dict[str, Any]:
"""Load a workout from local storage."""
if not filename.endswith(".json"):
filename += ".json"
path = os.path.join(self.storage_dir, filename)
if not os.path.exists(path):
raise FileNotFoundError(f"Workout {filename} not found")
with open(path, "r") as f:
return json.load(f)
def generate_workout_json(self, prompt: str, existing_workout: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Ask Gemini to generate or modify a Garmin workout JSON.
@@ -66,6 +107,8 @@ class WorkoutManager:
steps.append(self._create_step(2, "interval", "reps", 10)) # Bench
steps.append(self._create_step(3, "rest", "time", 60))
steps.append(self._create_step(4, "interval", "reps", 10)) # Squat
steps.append(self._create_step(5, "rest", "time", 60))
steps.append(self._create_step(6, "interval", "reps", 10)) # Lunges
return {
"workoutName": workout_name,
+42 -2
View File
@@ -275,12 +275,13 @@ async def get_workouts():
env.load_service_env("garmin")
client = GarminClient()
if client.login() != "SUCCESS":
# Fallback to local if auth fails (TODO: Implement local workout storage listing if needed)
# For now, return empty or error
# Fallback to local if auth fails
raise HTTPException(status_code=401, detail="Garmin login required to browse online workouts")
return client.get_workouts_list(limit=50)
@app.post("/workouts/chat")
async def chat_workout(payload: WorkoutPrompt):
"""Generate or modify a workout based on prompt."""
@@ -339,6 +340,45 @@ async def upload_workout(workout: Dict[str, Any]):
except Exception as e:
return {"success": False, "error":str(e)}
@app.get("/workouts/local")
async def list_local_workouts():
"""List local workout files."""
manager = WorkoutManager()
return manager.list_local_workouts()
@app.post("/workouts/local/save")
async def save_local_workout(payload: Dict[str, Any]):
"""Save workout to local file."""
name = payload.get("filename")
data = payload.get("workout")
if not name or not data:
raise HTTPException(status_code=400, detail="Filename and workout data required")
manager = WorkoutManager()
saved_name = manager.save_local_workout(name, data)
return {"status": "SUCCESS", "filename": saved_name}
@app.get("/workouts/local/{filename}")
async def load_local_workout(filename: str):
"""Load local workout file."""
manager = WorkoutManager()
try:
return manager.load_local_workout(filename)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Workout not found")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/workouts/{workout_id}")
async def get_workout_detail(workout_id: str):
"""Get full details for a remote workout."""
env.load_service_env("garmin")
client = GarminClient()
if client.login() != "SUCCESS":
raise HTTPException(status_code=401, detail="Auth failed")
return client.get_workout_detail(workout_id)
@app.get("/health")
async def health():
return {"status": "ok"}
+9 -1
View File
@@ -1,3 +1,6 @@
import os
from typing import Optional
from common.settings_manager import SettingsManager
from garmin.sync import GarminSync
@@ -5,7 +8,12 @@ from garmin.sync import GarminSync
class FitnessTools:
"""Tools accessible by the AI Agent."""
def __init__(self, garmin_storage: str = "data/local/garmin"):
def __init__(self, garmin_storage: Optional[str] = None):
if garmin_storage is None:
# Calculate relative to project root (backend/src/recommendations/tools.py -> ../../../data/local/garmin)
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../"))
garmin_storage = os.path.join(base_dir, "data/local/garmin")
self.sync = GarminSync(None, storage_dir=garmin_storage)
self.settings = SettingsManager()