change to TUI

This commit is contained in:
2025-09-12 09:08:10 -07:00
parent 7c7dcb5b10
commit e0e70f6508
165 changed files with 3438 additions and 16154 deletions

4
tui/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""
TUI package for AI Cycling Coach.
Contains Textual-based terminal user interface components.
"""

Binary file not shown.

18
tui/services/__init__.py Normal file
View 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'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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)}")

View 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)}")

View 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)}")

View 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)}")

View 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
View 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'
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

219
tui/views/dashboard.py Normal file
View 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
View 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
View 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
View 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
View 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()