mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-01 03:52:06 +00:00
change to TUI
This commit is contained in:
4
tui/__init__.py
Normal file
4
tui/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
TUI package for AI Cycling Coach.
|
||||
Contains Textual-based terminal user interface components.
|
||||
"""
|
||||
BIN
tui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tui/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
18
tui/services/__init__.py
Normal file
18
tui/services/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Service layer for TUI application.
|
||||
Contains business logic extracted from FastAPI routes for direct method calls.
|
||||
"""
|
||||
|
||||
from .dashboard_service import DashboardService
|
||||
from .plan_service import PlanService
|
||||
from .workout_service import WorkoutService
|
||||
from .rule_service import RuleService
|
||||
from .route_service import RouteService
|
||||
|
||||
__all__ = [
|
||||
'DashboardService',
|
||||
'PlanService',
|
||||
'WorkoutService',
|
||||
'RuleService',
|
||||
'RouteService'
|
||||
]
|
||||
BIN
tui/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/dashboard_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/dashboard_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/plan_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/plan_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/route_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/route_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/rule_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/rule_service.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/services/__pycache__/workout_service.cpython-313.pyc
Normal file
BIN
tui/services/__pycache__/workout_service.cpython-313.pyc
Normal file
Binary file not shown.
110
tui/services/dashboard_service.py
Normal file
110
tui/services/dashboard_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
"""
|
||||
Dashboard service for TUI application.
|
||||
Provides dashboard data without HTTP dependencies.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""Service for dashboard data operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_dashboard_data(self) -> Dict:
|
||||
"""Get consolidated dashboard data."""
|
||||
try:
|
||||
# Recent workouts (last 7 days)
|
||||
workout_result = await self.db.execute(
|
||||
select(Workout)
|
||||
.where(Workout.start_time >= datetime.now() - timedelta(days=7))
|
||||
.order_by(desc(Workout.start_time))
|
||||
.limit(5)
|
||||
)
|
||||
recent_workouts = workout_result.scalars().all()
|
||||
|
||||
# Current active plan - Modified since Plan model doesn't have 'active' field
|
||||
plan_result = await self.db.execute(
|
||||
select(Plan)
|
||||
.order_by(desc(Plan.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
current_plan = plan_result.scalar_one_or_none()
|
||||
|
||||
# Sync status
|
||||
sync_result = await self.db.execute(
|
||||
select(GarminSyncLog)
|
||||
.order_by(desc(GarminSyncLog.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
last_sync = sync_result.scalar_one_or_none()
|
||||
|
||||
# Calculate metrics
|
||||
total_duration = sum(w.duration_seconds or 0 for w in recent_workouts)
|
||||
weekly_volume = total_duration / 3600 if total_duration else 0
|
||||
|
||||
return {
|
||||
"recent_workouts": [
|
||||
{
|
||||
"id": w.id,
|
||||
"activity_type": w.activity_type,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"duration_seconds": w.duration_seconds,
|
||||
"distance_m": w.distance_m,
|
||||
"avg_hr": w.avg_hr,
|
||||
"avg_power": w.avg_power
|
||||
} for w in recent_workouts
|
||||
],
|
||||
"current_plan": {
|
||||
"id": current_plan.id,
|
||||
"version": current_plan.version,
|
||||
"created_at": current_plan.created_at.isoformat() if current_plan.created_at else None
|
||||
} if current_plan else None,
|
||||
"last_sync": {
|
||||
"last_sync_time": last_sync.last_sync_time.isoformat() if last_sync and last_sync.last_sync_time else None,
|
||||
"activities_synced": last_sync.activities_synced if last_sync else 0,
|
||||
"status": last_sync.status if last_sync else "never_synced",
|
||||
"error_message": last_sync.error_message if last_sync else None
|
||||
} if last_sync else None,
|
||||
"metrics": {
|
||||
"weekly_volume": weekly_volume,
|
||||
"workout_count": len(recent_workouts),
|
||||
"plan_progress": 0 # TODO: Calculate actual progress
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Dashboard data error: {str(e)}")
|
||||
|
||||
async def get_weekly_stats(self) -> Dict:
|
||||
"""Get weekly workout statistics."""
|
||||
try:
|
||||
week_start = datetime.now() - timedelta(days=7)
|
||||
|
||||
workout_result = await self.db.execute(
|
||||
select(Workout)
|
||||
.where(Workout.start_time >= week_start)
|
||||
)
|
||||
workouts = workout_result.scalars().all()
|
||||
|
||||
total_distance = sum(w.distance_m or 0 for w in workouts) / 1000 # Convert to km
|
||||
total_time = sum(w.duration_seconds or 0 for w in workouts) / 3600 # Convert to hours
|
||||
|
||||
return {
|
||||
"workout_count": len(workouts),
|
||||
"total_distance_km": round(total_distance, 1),
|
||||
"total_time_hours": round(total_time, 1),
|
||||
"avg_distance_km": round(total_distance / len(workouts), 1) if workouts else 0,
|
||||
"avg_duration_hours": round(total_time / len(workouts), 1) if workouts else 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Weekly stats error: {str(e)}")
|
||||
199
tui/services/plan_service.py
Normal file
199
tui/services/plan_service.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Plan service for TUI application.
|
||||
Manages training plans and plan generation without HTTP dependencies.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.plan import Plan
|
||||
from backend.app.models.rule import Rule
|
||||
from backend.app.services.ai_service import AIService
|
||||
from backend.app.services.plan_evolution import PlanEvolutionService
|
||||
|
||||
|
||||
class PlanService:
|
||||
"""Service for training plan operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_plans(self) -> List[Dict]:
|
||||
"""Get all training plans."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Plan).order_by(desc(Plan.created_at))
|
||||
)
|
||||
plans = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"version": p.version,
|
||||
"parent_plan_id": p.parent_plan_id,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"plan_data": p.jsonb_plan
|
||||
} for p in plans
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching plans: {str(e)}")
|
||||
|
||||
async def get_plan(self, plan_id: int) -> Optional[Dict]:
|
||||
"""Get a specific plan by ID."""
|
||||
try:
|
||||
plan = await self.db.get(Plan, plan_id)
|
||||
if not plan:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": plan.id,
|
||||
"version": plan.version,
|
||||
"parent_plan_id": plan.parent_plan_id,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
||||
"plan_data": plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching plan {plan_id}: {str(e)}")
|
||||
|
||||
async def create_plan(self, plan_data: Dict, version: int = 1, parent_plan_id: Optional[int] = None) -> Dict:
|
||||
"""Create a new training plan."""
|
||||
try:
|
||||
db_plan = Plan(
|
||||
jsonb_plan=plan_data,
|
||||
version=version,
|
||||
parent_plan_id=parent_plan_id
|
||||
)
|
||||
self.db.add(db_plan)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_plan)
|
||||
|
||||
return {
|
||||
"id": db_plan.id,
|
||||
"version": db_plan.version,
|
||||
"parent_plan_id": db_plan.parent_plan_id,
|
||||
"created_at": db_plan.created_at.isoformat() if db_plan.created_at else None,
|
||||
"plan_data": db_plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating plan: {str(e)}")
|
||||
|
||||
async def update_plan(self, plan_id: int, plan_data: Dict, version: Optional[int] = None) -> Dict:
|
||||
"""Update an existing plan."""
|
||||
try:
|
||||
db_plan = await self.db.get(Plan, plan_id)
|
||||
if not db_plan:
|
||||
raise Exception("Plan not found")
|
||||
|
||||
db_plan.jsonb_plan = plan_data
|
||||
if version is not None:
|
||||
db_plan.version = version
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_plan)
|
||||
|
||||
return {
|
||||
"id": db_plan.id,
|
||||
"version": db_plan.version,
|
||||
"parent_plan_id": db_plan.parent_plan_id,
|
||||
"created_at": db_plan.created_at.isoformat() if db_plan.created_at else None,
|
||||
"plan_data": db_plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error updating plan: {str(e)}")
|
||||
|
||||
async def delete_plan(self, plan_id: int) -> Dict:
|
||||
"""Delete a plan."""
|
||||
try:
|
||||
plan = await self.db.get(Plan, plan_id)
|
||||
if not plan:
|
||||
raise Exception("Plan not found")
|
||||
|
||||
await self.db.delete(plan)
|
||||
await self.db.commit()
|
||||
|
||||
return {"message": "Plan deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error deleting plan: {str(e)}")
|
||||
|
||||
async def generate_plan(self, rule_ids: List[int], goals: Dict, preferred_routes: Optional[List[str]] = None) -> Dict:
|
||||
"""Generate a new training plan using AI."""
|
||||
try:
|
||||
# Get all rules from the provided rule IDs
|
||||
rules = []
|
||||
for rule_id in rule_ids:
|
||||
rule = await self.db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
raise Exception(f"Rule with ID {rule_id} not found")
|
||||
rules.append(rule.rule_text)
|
||||
|
||||
# Generate plan using AI service
|
||||
ai_service = AIService(self.db)
|
||||
generated_plan = await ai_service.generate_training_plan(
|
||||
rule_set=rules,
|
||||
goals=goals,
|
||||
preferred_routes=preferred_routes or []
|
||||
)
|
||||
|
||||
# Create and store the plan
|
||||
plan_dict = await self.create_plan(generated_plan, version=1)
|
||||
|
||||
return {
|
||||
"plan": plan_dict,
|
||||
"generation_metadata": {
|
||||
"status": "success",
|
||||
"rule_ids": rule_ids,
|
||||
"goals": goals
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error generating plan: {str(e)}")
|
||||
|
||||
async def get_plan_evolution_history(self, plan_id: int) -> List[Dict]:
|
||||
"""Get full evolution history for a plan."""
|
||||
try:
|
||||
evolution_service = PlanEvolutionService(self.db)
|
||||
plans = await evolution_service.get_plan_evolution_history(plan_id)
|
||||
|
||||
if not plans:
|
||||
raise Exception("Plan not found")
|
||||
|
||||
return [
|
||||
{
|
||||
"id": p.id,
|
||||
"version": p.version,
|
||||
"parent_plan_id": p.parent_plan_id,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
"plan_data": p.jsonb_plan
|
||||
} for p in plans
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching plan evolution: {str(e)}")
|
||||
|
||||
async def get_current_plan(self) -> Optional[Dict]:
|
||||
"""Get the most recent active plan."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Plan).order_by(desc(Plan.created_at)).limit(1)
|
||||
)
|
||||
plan = result.scalar_one_or_none()
|
||||
|
||||
if not plan:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": plan.id,
|
||||
"version": plan.version,
|
||||
"parent_plan_id": plan.parent_plan_id,
|
||||
"created_at": plan.created_at.isoformat() if plan.created_at else None,
|
||||
"plan_data": plan.jsonb_plan
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching current plan: {str(e)}")
|
||||
215
tui/services/route_service.py
Normal file
215
tui/services/route_service.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Route service for TUI application.
|
||||
Manages GPX routes and route visualization without HTTP dependencies.
|
||||
"""
|
||||
import gpxpy
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.route import Route
|
||||
from backend.app.models.section import Section
|
||||
|
||||
|
||||
class RouteService:
|
||||
"""Service for route and GPX operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_routes(self) -> List[Dict]:
|
||||
"""Get all routes."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Route).order_by(desc(Route.created_at))
|
||||
)
|
||||
routes = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"total_distance": r.total_distance,
|
||||
"elevation_gain": r.elevation_gain,
|
||||
"gpx_file_path": r.gpx_file_path,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in routes
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching routes: {str(e)}")
|
||||
|
||||
async def get_route(self, route_id: int) -> Optional[Dict]:
|
||||
"""Get a specific route by ID."""
|
||||
try:
|
||||
route = await self.db.get(Route, route_id)
|
||||
if not route:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": route.id,
|
||||
"name": route.name,
|
||||
"description": route.description,
|
||||
"total_distance": route.total_distance,
|
||||
"elevation_gain": route.elevation_gain,
|
||||
"gpx_file_path": route.gpx_file_path,
|
||||
"created_at": route.created_at.isoformat() if route.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching route {route_id}: {str(e)}")
|
||||
|
||||
async def load_gpx_file(self, file_path: str) -> Dict:
|
||||
"""Load and parse GPX file."""
|
||||
try:
|
||||
gpx_path = Path(file_path)
|
||||
if not gpx_path.exists():
|
||||
raise Exception(f"GPX file not found: {file_path}")
|
||||
|
||||
with open(gpx_path, 'r', encoding='utf-8') as gpx_file:
|
||||
gpx = gpxpy.parse(gpx_file)
|
||||
|
||||
# Extract track points
|
||||
track_points = []
|
||||
total_distance = 0
|
||||
min_elevation = float('inf')
|
||||
max_elevation = float('-inf')
|
||||
|
||||
for track in gpx.tracks:
|
||||
for segment in track.segments:
|
||||
for point in segment.points:
|
||||
track_points.append({
|
||||
"lat": point.latitude,
|
||||
"lon": point.longitude,
|
||||
"ele": point.elevation if point.elevation else 0,
|
||||
"time": point.time.isoformat() if point.time else None
|
||||
})
|
||||
|
||||
if point.elevation:
|
||||
min_elevation = min(min_elevation, point.elevation)
|
||||
max_elevation = max(max_elevation, point.elevation)
|
||||
|
||||
# Calculate total distance and elevation gain
|
||||
if track_points:
|
||||
total_distance = self._calculate_total_distance(track_points)
|
||||
elevation_gain = max_elevation - min_elevation if max_elevation != float('-inf') else 0
|
||||
|
||||
return {
|
||||
"name": gpx_path.stem,
|
||||
"total_distance": total_distance,
|
||||
"elevation_gain": elevation_gain,
|
||||
"track_points": track_points,
|
||||
"gpx_file_path": file_path
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error loading GPX file {file_path}: {str(e)}")
|
||||
|
||||
def _calculate_total_distance(self, track_points: List[Dict]) -> float:
|
||||
"""Calculate total distance from track points."""
|
||||
if len(track_points) < 2:
|
||||
return 0
|
||||
|
||||
total_distance = 0
|
||||
for i in range(1, len(track_points)):
|
||||
prev_point = track_points[i-1]
|
||||
curr_point = track_points[i]
|
||||
|
||||
# Simple haversine distance calculation
|
||||
distance = self._haversine_distance(
|
||||
prev_point["lat"], prev_point["lon"],
|
||||
curr_point["lat"], curr_point["lon"]
|
||||
)
|
||||
total_distance += distance
|
||||
|
||||
return total_distance
|
||||
|
||||
def _haversine_distance(self, lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance between two points using haversine formula."""
|
||||
import math
|
||||
|
||||
R = 6371000 # Earth's radius in meters
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
delta_lat = math.radians(lat2 - lat1)
|
||||
delta_lon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(delta_lat / 2) ** 2 +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) *
|
||||
math.sin(delta_lon / 2) ** 2)
|
||||
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
async def create_route(self, name: str, description: str, gpx_file_path: str) -> Dict:
|
||||
"""Create a new route from GPX data."""
|
||||
try:
|
||||
# Load GPX data
|
||||
gpx_data = await self.load_gpx_file(gpx_file_path)
|
||||
|
||||
# Create route record
|
||||
db_route = Route(
|
||||
name=name,
|
||||
description=description,
|
||||
total_distance=gpx_data["total_distance"],
|
||||
elevation_gain=gpx_data["elevation_gain"],
|
||||
gpx_file_path=gpx_file_path
|
||||
)
|
||||
self.db.add(db_route)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_route)
|
||||
|
||||
return {
|
||||
"id": db_route.id,
|
||||
"name": db_route.name,
|
||||
"description": db_route.description,
|
||||
"total_distance": db_route.total_distance,
|
||||
"elevation_gain": db_route.elevation_gain,
|
||||
"gpx_file_path": db_route.gpx_file_path,
|
||||
"created_at": db_route.created_at.isoformat() if db_route.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating route: {str(e)}")
|
||||
|
||||
async def delete_route(self, route_id: int) -> Dict:
|
||||
"""Delete a route."""
|
||||
try:
|
||||
route = await self.db.get(Route, route_id)
|
||||
if not route:
|
||||
raise Exception("Route not found")
|
||||
|
||||
await self.db.delete(route)
|
||||
await self.db.commit()
|
||||
|
||||
return {"message": "Route deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error deleting route: {str(e)}")
|
||||
|
||||
async def get_route_sections(self, route_id: int) -> List[Dict]:
|
||||
"""Get sections for a specific route."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Section).where(Section.route_id == route_id)
|
||||
)
|
||||
sections = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": s.id,
|
||||
"route_id": s.route_id,
|
||||
"gpx_file_path": s.gpx_file_path,
|
||||
"distance_m": s.distance_m,
|
||||
"grade_avg": s.grade_avg,
|
||||
"min_gear": s.min_gear,
|
||||
"est_time_minutes": s.est_time_minutes
|
||||
} for s in sections
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching route sections: {str(e)}")
|
||||
244
tui/services/rule_service.py
Normal file
244
tui/services/rule_service.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Rule service for TUI application.
|
||||
Manages training rules without HTTP dependencies.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.rule import Rule
|
||||
|
||||
|
||||
class RuleService:
|
||||
"""Service for training rule operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_rules(self) -> List[Dict]:
|
||||
"""Get all training rules."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Rule).order_by(desc(Rule.created_at))
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"user_defined": r.user_defined,
|
||||
"rule_text": r.rule_text,
|
||||
"version": r.version,
|
||||
"parent_rule_id": r.parent_rule_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in rules
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching rules: {str(e)}")
|
||||
|
||||
async def get_rule(self, rule_id: int) -> Optional[Dict]:
|
||||
"""Get a specific rule by ID."""
|
||||
try:
|
||||
rule = await self.db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": rule.id,
|
||||
"name": rule.name,
|
||||
"description": rule.description,
|
||||
"user_defined": rule.user_defined,
|
||||
"rule_text": rule.rule_text,
|
||||
"version": rule.version,
|
||||
"parent_rule_id": rule.parent_rule_id,
|
||||
"created_at": rule.created_at.isoformat() if rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching rule {rule_id}: {str(e)}")
|
||||
|
||||
async def create_rule(
|
||||
self,
|
||||
name: str,
|
||||
rule_text: str,
|
||||
description: Optional[str] = None,
|
||||
user_defined: bool = True,
|
||||
version: int = 1,
|
||||
parent_rule_id: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""Create a new training rule."""
|
||||
try:
|
||||
db_rule = Rule(
|
||||
name=name,
|
||||
description=description,
|
||||
user_defined=user_defined,
|
||||
rule_text=rule_text,
|
||||
version=version,
|
||||
parent_rule_id=parent_rule_id
|
||||
)
|
||||
self.db.add(db_rule)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_rule)
|
||||
|
||||
return {
|
||||
"id": db_rule.id,
|
||||
"name": db_rule.name,
|
||||
"description": db_rule.description,
|
||||
"user_defined": db_rule.user_defined,
|
||||
"rule_text": db_rule.rule_text,
|
||||
"version": db_rule.version,
|
||||
"parent_rule_id": db_rule.parent_rule_id,
|
||||
"created_at": db_rule.created_at.isoformat() if db_rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating rule: {str(e)}")
|
||||
|
||||
async def update_rule(
|
||||
self,
|
||||
rule_id: int,
|
||||
name: Optional[str] = None,
|
||||
rule_text: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
version: Optional[int] = None
|
||||
) -> Dict:
|
||||
"""Update an existing rule."""
|
||||
try:
|
||||
db_rule = await self.db.get(Rule, rule_id)
|
||||
if not db_rule:
|
||||
raise Exception("Rule not found")
|
||||
|
||||
if name is not None:
|
||||
db_rule.name = name
|
||||
if rule_text is not None:
|
||||
db_rule.rule_text = rule_text
|
||||
if description is not None:
|
||||
db_rule.description = description
|
||||
if version is not None:
|
||||
db_rule.version = version
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(db_rule)
|
||||
|
||||
return {
|
||||
"id": db_rule.id,
|
||||
"name": db_rule.name,
|
||||
"description": db_rule.description,
|
||||
"user_defined": db_rule.user_defined,
|
||||
"rule_text": db_rule.rule_text,
|
||||
"version": db_rule.version,
|
||||
"parent_rule_id": db_rule.parent_rule_id,
|
||||
"created_at": db_rule.created_at.isoformat() if db_rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error updating rule: {str(e)}")
|
||||
|
||||
async def delete_rule(self, rule_id: int) -> Dict:
|
||||
"""Delete a rule."""
|
||||
try:
|
||||
rule = await self.db.get(Rule, rule_id)
|
||||
if not rule:
|
||||
raise Exception("Rule not found")
|
||||
|
||||
await self.db.delete(rule)
|
||||
await self.db.commit()
|
||||
|
||||
return {"message": "Rule deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error deleting rule: {str(e)}")
|
||||
|
||||
async def get_user_defined_rules(self) -> List[Dict]:
|
||||
"""Get only user-defined rules."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Rule)
|
||||
.where(Rule.user_defined == True)
|
||||
.order_by(desc(Rule.created_at))
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"rule_text": r.rule_text,
|
||||
"version": r.version,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in rules
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching user-defined rules: {str(e)}")
|
||||
|
||||
async def get_system_rules(self) -> List[Dict]:
|
||||
"""Get only system-defined rules."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(Rule)
|
||||
.where(Rule.user_defined == False)
|
||||
.order_by(desc(Rule.created_at))
|
||||
)
|
||||
rules = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"description": r.description,
|
||||
"rule_text": r.rule_text,
|
||||
"version": r.version,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in rules
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching system rules: {str(e)}")
|
||||
|
||||
async def create_rule_version(self, parent_rule_id: int, rule_text: str, description: Optional[str] = None) -> Dict:
|
||||
"""Create a new version of an existing rule."""
|
||||
try:
|
||||
parent_rule = await self.db.get(Rule, parent_rule_id)
|
||||
if not parent_rule:
|
||||
raise Exception("Parent rule not found")
|
||||
|
||||
# Get the highest version number for this rule family
|
||||
result = await self.db.execute(
|
||||
select(Rule.version)
|
||||
.where(Rule.parent_rule_id == parent_rule_id)
|
||||
.order_by(desc(Rule.version))
|
||||
.limit(1)
|
||||
)
|
||||
max_version = result.scalar_one_or_none() or parent_rule.version
|
||||
new_version = max_version + 1
|
||||
|
||||
new_rule = Rule(
|
||||
name=parent_rule.name,
|
||||
description=description or parent_rule.description,
|
||||
user_defined=parent_rule.user_defined,
|
||||
rule_text=rule_text,
|
||||
version=new_version,
|
||||
parent_rule_id=parent_rule_id
|
||||
)
|
||||
self.db.add(new_rule)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(new_rule)
|
||||
|
||||
return {
|
||||
"id": new_rule.id,
|
||||
"name": new_rule.name,
|
||||
"description": new_rule.description,
|
||||
"user_defined": new_rule.user_defined,
|
||||
"rule_text": new_rule.rule_text,
|
||||
"version": new_rule.version,
|
||||
"parent_rule_id": new_rule.parent_rule_id,
|
||||
"created_at": new_rule.created_at.isoformat() if new_rule.created_at else None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error creating rule version: {str(e)}")
|
||||
202
tui/services/workout_service.py
Normal file
202
tui/services/workout_service.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Workout service for TUI application.
|
||||
Manages workout data, analysis, and Garmin sync without HTTP dependencies.
|
||||
"""
|
||||
from typing import Dict, List, Optional
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.workout import Workout
|
||||
from backend.app.models.analysis import Analysis
|
||||
from backend.app.models.garmin_sync_log import GarminSyncLog
|
||||
from backend.app.services.workout_sync import WorkoutSyncService
|
||||
from backend.app.services.ai_service import AIService
|
||||
|
||||
|
||||
class WorkoutService:
|
||||
"""Service for workout operations."""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_workouts(self, limit: Optional[int] = None) -> List[Dict]:
|
||||
"""Get all workouts."""
|
||||
try:
|
||||
query = select(Workout).order_by(desc(Workout.start_time))
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
workouts = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"garmin_activity_id": w.garmin_activity_id,
|
||||
"activity_type": w.activity_type,
|
||||
"start_time": w.start_time.isoformat() if w.start_time else None,
|
||||
"duration_seconds": w.duration_seconds,
|
||||
"distance_m": w.distance_m,
|
||||
"avg_hr": w.avg_hr,
|
||||
"max_hr": w.max_hr,
|
||||
"avg_power": w.avg_power,
|
||||
"max_power": w.max_power,
|
||||
"avg_cadence": w.avg_cadence,
|
||||
"elevation_gain_m": w.elevation_gain_m
|
||||
} for w in workouts
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching workouts: {str(e)}")
|
||||
|
||||
async def get_workout(self, workout_id: int) -> Optional[Dict]:
|
||||
"""Get a specific workout by ID."""
|
||||
try:
|
||||
workout = await self.db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": workout.id,
|
||||
"garmin_activity_id": workout.garmin_activity_id,
|
||||
"activity_type": workout.activity_type,
|
||||
"start_time": workout.start_time.isoformat() if workout.start_time else None,
|
||||
"duration_seconds": workout.duration_seconds,
|
||||
"distance_m": workout.distance_m,
|
||||
"avg_hr": workout.avg_hr,
|
||||
"max_hr": workout.max_hr,
|
||||
"avg_power": workout.avg_power,
|
||||
"max_power": workout.max_power,
|
||||
"avg_cadence": workout.avg_cadence,
|
||||
"elevation_gain_m": workout.elevation_gain_m,
|
||||
"metrics": workout.metrics
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching workout {workout_id}: {str(e)}")
|
||||
|
||||
async def get_workout_metrics(self, workout_id: int) -> List[Dict]:
|
||||
"""Get time-series metrics for a workout."""
|
||||
try:
|
||||
workout = await self.db.get(Workout, workout_id)
|
||||
if not workout or not workout.metrics:
|
||||
return []
|
||||
|
||||
return workout.metrics
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching workout metrics: {str(e)}")
|
||||
|
||||
async def sync_garmin_activities(self, days_back: int = 14) -> Dict:
|
||||
"""Trigger Garmin sync in background."""
|
||||
try:
|
||||
sync_service = WorkoutSyncService(self.db)
|
||||
result = await sync_service.sync_recent_activities(days_back=days_back)
|
||||
|
||||
return {
|
||||
"message": "Garmin sync completed",
|
||||
"activities_synced": result.get("activities_synced", 0),
|
||||
"status": "success"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"message": f"Garmin sync failed: {str(e)}",
|
||||
"activities_synced": 0,
|
||||
"status": "error"
|
||||
}
|
||||
|
||||
async def get_sync_status(self) -> Dict:
|
||||
"""Get the latest sync status."""
|
||||
try:
|
||||
result = await self.db.execute(
|
||||
select(GarminSyncLog).order_by(desc(GarminSyncLog.created_at)).limit(1)
|
||||
)
|
||||
sync_log = result.scalar_one_or_none()
|
||||
|
||||
if not sync_log:
|
||||
return {"status": "never_synced"}
|
||||
|
||||
return {
|
||||
"status": sync_log.status,
|
||||
"last_sync_time": sync_log.last_sync_time.isoformat() if sync_log.last_sync_time else None,
|
||||
"activities_synced": sync_log.activities_synced,
|
||||
"error_message": sync_log.error_message
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching sync status: {str(e)}")
|
||||
|
||||
async def analyze_workout(self, workout_id: int) -> Dict:
|
||||
"""Trigger AI analysis of a specific workout."""
|
||||
try:
|
||||
workout = await self.db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
raise Exception("Workout not found")
|
||||
|
||||
ai_service = AIService(self.db)
|
||||
analysis_result = await ai_service.analyze_workout(workout, None)
|
||||
|
||||
# Store analysis
|
||||
analysis = Analysis(
|
||||
workout_id=workout.id,
|
||||
jsonb_feedback=analysis_result.get("feedback", {}),
|
||||
suggestions=analysis_result.get("suggestions", {})
|
||||
)
|
||||
self.db.add(analysis)
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"message": "Analysis completed",
|
||||
"workout_id": workout_id,
|
||||
"analysis_id": analysis.id,
|
||||
"feedback": analysis_result.get("feedback", {}),
|
||||
"suggestions": analysis_result.get("suggestions", {})
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error analyzing workout: {str(e)}")
|
||||
|
||||
async def get_workout_analyses(self, workout_id: int) -> List[Dict]:
|
||||
"""Get all analyses for a specific workout."""
|
||||
try:
|
||||
workout = await self.db.get(Workout, workout_id)
|
||||
if not workout:
|
||||
raise Exception("Workout not found")
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Analysis).where(Analysis.workout_id == workout_id)
|
||||
)
|
||||
analyses = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": a.id,
|
||||
"analysis_type": a.analysis_type,
|
||||
"feedback": a.jsonb_feedback,
|
||||
"suggestions": a.suggestions,
|
||||
"approved": a.approved,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None
|
||||
} for a in analyses
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error fetching workout analyses: {str(e)}")
|
||||
|
||||
async def approve_analysis(self, analysis_id: int) -> Dict:
|
||||
"""Approve analysis suggestions."""
|
||||
try:
|
||||
analysis = await self.db.get(Analysis, analysis_id)
|
||||
if not analysis:
|
||||
raise Exception("Analysis not found")
|
||||
|
||||
analysis.approved = True
|
||||
await self.db.commit()
|
||||
|
||||
return {
|
||||
"message": "Analysis approved",
|
||||
"analysis_id": analysis_id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"Error approving analysis: {str(e)}")
|
||||
18
tui/views/__init__.py
Normal file
18
tui/views/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
TUI Views package.
|
||||
Contains all the main view components for the application screens.
|
||||
"""
|
||||
|
||||
from .dashboard import DashboardView
|
||||
from .workouts import WorkoutView
|
||||
from .plans import PlanView
|
||||
from .rules import RuleView
|
||||
from .routes import RouteView
|
||||
|
||||
__all__ = [
|
||||
'DashboardView',
|
||||
'WorkoutView',
|
||||
'PlanView',
|
||||
'RuleView',
|
||||
'RouteView'
|
||||
]
|
||||
BIN
tui/views/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/dashboard.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/dashboard.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/plans.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/plans.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/routes.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/routes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/rules.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/rules.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/workouts.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/workouts.cpython-313.pyc
Normal file
Binary file not shown.
219
tui/views/dashboard.py
Normal file
219
tui/views/dashboard.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Dashboard view for AI Cycling Coach TUI.
|
||||
Displays overview of recent workouts, plans, and key metrics.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import Static, DataTable, LoadingIndicator
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.dashboard_service import DashboardService
|
||||
|
||||
|
||||
class DashboardView(Widget):
|
||||
"""Main dashboard view showing workout summary and stats."""
|
||||
|
||||
# Reactive attributes to store data
|
||||
dashboard_data = reactive({})
|
||||
loading = reactive(True)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.dashboard-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
margin: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create dashboard layout."""
|
||||
yield Static("AI Cycling Coach Dashboard", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="dashboard-loader")
|
||||
else:
|
||||
with ScrollableContainer():
|
||||
with Horizontal():
|
||||
# Left column - Recent workouts
|
||||
with Vertical(classes="dashboard-column"):
|
||||
yield Static("Recent Workouts", classes="section-title")
|
||||
workout_table = DataTable(id="recent-workouts")
|
||||
workout_table.add_columns("Date", "Type", "Duration", "Distance", "Avg HR")
|
||||
yield workout_table
|
||||
|
||||
# Right column - Quick stats and current plan
|
||||
with Vertical(classes="dashboard-column"):
|
||||
# Weekly stats
|
||||
with Container(classes="stats-container"):
|
||||
yield Static("This Week", classes="section-title")
|
||||
yield Static("Workouts: 0", id="week-workouts", classes="stat-item")
|
||||
yield Static("Distance: 0 km", id="week-distance", classes="stat-item")
|
||||
yield Static("Time: 0h 0m", id="week-time", classes="stat-item")
|
||||
|
||||
# Active plan
|
||||
with Container(classes="stats-container"):
|
||||
yield Static("Current Plan", classes="section-title")
|
||||
yield Static("No active plan", id="active-plan", classes="stat-item")
|
||||
|
||||
# Sync status
|
||||
with Container(classes="stats-container"):
|
||||
yield Static("Garmin Sync", classes="section-title")
|
||||
yield Static("Never synced", id="sync-status", classes="stat-item")
|
||||
yield Static("", id="last-sync", classes="stat-item")
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load dashboard data when mounted."""
|
||||
try:
|
||||
await self.load_dashboard_data()
|
||||
except Exception as e:
|
||||
self.log(f"Dashboard loading error: {e}", severity="error")
|
||||
# Show error state instead of loading indicator
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_dashboard_data(self) -> None:
|
||||
"""Load and display dashboard data."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
dashboard_service = DashboardService(db)
|
||||
self.dashboard_data = await dashboard_service.get_dashboard_data()
|
||||
weekly_stats = await dashboard_service.get_weekly_stats()
|
||||
|
||||
# Update the reactive data and stop loading
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate the dashboard with data
|
||||
await self.populate_dashboard(weekly_stats)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading dashboard data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_dashboard(self, weekly_stats: dict) -> None:
|
||||
"""Populate dashboard widgets with loaded data."""
|
||||
try:
|
||||
# Update recent workouts table
|
||||
workout_table = self.query_one("#recent-workouts", DataTable)
|
||||
workout_table.clear()
|
||||
|
||||
for workout in self.dashboard_data.get("recent_workouts", []):
|
||||
# Format datetime for display
|
||||
start_time = "N/A"
|
||||
if workout.get("start_time"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
|
||||
start_time = dt.strftime("%m/%d %H:%M")
|
||||
except:
|
||||
start_time = workout["start_time"][:10] # Fallback to date only
|
||||
|
||||
# Format duration
|
||||
duration = "N/A"
|
||||
if workout.get("duration_seconds"):
|
||||
minutes = workout["duration_seconds"] // 60
|
||||
duration = f"{minutes}min"
|
||||
|
||||
# Format distance
|
||||
distance = "N/A"
|
||||
if workout.get("distance_m"):
|
||||
distance = f"{workout['distance_m'] / 1000:.1f}km"
|
||||
|
||||
# Format heart rate
|
||||
avg_hr = workout.get("avg_hr", "N/A")
|
||||
if avg_hr != "N/A":
|
||||
avg_hr = f"{avg_hr}bpm"
|
||||
|
||||
workout_table.add_row(
|
||||
start_time,
|
||||
workout.get("activity_type", "Unknown") or "Unknown",
|
||||
duration,
|
||||
distance,
|
||||
str(avg_hr)
|
||||
)
|
||||
|
||||
# Update weekly stats
|
||||
self.query_one("#week-workouts", Static).update(
|
||||
f"Workouts: {weekly_stats.get('workout_count', 0)}"
|
||||
)
|
||||
self.query_one("#week-distance", Static).update(
|
||||
f"Distance: {weekly_stats.get('total_distance_km', 0)}km"
|
||||
)
|
||||
self.query_one("#week-time", Static).update(
|
||||
f"Time: {weekly_stats.get('total_time_hours', 0):.1f}h"
|
||||
)
|
||||
|
||||
# Update current plan
|
||||
current_plan = self.dashboard_data.get("current_plan")
|
||||
if current_plan:
|
||||
plan_text = f"Plan v{current_plan.get('version', 'N/A')}"
|
||||
if current_plan.get("created_at"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(current_plan["created_at"].replace('Z', '+00:00'))
|
||||
plan_text += f" ({dt.strftime('%m/%d/%Y')})"
|
||||
except:
|
||||
pass
|
||||
self.query_one("#active-plan", Static).update(plan_text)
|
||||
else:
|
||||
self.query_one("#active-plan", Static).update("No active plan")
|
||||
|
||||
# Update sync status
|
||||
last_sync = self.dashboard_data.get("last_sync")
|
||||
if last_sync:
|
||||
status = last_sync.get("status", "unknown")
|
||||
activities_count = last_sync.get("activities_synced", 0)
|
||||
|
||||
self.query_one("#sync-status", Static).update(
|
||||
f"Status: {status.title()}"
|
||||
)
|
||||
|
||||
if last_sync.get("last_sync_time"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_sync["last_sync_time"].replace('Z', '+00:00'))
|
||||
sync_time = dt.strftime("%m/%d %H:%M")
|
||||
self.query_one("#last-sync", Static).update(
|
||||
f"Last: {sync_time} ({activities_count} activities)"
|
||||
)
|
||||
except:
|
||||
self.query_one("#last-sync", Static).update(
|
||||
f"Activities: {activities_count}"
|
||||
)
|
||||
else:
|
||||
self.query_one("#last-sync", Static).update("")
|
||||
else:
|
||||
self.query_one("#sync-status", Static).update("Never synced")
|
||||
self.query_one("#last-sync", Static).update("")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating dashboard: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
# Trigger recomposition when loading state changes
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
348
tui/views/plans.py
Normal file
348
tui/views/plans.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Plan view for AI Cycling Coach TUI.
|
||||
Displays training plans, plan generation, and plan management.
|
||||
"""
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import (
|
||||
Static, DataTable, Button, Input, TextArea, Select, LoadingIndicator,
|
||||
Collapsible, TabbedContent, TabPane, Label
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.plan_service import PlanService
|
||||
from tui.services.rule_service import RuleService
|
||||
|
||||
|
||||
class PlanGenerationForm(Widget):
|
||||
"""Form for generating new training plans."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create plan generation form."""
|
||||
yield Label("Plan Generation")
|
||||
|
||||
with Vertical():
|
||||
yield Label("Training Goals:")
|
||||
yield Input(placeholder="e.g., Build endurance, Improve power", id="goals-input")
|
||||
|
||||
yield Label("Weekly Training Days:")
|
||||
yield Select(
|
||||
[(str(i), str(i)) for i in range(1, 8)],
|
||||
value="4",
|
||||
id="training-days-select"
|
||||
)
|
||||
|
||||
yield Label("Select Training Rules:")
|
||||
yield Select(
|
||||
[("loading", "Loading rules...")],
|
||||
id="rules-select",
|
||||
allow_multiple=True
|
||||
)
|
||||
|
||||
with Horizontal():
|
||||
yield Button("Generate Plan", id="generate-plan-btn", variant="primary")
|
||||
yield Button("Clear Form", id="clear-form-btn")
|
||||
|
||||
|
||||
class PlanDetailsModal(Widget):
|
||||
"""Modal for viewing plan details."""
|
||||
|
||||
def __init__(self, plan_data: Dict):
|
||||
super().__init__()
|
||||
self.plan_data = plan_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create plan details modal."""
|
||||
yield Label(f"Plan Details - Version {self.plan_data.get('version', 'N/A')}")
|
||||
|
||||
with ScrollableContainer():
|
||||
yield Label(f"Created: {self.plan_data.get('created_at', 'Unknown')[:10]}")
|
||||
|
||||
# Display plan content
|
||||
plan_content = str(self.plan_data.get('plan_data', {}))
|
||||
yield TextArea(plan_content, read_only=True, id="plan-content")
|
||||
|
||||
with Horizontal():
|
||||
yield Button("Close", id="close-modal-btn")
|
||||
yield Button("Edit Plan", id="edit-plan-btn", variant="primary")
|
||||
|
||||
|
||||
class PlanView(Widget):
|
||||
"""Training plan management view."""
|
||||
|
||||
# Reactive attributes
|
||||
plans = reactive([])
|
||||
rules = reactive([])
|
||||
loading = reactive(True)
|
||||
current_view = reactive("list") # list, generate, details
|
||||
selected_plan = reactive(None)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.plan-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
class PlanSelected(Message):
|
||||
"""Message sent when a plan is selected."""
|
||||
def __init__(self, plan_id: int):
|
||||
super().__init__()
|
||||
self.plan_id = plan_id
|
||||
|
||||
class PlanGenerated(Message):
|
||||
"""Message sent when a new plan is generated."""
|
||||
def __init__(self, plan_data: Dict):
|
||||
super().__init__()
|
||||
self.plan_data = plan_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create plan view layout."""
|
||||
yield Static("Training Plans", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="plans-loader")
|
||||
else:
|
||||
with TabbedContent():
|
||||
with TabPane("Plan List", id="plan-list-tab"):
|
||||
yield self.compose_plan_list()
|
||||
|
||||
with TabPane("Generate Plan", id="generate-plan-tab"):
|
||||
yield self.compose_plan_generator()
|
||||
|
||||
def compose_plan_list(self) -> ComposeResult:
|
||||
"""Create plan list view."""
|
||||
with Container():
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Refresh", id="refresh-plans-btn")
|
||||
yield Button("New Plan", id="new-plan-btn", variant="primary")
|
||||
|
||||
# Plans table
|
||||
plans_table = DataTable(id="plans-table")
|
||||
plans_table.add_columns("ID", "Version", "Created", "Actions")
|
||||
yield plans_table
|
||||
|
||||
def compose_plan_generator(self) -> ComposeResult:
|
||||
"""Create plan generation view."""
|
||||
with Container():
|
||||
yield PlanGenerationForm()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load plan data when mounted."""
|
||||
try:
|
||||
await self.load_plans_data()
|
||||
except Exception as e:
|
||||
self.log(f"Plans loading error: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_plans_data(self) -> None:
|
||||
"""Load plans and rules data."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
plan_service = PlanService(db)
|
||||
rule_service = RuleService(db)
|
||||
|
||||
# Load plans and rules
|
||||
self.plans = await plan_service.get_plans()
|
||||
self.rules = await rule_service.get_rules()
|
||||
|
||||
# Update loading state
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate UI elements
|
||||
await self.populate_plans_table()
|
||||
await self.populate_rules_select()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading plans data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_plans_table(self) -> None:
|
||||
"""Populate the plans table."""
|
||||
try:
|
||||
plans_table = self.query_one("#plans-table", DataTable)
|
||||
plans_table.clear()
|
||||
|
||||
for plan in self.plans:
|
||||
created_date = "Unknown"
|
||||
if plan.get("created_at"):
|
||||
created_date = plan["created_at"][:10] # Extract date part
|
||||
|
||||
plans_table.add_row(
|
||||
str(plan["id"]),
|
||||
f"v{plan['version']}",
|
||||
created_date,
|
||||
"View | Edit"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating plans table: {e}", severity="error")
|
||||
|
||||
async def populate_rules_select(self) -> None:
|
||||
"""Populate the rules select dropdown."""
|
||||
try:
|
||||
rules_select = self.query_one("#rules-select", Select)
|
||||
|
||||
# Create options from rules
|
||||
options = [(str(rule["id"]), f"{rule['name']} (v{rule['version']})")
|
||||
for rule in self.rules]
|
||||
|
||||
rules_select.set_options(options)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating rules select: {e}", severity="error")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press events."""
|
||||
try:
|
||||
if event.button.id == "refresh-plans-btn":
|
||||
await self.refresh_plans()
|
||||
elif event.button.id == "new-plan-btn":
|
||||
await self.show_plan_generator()
|
||||
elif event.button.id == "generate-plan-btn":
|
||||
await self.generate_new_plan()
|
||||
elif event.button.id == "clear-form-btn":
|
||||
await self.clear_generation_form()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Button press error: {e}", severity="error")
|
||||
|
||||
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection in plans table."""
|
||||
try:
|
||||
if event.data_table.id == "plans-table":
|
||||
# Get selected plan ID from the first column
|
||||
row_data = event.data_table.get_row(event.row_key)
|
||||
plan_id = int(row_data[0])
|
||||
|
||||
# Find the selected plan
|
||||
selected_plan = next((p for p in self.plans if p["id"] == plan_id), None)
|
||||
if selected_plan:
|
||||
self.selected_plan = selected_plan
|
||||
await self.show_plan_details(selected_plan)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Row selection error: {e}", severity="error")
|
||||
|
||||
async def refresh_plans(self) -> None:
|
||||
"""Refresh the plans list."""
|
||||
self.loading = True
|
||||
self.refresh()
|
||||
await self.load_plans_data()
|
||||
|
||||
async def show_plan_generator(self) -> None:
|
||||
"""Switch to the plan generator tab."""
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "generate-plan-tab"
|
||||
|
||||
async def show_plan_details(self, plan_data: Dict) -> None:
|
||||
"""Show detailed view of a plan."""
|
||||
# For now, just log the plan details
|
||||
# In a full implementation, this would show a modal or detailed view
|
||||
self.log(f"Showing details for plan {plan_data['id']}")
|
||||
|
||||
# Post message that plan was selected
|
||||
self.post_message(self.PlanSelected(plan_data["id"]))
|
||||
|
||||
async def generate_new_plan(self) -> None:
|
||||
"""Generate a new training plan."""
|
||||
try:
|
||||
# Get form values
|
||||
goals_input = self.query_one("#goals-input", Input)
|
||||
training_days_select = self.query_one("#training-days-select", Select)
|
||||
rules_select = self.query_one("#rules-select", Select)
|
||||
|
||||
goals_text = goals_input.value.strip()
|
||||
if not goals_text:
|
||||
self.log("Please enter training goals", severity="warning")
|
||||
return
|
||||
|
||||
# Get selected rule IDs
|
||||
selected_rule_ids = []
|
||||
if hasattr(rules_select, 'selected') and rules_select.selected:
|
||||
if isinstance(rules_select.selected, list):
|
||||
selected_rule_ids = [int(rule_id) for rule_id in rules_select.selected]
|
||||
else:
|
||||
selected_rule_ids = [int(rules_select.selected)]
|
||||
|
||||
if not selected_rule_ids:
|
||||
self.log("Please select at least one training rule", severity="warning")
|
||||
return
|
||||
|
||||
# Generate plan
|
||||
async with AsyncSessionLocal() as db:
|
||||
plan_service = PlanService(db)
|
||||
|
||||
goals_dict = {
|
||||
"description": goals_text,
|
||||
"training_days_per_week": int(training_days_select.value),
|
||||
"focus": "general_fitness"
|
||||
}
|
||||
|
||||
result = await plan_service.generate_plan(
|
||||
rule_ids=selected_rule_ids,
|
||||
goals=goals_dict
|
||||
)
|
||||
|
||||
# Add new plan to local list
|
||||
self.plans.insert(0, result["plan"])
|
||||
|
||||
# Refresh the table
|
||||
await self.populate_plans_table()
|
||||
|
||||
# Post message about new plan
|
||||
self.post_message(self.PlanGenerated(result["plan"]))
|
||||
|
||||
# Switch back to list view
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "plan-list-tab"
|
||||
|
||||
self.log(f"Successfully generated new training plan!", severity="info")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error generating plan: {e}", severity="error")
|
||||
|
||||
async def clear_generation_form(self) -> None:
|
||||
"""Clear the plan generation form."""
|
||||
try:
|
||||
self.query_one("#goals-input", Input).value = ""
|
||||
self.query_one("#training-days-select", Select).value = "4"
|
||||
# Note: Rules select clearing might need different approach depending on Textual version
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error clearing form: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
451
tui/views/routes.py
Normal file
451
tui/views/routes.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Routes view for AI Cycling Coach TUI.
|
||||
Displays GPX routes, route management, and visualization.
|
||||
"""
|
||||
import math
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import (
|
||||
Static, DataTable, Button, Input, TextArea, LoadingIndicator,
|
||||
TabbedContent, TabPane, Label, DirectoryTree
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.route_service import RouteService
|
||||
|
||||
|
||||
class GPXVisualization(Widget):
|
||||
"""ASCII-based GPX route visualization."""
|
||||
|
||||
def __init__(self, route_data: Dict):
|
||||
super().__init__()
|
||||
self.route_data = route_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create GPX visualization."""
|
||||
yield Label(f"Route: {self.route_data.get('name', 'Unknown')}")
|
||||
|
||||
# Route summary
|
||||
distance = self.route_data.get('total_distance', 0)
|
||||
elevation = self.route_data.get('elevation_gain', 0)
|
||||
|
||||
summary = f"Distance: {distance/1000:.2f} km | Elevation Gain: {elevation:.0f} m"
|
||||
yield Static(summary)
|
||||
|
||||
# ASCII route visualization
|
||||
if self.route_data.get('track_points'):
|
||||
yield self.create_route_map()
|
||||
yield self.create_elevation_profile()
|
||||
else:
|
||||
yield Static("No track data available for visualization")
|
||||
|
||||
def create_route_map(self) -> Static:
|
||||
"""Create ASCII map of the route."""
|
||||
track_points = self.route_data.get('track_points', [])
|
||||
if not track_points:
|
||||
return Static("No track points available")
|
||||
|
||||
# Extract coordinates
|
||||
lats = [float(p.get('lat', 0)) for p in track_points if p.get('lat')]
|
||||
lons = [float(p.get('lon', 0)) for p in track_points if p.get('lon')]
|
||||
|
||||
if not lats or not lons:
|
||||
return Static("Invalid coordinate data")
|
||||
|
||||
# Normalize coordinates to terminal space
|
||||
width, height = 60, 20
|
||||
|
||||
min_lat, max_lat = min(lats), max(lats)
|
||||
min_lon, max_lon = min(lons), max(lons)
|
||||
|
||||
# Avoid division by zero
|
||||
lat_range = max_lat - min_lat if max_lat != min_lat else 1
|
||||
lon_range = max_lon - min_lon if max_lon != min_lon else 1
|
||||
|
||||
# Create ASCII grid
|
||||
grid = [[' ' for _ in range(width)] for _ in range(height)]
|
||||
|
||||
# Plot route points
|
||||
for i, (lat, lon) in enumerate(zip(lats, lons)):
|
||||
x = int((lon - min_lon) / lon_range * (width - 1))
|
||||
y = int((lat - min_lat) / lat_range * (height - 1))
|
||||
|
||||
# Use different characters for start, end, and middle
|
||||
if i == 0:
|
||||
char = 'S' # Start
|
||||
elif i == len(lats) - 1:
|
||||
char = 'E' # End
|
||||
else:
|
||||
char = '●' # Route point
|
||||
|
||||
if 0 <= y < height and 0 <= x < width:
|
||||
grid[height - 1 - y][x] = char # Flip Y axis
|
||||
|
||||
# Convert grid to string
|
||||
map_lines = [''.join(row) for row in grid]
|
||||
map_text = "Route Map:\n" + '\n'.join(map_lines)
|
||||
map_text += f"\nS = Start, E = End, ● = Route"
|
||||
|
||||
return Static(map_text)
|
||||
|
||||
def create_elevation_profile(self) -> Static:
|
||||
"""Create ASCII elevation profile."""
|
||||
track_points = self.route_data.get('track_points', [])
|
||||
elevations = [float(p.get('ele', 0)) for p in track_points if p.get('ele')]
|
||||
|
||||
if not elevations:
|
||||
return Static("No elevation data available")
|
||||
|
||||
# Normalize elevation data
|
||||
width = 60
|
||||
height = 10
|
||||
|
||||
min_ele, max_ele = min(elevations), max(elevations)
|
||||
ele_range = max_ele - min_ele if max_ele != min_ele else 1
|
||||
|
||||
# Sample elevations to fit width
|
||||
if len(elevations) > width:
|
||||
step = len(elevations) // width
|
||||
elevations = elevations[::step][:width]
|
||||
|
||||
# Create elevation profile
|
||||
profile_lines = []
|
||||
for h in range(height):
|
||||
line = []
|
||||
threshold = min_ele + (height - h) / height * ele_range
|
||||
|
||||
for ele in elevations:
|
||||
if ele >= threshold:
|
||||
line.append('█')
|
||||
else:
|
||||
line.append(' ')
|
||||
profile_lines.append(''.join(line))
|
||||
|
||||
# Add elevation markers
|
||||
profile_text = f"Elevation Profile ({min_ele:.0f}m - {max_ele:.0f}m):\n"
|
||||
profile_text += '\n'.join(profile_lines)
|
||||
|
||||
return Static(profile_text)
|
||||
|
||||
|
||||
class RouteFileUpload(Widget):
|
||||
"""File upload widget for GPX files."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create file upload interface."""
|
||||
yield Label("Upload GPX Files")
|
||||
yield Button("Browse Files", id="browse-gpx-btn", variant="primary")
|
||||
yield Static("", id="upload-status")
|
||||
|
||||
# Directory tree for local file browsing
|
||||
yield Label("Or browse local files:")
|
||||
yield DirectoryTree("./data/gpx", id="gpx-directory")
|
||||
|
||||
|
||||
class RouteView(Widget):
|
||||
"""Route management and visualization view."""
|
||||
|
||||
# Reactive attributes
|
||||
routes = reactive([])
|
||||
selected_route = reactive(None)
|
||||
loading = reactive(True)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.route-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
min-height: 30;
|
||||
}
|
||||
"""
|
||||
|
||||
class RouteSelected(Message):
|
||||
"""Message sent when a route is selected."""
|
||||
def __init__(self, route_id: int):
|
||||
super().__init__()
|
||||
self.route_id = route_id
|
||||
|
||||
class RouteUploaded(Message):
|
||||
"""Message sent when a route is uploaded."""
|
||||
def __init__(self, route_data: Dict):
|
||||
super().__init__()
|
||||
self.route_data = route_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create route view layout."""
|
||||
yield Static("Routes & GPX Files", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="routes-loader")
|
||||
else:
|
||||
with TabbedContent():
|
||||
with TabPane("Route List", id="route-list-tab"):
|
||||
yield self.compose_route_list()
|
||||
|
||||
with TabPane("Upload GPX", id="upload-gpx-tab"):
|
||||
yield self.compose_file_upload()
|
||||
|
||||
if self.selected_route:
|
||||
with TabPane("Route Visualization", id="route-viz-tab"):
|
||||
yield self.compose_route_visualization()
|
||||
|
||||
def compose_route_list(self) -> ComposeResult:
|
||||
"""Create route list view."""
|
||||
with Container():
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Refresh", id="refresh-routes-btn")
|
||||
yield Button("Import GPX", id="import-gpx-btn", variant="primary")
|
||||
yield Button("Analyze Routes", id="analyze-routes-btn")
|
||||
|
||||
# Routes table
|
||||
routes_table = DataTable(id="routes-table")
|
||||
routes_table.add_columns("Name", "Distance", "Elevation", "Sections", "Actions")
|
||||
yield routes_table
|
||||
|
||||
# Route sections (if any)
|
||||
yield Static("Route Sections", classes="section-title")
|
||||
sections_table = DataTable(id="sections-table")
|
||||
sections_table.add_columns("Section", "Distance", "Grade", "Difficulty")
|
||||
yield sections_table
|
||||
|
||||
def compose_file_upload(self) -> ComposeResult:
|
||||
"""Create file upload view."""
|
||||
with Container(classes="upload-container"):
|
||||
yield RouteFileUpload()
|
||||
|
||||
def compose_route_visualization(self) -> ComposeResult:
|
||||
"""Create route visualization view."""
|
||||
if not self.selected_route:
|
||||
yield Static("No route selected")
|
||||
return
|
||||
|
||||
with Container(classes="visualization-container"):
|
||||
yield GPXVisualization(self.selected_route)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load route data when mounted."""
|
||||
try:
|
||||
await self.load_routes_data()
|
||||
except Exception as e:
|
||||
self.log(f"Routes loading error: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_routes_data(self) -> None:
|
||||
"""Load routes data."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
route_service = RouteService(db)
|
||||
self.routes = await route_service.get_routes()
|
||||
|
||||
# Update loading state
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate UI elements
|
||||
await self.populate_routes_table()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading routes data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_routes_table(self) -> None:
|
||||
"""Populate the routes table."""
|
||||
try:
|
||||
routes_table = self.query_one("#routes-table", DataTable)
|
||||
routes_table.clear()
|
||||
|
||||
for route in self.routes:
|
||||
distance_km = route.get("total_distance", 0) / 1000
|
||||
elevation_m = route.get("elevation_gain", 0)
|
||||
|
||||
routes_table.add_row(
|
||||
route.get("name", "Unknown"),
|
||||
f"{distance_km:.1f} km",
|
||||
f"{elevation_m:.0f} m",
|
||||
"0", # TODO: Count sections
|
||||
"View | Edit"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating routes table: {e}", severity="error")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press events."""
|
||||
try:
|
||||
if event.button.id == "refresh-routes-btn":
|
||||
await self.refresh_routes()
|
||||
elif event.button.id == "import-gpx-btn":
|
||||
await self.show_file_upload()
|
||||
elif event.button.id == "browse-gpx-btn":
|
||||
await self.browse_gpx_files()
|
||||
elif event.button.id == "analyze-routes-btn":
|
||||
await self.analyze_routes()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Button press error: {e}", severity="error")
|
||||
|
||||
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection in routes table."""
|
||||
try:
|
||||
if event.data_table.id == "routes-table":
|
||||
# Get route index from row selection
|
||||
row_index = event.row_key.value if hasattr(event.row_key, 'value') else event.cursor_row
|
||||
|
||||
if 0 <= row_index < len(self.routes):
|
||||
selected_route = self.routes[row_index]
|
||||
await self.show_route_visualization(selected_route)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Row selection error: {e}", severity="error")
|
||||
|
||||
async def on_directory_tree_file_selected(self, event) -> None:
|
||||
"""Handle file selection from directory tree."""
|
||||
try:
|
||||
file_path = str(event.path)
|
||||
if file_path.lower().endswith('.gpx'):
|
||||
await self.load_gpx_file(file_path)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"File selection error: {e}", severity="error")
|
||||
|
||||
async def show_route_visualization(self, route_data: Dict) -> None:
|
||||
"""Show visualization for a route."""
|
||||
try:
|
||||
# Load additional route data if needed
|
||||
async with AsyncSessionLocal() as db:
|
||||
route_service = RouteService(db)
|
||||
full_route_data = await route_service.load_gpx_file(
|
||||
route_data.get("gpx_file_path", "")
|
||||
)
|
||||
|
||||
self.selected_route = full_route_data
|
||||
self.refresh()
|
||||
|
||||
# Switch to visualization tab
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "route-viz-tab"
|
||||
|
||||
# Post message that route was selected
|
||||
self.post_message(self.RouteSelected(route_data["id"]))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error showing route visualization: {e}", severity="error")
|
||||
|
||||
async def refresh_routes(self) -> None:
|
||||
"""Refresh the routes list."""
|
||||
self.loading = True
|
||||
self.refresh()
|
||||
await self.load_routes_data()
|
||||
|
||||
async def show_file_upload(self) -> None:
|
||||
"""Switch to the file upload tab."""
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "upload-gpx-tab"
|
||||
|
||||
async def browse_gpx_files(self) -> None:
|
||||
"""Browse for GPX files."""
|
||||
try:
|
||||
# Update status
|
||||
status = self.query_one("#upload-status", Static)
|
||||
status.update("Click on a .gpx file in the directory tree below")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error browsing files: {e}", severity="error")
|
||||
|
||||
async def load_gpx_file(self, file_path: str) -> None:
|
||||
"""Load a GPX file and create route visualization."""
|
||||
try:
|
||||
self.log(f"Loading GPX file: {file_path}", severity="info")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
route_service = RouteService(db)
|
||||
route_data = await route_service.load_gpx_file(file_path)
|
||||
|
||||
# Create visualization
|
||||
self.selected_route = route_data
|
||||
self.refresh()
|
||||
|
||||
# Switch to visualization tab
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "route-viz-tab"
|
||||
|
||||
# Update upload status
|
||||
status = self.query_one("#upload-status", Static)
|
||||
status.update(f"Loaded: {route_data.get('name', 'Unknown Route')}")
|
||||
|
||||
# Post message about route upload
|
||||
self.post_message(self.RouteUploaded(route_data))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading GPX file: {e}", severity="error")
|
||||
|
||||
# Update status with error
|
||||
try:
|
||||
status = self.query_one("#upload-status", Static)
|
||||
status.update(f"Error: {str(e)}")
|
||||
except:
|
||||
pass
|
||||
|
||||
async def analyze_routes(self) -> None:
|
||||
"""Analyze all routes for insights."""
|
||||
try:
|
||||
if not self.routes:
|
||||
self.log("No routes to analyze", severity="warning")
|
||||
return
|
||||
|
||||
# Calculate route statistics
|
||||
total_distance = sum(r.get("total_distance", 0) for r in self.routes) / 1000
|
||||
total_elevation = sum(r.get("elevation_gain", 0) for r in self.routes)
|
||||
avg_distance = total_distance / len(self.routes)
|
||||
|
||||
analysis = f"""Route Analysis:
|
||||
• Total Routes: {len(self.routes)}
|
||||
• Total Distance: {total_distance:.1f} km
|
||||
• Total Elevation: {total_elevation:.0f} m
|
||||
• Average Distance: {avg_distance:.1f} km
|
||||
• Average Elevation: {total_elevation / len(self.routes):.0f} m"""
|
||||
|
||||
self.log("Route Analysis Complete", severity="info")
|
||||
self.log(analysis, severity="info")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error analyzing routes: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
18
tui/views/rules.py
Normal file
18
tui/views/rules.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Rules view for AI Cycling Coach TUI.
|
||||
Displays training rules, rule creation and editing.
|
||||
"""
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.widgets import Static, Placeholder
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class RuleView(Widget):
|
||||
"""Training rules management view."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create rules view layout."""
|
||||
with Container():
|
||||
yield Static("Training Rules", classes="view-title")
|
||||
yield Placeholder("Rule creation and editing will be displayed here")
|
||||
537
tui/views/workouts.py
Normal file
537
tui/views/workouts.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
Workout view for AI Cycling Coach TUI.
|
||||
Displays workout list, analysis, and import functionality.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import (
|
||||
Static, DataTable, Button, Input, TextArea, LoadingIndicator,
|
||||
TabbedContent, TabPane, Label, ProgressBar, Collapsible
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.workout_service import WorkoutService
|
||||
|
||||
|
||||
class WorkoutMetricsChart(Widget):
|
||||
"""ASCII-based workout metrics visualization."""
|
||||
|
||||
def __init__(self, metrics_data: List[Dict]):
|
||||
super().__init__()
|
||||
self.metrics_data = metrics_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create metrics chart view."""
|
||||
if not self.metrics_data:
|
||||
yield Static("No metrics data available")
|
||||
return
|
||||
|
||||
# Create simple ASCII charts for key metrics
|
||||
yield Label("Workout Metrics Overview")
|
||||
|
||||
# Heart Rate Chart (simple bar representation)
|
||||
hr_values = [m.get("heart_rate", 0) for m in self.metrics_data if m.get("heart_rate")]
|
||||
if hr_values:
|
||||
yield self.create_ascii_chart("Heart Rate (BPM)", hr_values, max_width=50)
|
||||
|
||||
# Power Chart
|
||||
power_values = [m.get("power", 0) for m in self.metrics_data if m.get("power")]
|
||||
if power_values:
|
||||
yield self.create_ascii_chart("Power (W)", power_values, max_width=50)
|
||||
|
||||
# Speed Chart
|
||||
speed_values = [m.get("speed", 0) for m in self.metrics_data if m.get("speed")]
|
||||
if speed_values:
|
||||
yield self.create_ascii_chart("Speed (km/h)", speed_values, max_width=50)
|
||||
|
||||
def create_ascii_chart(self, title: str, values: List[float], max_width: int = 50) -> Static:
|
||||
"""Create a simple ASCII bar chart."""
|
||||
if not values:
|
||||
return Static(f"{title}: No data")
|
||||
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
avg_val = sum(values) / len(values)
|
||||
|
||||
# Create a simple representation
|
||||
chart_text = f"{title}:\n"
|
||||
chart_text += f"Min: {min_val:.1f} | Max: {max_val:.1f} | Avg: {avg_val:.1f}\n"
|
||||
|
||||
# Simple histogram representation
|
||||
if max_val > min_val:
|
||||
normalized = [(v - min_val) / (max_val - min_val) for v in values[:20]] # Take first 20 points
|
||||
chart_text += "["
|
||||
for norm_val in normalized:
|
||||
bar_length = int(norm_val * 10)
|
||||
chart_text += "█" * bar_length + "░" * (10 - bar_length) + " "
|
||||
chart_text += "]\n"
|
||||
|
||||
return Static(chart_text)
|
||||
|
||||
|
||||
class WorkoutAnalysisPanel(Widget):
|
||||
"""Panel showing AI analysis of a workout."""
|
||||
|
||||
def __init__(self, workout_data: Dict, analyses: List[Dict]):
|
||||
super().__init__()
|
||||
self.workout_data = workout_data
|
||||
self.analyses = analyses
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create analysis panel layout."""
|
||||
yield Label("AI Analysis")
|
||||
|
||||
if not self.analyses:
|
||||
yield Static("No analysis available for this workout.")
|
||||
yield Button("Analyze Workout", id="analyze-workout-btn", variant="primary")
|
||||
return
|
||||
|
||||
# Show existing analyses
|
||||
with ScrollableContainer():
|
||||
for i, analysis in enumerate(self.analyses):
|
||||
with Collapsible(title=f"Analysis {i+1} - {analysis.get('analysis_type', 'Unknown')}"):
|
||||
|
||||
# Feedback section
|
||||
feedback = analysis.get('feedback', {})
|
||||
if feedback:
|
||||
yield Label("Feedback:")
|
||||
feedback_text = self.format_feedback(feedback)
|
||||
yield TextArea(feedback_text, read_only=True)
|
||||
|
||||
# Suggestions section
|
||||
suggestions = analysis.get('suggestions', {})
|
||||
if suggestions:
|
||||
yield Label("Suggestions:")
|
||||
suggestions_text = self.format_suggestions(suggestions)
|
||||
yield TextArea(suggestions_text, read_only=True)
|
||||
|
||||
# Analysis metadata
|
||||
created_at = analysis.get('created_at', '')
|
||||
approved = analysis.get('approved', False)
|
||||
|
||||
with Horizontal():
|
||||
yield Static(f"Created: {created_at[:19] if created_at else 'Unknown'}")
|
||||
if not approved:
|
||||
yield Button("Approve", id=f"approve-analysis-{analysis['id']}", variant="success")
|
||||
|
||||
# Button to run new analysis
|
||||
yield Button("Run New Analysis", id="analyze-workout-btn", variant="primary")
|
||||
|
||||
def format_feedback(self, feedback: Dict) -> str:
|
||||
"""Format feedback dictionary as readable text."""
|
||||
if isinstance(feedback, str):
|
||||
return feedback
|
||||
|
||||
formatted = []
|
||||
for key, value in feedback.items():
|
||||
formatted.append(f"{key.replace('_', ' ').title()}: {value}")
|
||||
return "\n".join(formatted)
|
||||
|
||||
def format_suggestions(self, suggestions: Dict) -> str:
|
||||
"""Format suggestions dictionary as readable text."""
|
||||
if isinstance(suggestions, str):
|
||||
return suggestions
|
||||
|
||||
formatted = []
|
||||
for key, value in suggestions.items():
|
||||
formatted.append(f"• {key.replace('_', ' ').title()}: {value}")
|
||||
return "\n".join(formatted)
|
||||
|
||||
|
||||
class WorkoutView(Widget):
|
||||
"""Workout management view."""
|
||||
|
||||
# Reactive attributes
|
||||
workouts = reactive([])
|
||||
selected_workout = reactive(None)
|
||||
workout_analyses = reactive([])
|
||||
loading = reactive(True)
|
||||
sync_status = reactive({})
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.workout-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.sync-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.metrics-container {
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
class WorkoutSelected(Message):
|
||||
"""Message sent when a workout is selected."""
|
||||
def __init__(self, workout_id: int):
|
||||
super().__init__()
|
||||
self.workout_id = workout_id
|
||||
|
||||
class AnalysisRequested(Message):
|
||||
"""Message sent when analysis is requested."""
|
||||
def __init__(self, workout_id: int):
|
||||
super().__init__()
|
||||
self.workout_id = workout_id
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create workout view layout."""
|
||||
yield Static("Workout Management", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="workouts-loader")
|
||||
else:
|
||||
with TabbedContent():
|
||||
with TabPane("Workout List", id="workout-list-tab"):
|
||||
yield self.compose_workout_list()
|
||||
|
||||
if self.selected_workout:
|
||||
with TabPane("Workout Details", id="workout-details-tab"):
|
||||
yield self.compose_workout_details()
|
||||
|
||||
def compose_workout_list(self) -> ComposeResult:
|
||||
"""Create workout list view."""
|
||||
with Container():
|
||||
# Sync section
|
||||
with Container(classes="sync-container"):
|
||||
yield Static("Garmin Sync", classes="section-title")
|
||||
yield Static("Status: Unknown", id="sync-status-text")
|
||||
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Sync Now", id="sync-garmin-btn", variant="primary")
|
||||
yield Button("Check Status", id="check-sync-btn")
|
||||
|
||||
# Workout filters and actions
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Refresh", id="refresh-workouts-btn")
|
||||
yield Input(placeholder="Filter workouts...", id="workout-filter")
|
||||
yield Button("Filter", id="filter-workouts-btn")
|
||||
|
||||
# Workouts table
|
||||
workouts_table = DataTable(id="workouts-table")
|
||||
workouts_table.add_columns("Date", "Type", "Duration", "Distance", "Avg HR", "Avg Power", "Actions")
|
||||
yield workouts_table
|
||||
|
||||
def compose_workout_details(self) -> ComposeResult:
|
||||
"""Create workout details view."""
|
||||
workout = self.selected_workout
|
||||
if not workout:
|
||||
yield Static("No workout selected")
|
||||
return
|
||||
|
||||
with ScrollableContainer():
|
||||
# Workout summary
|
||||
yield Static("Workout Summary", classes="section-title")
|
||||
yield self.create_workout_summary(workout)
|
||||
|
||||
# Metrics visualization
|
||||
if workout.get('metrics'):
|
||||
with Container(classes="metrics-container"):
|
||||
yield WorkoutMetricsChart(workout['metrics'])
|
||||
|
||||
# Analysis section
|
||||
yield Static("AI Analysis", classes="section-title")
|
||||
yield WorkoutAnalysisPanel(workout, self.workout_analyses)
|
||||
|
||||
def create_workout_summary(self, workout: Dict) -> Container:
|
||||
"""Create workout summary display."""
|
||||
container = Container()
|
||||
|
||||
# Basic workout info
|
||||
start_time = "Unknown"
|
||||
if workout.get("start_time"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
|
||||
start_time = dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
except:
|
||||
start_time = workout["start_time"]
|
||||
|
||||
duration = "Unknown"
|
||||
if workout.get("duration_seconds"):
|
||||
minutes = workout["duration_seconds"] // 60
|
||||
seconds = workout["duration_seconds"] % 60
|
||||
duration = f"{minutes}:{seconds:02d}"
|
||||
|
||||
distance = "Unknown"
|
||||
if workout.get("distance_m"):
|
||||
distance = f"{workout['distance_m'] / 1000:.2f} km"
|
||||
|
||||
summary_text = f"""
|
||||
Activity Type: {workout.get('activity_type', 'Unknown')}
|
||||
Start Time: {start_time}
|
||||
Duration: {duration}
|
||||
Distance: {distance}
|
||||
Average Heart Rate: {workout.get('avg_hr', 'N/A')} BPM
|
||||
Max Heart Rate: {workout.get('max_hr', 'N/A')} BPM
|
||||
Average Power: {workout.get('avg_power', 'N/A')} W
|
||||
Max Power: {workout.get('max_power', 'N/A')} W
|
||||
Average Cadence: {workout.get('avg_cadence', 'N/A')} RPM
|
||||
Elevation Gain: {workout.get('elevation_gain_m', 'N/A')} m
|
||||
""".strip()
|
||||
|
||||
return Static(summary_text)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load workout data when mounted."""
|
||||
try:
|
||||
await self.load_workouts_data()
|
||||
except Exception as e:
|
||||
self.log(f"Workouts loading error: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_workouts_data(self) -> None:
|
||||
"""Load workouts and sync status."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
|
||||
# Load workouts and sync status
|
||||
self.workouts = await workout_service.get_workouts(limit=50)
|
||||
self.sync_status = await workout_service.get_sync_status()
|
||||
|
||||
# Update loading state
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate UI elements
|
||||
await self.populate_workouts_table()
|
||||
await self.update_sync_status()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading workouts data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_workouts_table(self) -> None:
|
||||
"""Populate the workouts table."""
|
||||
try:
|
||||
workouts_table = self.query_one("#workouts-table", DataTable)
|
||||
workouts_table.clear()
|
||||
|
||||
for workout in self.workouts:
|
||||
# Format date
|
||||
date_str = "Unknown"
|
||||
if workout.get("start_time"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
|
||||
date_str = dt.strftime("%m/%d %H:%M")
|
||||
except:
|
||||
date_str = workout["start_time"][:10]
|
||||
|
||||
# Format duration
|
||||
duration_str = "N/A"
|
||||
if workout.get("duration_seconds"):
|
||||
minutes = workout["duration_seconds"] // 60
|
||||
duration_str = f"{minutes}min"
|
||||
|
||||
# Format distance
|
||||
distance_str = "N/A"
|
||||
if workout.get("distance_m"):
|
||||
distance_str = f"{workout['distance_m'] / 1000:.1f}km"
|
||||
|
||||
workouts_table.add_row(
|
||||
date_str,
|
||||
workout.get("activity_type", "Unknown") or "Unknown",
|
||||
duration_str,
|
||||
distance_str,
|
||||
f"{workout.get('avg_hr', 'N/A')} BPM" if workout.get('avg_hr') else "N/A",
|
||||
f"{workout.get('avg_power', 'N/A')} W" if workout.get('avg_power') else "N/A",
|
||||
"View | Analyze"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating workouts table: {e}", severity="error")
|
||||
|
||||
async def update_sync_status(self) -> None:
|
||||
"""Update sync status display."""
|
||||
try:
|
||||
status_text = self.query_one("#sync-status-text", Static)
|
||||
|
||||
status = self.sync_status.get("status", "unknown")
|
||||
last_sync = self.sync_status.get("last_sync_time", "Never")
|
||||
activities_count = self.sync_status.get("activities_synced", 0)
|
||||
|
||||
if last_sync and last_sync != "Never":
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_sync.replace('Z', '+00:00'))
|
||||
last_sync = dt.strftime("%Y-%m-%d %H:%M")
|
||||
except:
|
||||
pass
|
||||
|
||||
status_display = f"Status: {status.title()} | Last Sync: {last_sync} | Activities: {activities_count}"
|
||||
|
||||
if self.sync_status.get("error_message"):
|
||||
status_display += f" | Error: {self.sync_status['error_message']}"
|
||||
|
||||
status_text.update(status_display)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error updating sync status: {e}", severity="error")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press events."""
|
||||
try:
|
||||
if event.button.id == "refresh-workouts-btn":
|
||||
await self.refresh_workouts()
|
||||
elif event.button.id == "sync-garmin-btn":
|
||||
await self.sync_garmin_activities()
|
||||
elif event.button.id == "check-sync-btn":
|
||||
await self.check_sync_status()
|
||||
elif event.button.id == "analyze-workout-btn":
|
||||
await self.analyze_selected_workout()
|
||||
elif event.button.id.startswith("approve-analysis-"):
|
||||
analysis_id = int(event.button.id.split("-")[-1])
|
||||
await self.approve_analysis(analysis_id)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Button press error: {e}", severity="error")
|
||||
|
||||
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection in workouts table."""
|
||||
try:
|
||||
if event.data_table.id == "workouts-table":
|
||||
# Get workout index from row selection
|
||||
row_index = event.row_key.value if hasattr(event.row_key, 'value') else event.cursor_row
|
||||
|
||||
if 0 <= row_index < len(self.workouts):
|
||||
selected_workout = self.workouts[row_index]
|
||||
await self.show_workout_details(selected_workout)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Row selection error: {e}", severity="error")
|
||||
|
||||
async def show_workout_details(self, workout: Dict) -> None:
|
||||
"""Show detailed view of a workout."""
|
||||
try:
|
||||
self.selected_workout = workout
|
||||
|
||||
# Load analyses for this workout
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
self.workout_analyses = await workout_service.get_workout_analyses(workout["id"])
|
||||
|
||||
# Refresh to show the details tab
|
||||
self.refresh()
|
||||
|
||||
# Switch to details tab
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "workout-details-tab"
|
||||
|
||||
# Post message that workout was selected
|
||||
self.post_message(self.WorkoutSelected(workout["id"]))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error showing workout details: {e}", severity="error")
|
||||
|
||||
async def refresh_workouts(self) -> None:
|
||||
"""Refresh the workouts list."""
|
||||
self.loading = True
|
||||
self.refresh()
|
||||
await self.load_workouts_data()
|
||||
|
||||
async def sync_garmin_activities(self) -> None:
|
||||
"""Sync with Garmin Connect."""
|
||||
try:
|
||||
self.log("Starting Garmin sync...", severity="info")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
result = await workout_service.sync_garmin_activities(days_back=14)
|
||||
|
||||
if result["status"] == "success":
|
||||
self.log(f"Sync completed: {result['activities_synced']} activities", severity="info")
|
||||
else:
|
||||
self.log(f"Sync failed: {result['message']}", severity="error")
|
||||
|
||||
# Refresh sync status and workouts
|
||||
await self.check_sync_status()
|
||||
await self.refresh_workouts()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error syncing Garmin activities: {e}", severity="error")
|
||||
|
||||
async def check_sync_status(self) -> None:
|
||||
"""Check current sync status."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
self.sync_status = await workout_service.get_sync_status()
|
||||
await self.update_sync_status()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error checking sync status: {e}", severity="error")
|
||||
|
||||
async def analyze_selected_workout(self) -> None:
|
||||
"""Analyze the currently selected workout."""
|
||||
if not self.selected_workout:
|
||||
self.log("No workout selected for analysis", severity="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
self.log("Starting workout analysis...", severity="info")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
result = await workout_service.analyze_workout(self.selected_workout["id"])
|
||||
|
||||
self.log(f"Analysis completed: {result['message']}", severity="info")
|
||||
|
||||
# Reload analyses for this workout
|
||||
self.workout_analyses = await workout_service.get_workout_analyses(self.selected_workout["id"])
|
||||
self.refresh()
|
||||
|
||||
# Post message that analysis was requested
|
||||
self.post_message(self.AnalysisRequested(self.selected_workout["id"]))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error analyzing workout: {e}", severity="error")
|
||||
|
||||
async def approve_analysis(self, analysis_id: int) -> None:
|
||||
"""Approve a workout analysis."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
result = await workout_service.approve_analysis(analysis_id)
|
||||
|
||||
self.log(f"Analysis approved: {result['message']}", severity="info")
|
||||
|
||||
# Reload analyses to update approval status
|
||||
if self.selected_workout:
|
||||
self.workout_analyses = await workout_service.get_workout_analyses(self.selected_workout["id"])
|
||||
self.refresh()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error approving analysis: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
Reference in New Issue
Block a user