""" Workout view for AI Cycling Coach TUI. Displays workout list, analysis, and import functionality. """ import asyncio 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 from tui.widgets.loading import LoadingSpinner from tui.views.base_view import BaseView 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(BaseView): """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 LoadingSpinner("Loading workouts...") 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) def on_mount(self) -> None: """Load workout data when mounted.""" self.loading = True # self.run_worker(self._load_workouts_and_handle_result_sync, thread=True) # def _load_workouts_and_handle_result_sync(self) -> None: # """Synchronous wrapper to load workouts data and handle the result.""" # try: # # Run the async part using asyncio.run # workouts, sync_status = asyncio.run(self._load_workouts_data()) # self.workouts = workouts # self.sync_status = sync_status # self.loading = False # self.call_after_refresh(lambda: self.refresh(layout=True)) # except Exception as e: # self.log(f"Error loading workouts data: {e}", severity="error") # self.loading = False # self.call_after_refresh(lambda: self.refresh()) async def _load_workouts_data(self) -> tuple[list, dict]: """Load workouts and sync status (async worker).""" try: async with AsyncSessionLocal() as db: workout_service = WorkoutService(db) return ( await workout_service.get_workouts(limit=50), await workout_service.get_sync_status() ) except Exception as e: self.log(f"Error loading workouts: {str(e)}", severity="error") raise def on_workouts_loaded(self, result: tuple[list, dict]) -> None: """Handle loaded workout data.""" try: workouts, sync_status = result self.workouts = workouts self.sync_status = sync_status self.loading = False self.refresh(layout=True) 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()