diff --git a/tui/services/__pycache__/dashboard_service.cpython-313.pyc b/tui/services/__pycache__/dashboard_service.cpython-313.pyc index aa67752..755953c 100644 Binary files a/tui/services/__pycache__/dashboard_service.cpython-313.pyc and b/tui/services/__pycache__/dashboard_service.cpython-313.pyc differ diff --git a/tui/services/dashboard_service.py b/tui/services/dashboard_service.py index f4b3caa..8ad7731 100644 --- a/tui/services/dashboard_service.py +++ b/tui/services/dashboard_service.py @@ -19,9 +19,21 @@ class DashboardService: def __init__(self, db: AsyncSession): self.db = db + async def test_connection(self) -> None: + """Test database connection by running a simple query.""" + try: + result = await self.db.execute("SELECT 1") + return result.scalar() == 1 + except Exception as e: + raise Exception(f"Database connection test failed: {str(e)}") + async def get_dashboard_data(self) -> Dict: """Get consolidated dashboard data.""" try: + # Test database connection first + if not await self.test_connection(): + raise Exception("Database connection test failed") + # Recent workouts (last 7 days) workout_result = await self.db.execute( select(Workout) @@ -87,6 +99,10 @@ class DashboardService: async def get_weekly_stats(self) -> Dict: """Get weekly workout statistics.""" try: + # Test database connection first + if not await self.test_connection(): + raise Exception("Database connection test failed") + week_start = datetime.now() - timedelta(days=7) workout_result = await self.db.execute( diff --git a/tui/views/__pycache__/dashboard.cpython-313.pyc b/tui/views/__pycache__/dashboard.cpython-313.pyc index 8b7ccc8..3d77cd0 100644 Binary files a/tui/views/__pycache__/dashboard.cpython-313.pyc and b/tui/views/__pycache__/dashboard.cpython-313.pyc differ diff --git a/tui/views/__pycache__/rules.cpython-313.pyc b/tui/views/__pycache__/rules.cpython-313.pyc index 15a8ed8..52454ad 100644 Binary files a/tui/views/__pycache__/rules.cpython-313.pyc and b/tui/views/__pycache__/rules.cpython-313.pyc differ diff --git a/tui/views/dashboard.py b/tui/views/dashboard.py index 135cc23..f9513c5 100644 --- a/tui/views/dashboard.py +++ b/tui/views/dashboard.py @@ -1,5 +1,5 @@ """ -Dashboard view for AI Cycling Coach TUI. +Fixed Dashboard view for AI Cycling Coach TUI. Displays overview of recent workouts, plans, and key metrics. """ from datetime import datetime @@ -8,17 +8,28 @@ from textual.containers import Container, Horizontal, Vertical, ScrollableContai from textual.widgets import Static, DataTable, LoadingIndicator from textual.widget import Widget from textual.reactive import reactive +from textual import work -from backend.app.database import AsyncSessionLocal -from tui.services.dashboard_service import DashboardService +try: + from backend.app.database import AsyncSessionLocal + from tui.services.dashboard_service import DashboardService +except ImportError as e: + raise ImportError(f"Critical import error: {e}. Check service dependencies.") class DashboardView(Widget): """Main dashboard view showing workout summary and stats.""" - # Reactive attributes to store data - dashboard_data = reactive({}) - loading = reactive(True) + # Reactive attributes to store data - set init=False to prevent immediate refresh + dashboard_data = reactive({}, init=False) + loading = reactive(True, init=False) + error_message = reactive("", init=False) + + # Add unique identifier for debugging + debug_id = reactive("", init=False) + + # Track if we've mounted to prevent refresh before mount + _mounted = False DEFAULT_CSS = """ .view-title { @@ -48,172 +59,297 @@ class DashboardView(Widget): .stat-item { margin: 0 1; } + + .error-container { + border: solid $error; + padding: 1; + margin: 1 0; + color: $error; + } + .error-title { + text-style: bold; + margin-bottom: 1; + } + .error-subtitle { + text-style: underline; + margin: 1 0 0 1; + } + .error-item { + margin: 0 0 0 2; + } + .error-spacer { + height: 1; + } + .error-action { + margin-top: 1; + text-style: bold; + } + + .loading-overlay { + align: right top; + offset: 1 1; + padding: 1; + background: $panel; + border: solid $primary; + } """ def compose(self) -> ComposeResult: """Create dashboard layout.""" + self.log(f"[DashboardView] compose called | debug_id={self.debug_id} | loading={self.loading} | error={self.error_message}") yield Static("AI Cycling Coach Dashboard", classes="view-title") - - if self.loading: + # Always show the structure - use conditional content + if self.error_message: + with Container(classes="error-container"): + yield Static(f"Error: {self.error_message}", classes="error-title") + yield Static("Possible causes:", classes="error-subtitle") + yield Static("- Database connection issue", classes="error-item") + yield Static("- Service dependency missing", classes="error-item") + yield Static("- Invalid configuration", classes="error-item") + yield Static("", classes="error-spacer") + yield Static("Troubleshooting steps:", classes="error-subtitle") + yield Static("- Check database configuration", classes="error-item") + yield Static("- Verify backend services are running", classes="error-item") + yield Static("- View logs for details", classes="error-item") + yield Static("", classes="error-spacer") + yield Static("Click Refresh to try again", classes="error-action") + elif self.loading and not self.dashboard_data: + # Initial load - full screen loader 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 + # Show content with optional refresh indicator + if self.loading: + with Container(classes="loading-overlay"): + yield LoadingIndicator() + yield Static("Refreshing...") + yield from self._compose_dashboard_content() + + + def _compose_dashboard_content(self) -> ComposeResult: + """Compose the main dashboard content.""" + 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") - # 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") + # 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") +def on_mount(self) -> None: + """Load dashboard data when mounted.""" + # Generate unique debug ID for this instance + import uuid + self.debug_id = str(uuid.uuid4())[:8] + self.log(f"[DashboardView] on_mount called | debug_id={self.debug_id}") + self._mounted = True - 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() + # Use @work decorator for async operations + self.load_dashboard_data() + + @work(exclusive=True) async def load_dashboard_data(self) -> None: """Load and display dashboard data.""" + self.log(f"[DashboardView] load_dashboard_data started | debug_id={self.debug_id}") try: + self.loading = True + self.error_message = "" + + try: + # Explicitly check imports again in case of lazy loading + from backend.app.database import AsyncSessionLocal + from tui.services.dashboard_service import DashboardService + except ImportError as e: + self.log(f"[DashboardView] Import error in load_dashboard_data: {e} | debug_id={self.debug_id}", severity="error") + self.error_message = f"Service dependency error: {e}" + self.loading = False + return + async with AsyncSessionLocal() as db: + self.log(f"[DashboardView] Database session opened | debug_id={self.debug_id}") dashboard_service = DashboardService(db) - self.dashboard_data = await dashboard_service.get_dashboard_data() + + # Log service initialization + self.log(f"[DashboardView] DashboardService created | debug_id={self.debug_id}") + + 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() + # Log data retrieval + self.log(f"[DashboardView] Data retrieved | debug_id={self.debug_id} | workouts={len(dashboard_data.get('recent_workouts', []))} | weekly_stats={weekly_stats}") - # Populate the dashboard with data - await self.populate_dashboard(weekly_stats) + # Update reactive data + self.dashboard_data = dashboard_data + self.loading = False + + # Force recomposition after data is loaded + await self.call_after_refresh(self.populate_dashboard, weekly_stats) except Exception as e: - self.log(f"Error loading dashboard data: {e}", severity="error") + self.log(f"[DashboardView] Error loading dashboard data: {e} | debug_id={self.debug_id}", severity="error") + self.error_message = str(e) self.loading = False - self.refresh() + finally: + self.log(f"[DashboardView] load_dashboard_data completed | debug_id={self.debug_id}") async def populate_dashboard(self, weekly_stats: dict) -> None: """Populate dashboard widgets with loaded data.""" + self.log(f"[DashboardView] populate_dashboard started | debug_id={self.debug_id}") + if self.loading or self.error_message: + self.log(f"[DashboardView] populate_dashboard skipped - loading={self.loading}, error={self.error_message} | debug_id={self.debug_id}") + return + 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 + try: + self.log(f"[DashboardView] Updating workout table | debug_id={self.debug_id}") + workout_table = self.query_one("#recent-workouts", DataTable) + workout_table.clear() - # 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) - ) + 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 Exception: + 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) + ) + self.log(f"[DashboardView] Workout table updated with {len(self.dashboard_data.get('recent_workouts', []))} rows | debug_id={self.debug_id}") + except Exception as e: + self.log(f"[DashboardView] Error updating workout table: {e} | debug_id={self.debug_id}", severity="error") # 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" - ) + try: + self.log(f"[DashboardView] Updating weekly stats | debug_id={self.debug_id}") + 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" + ) + self.log(f"[DashboardView] Weekly stats updated | debug_id={self.debug_id}") + except Exception as e: + self.log(f"[DashboardView] Error updating stats: {e} | debug_id={self.debug_id}", severity="error") # 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") + try: + self.log(f"[DashboardView] Updating current plan | debug_id={self.debug_id}") + 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 Exception: + pass + self.query_one("#active-plan", Static).update(plan_text) + else: + self.query_one("#active-plan", Static).update("No active plan") + self.log(f"[DashboardView] Current plan updated | debug_id={self.debug_id}") + except Exception as e: + self.log(f"[DashboardView] Error updating plan: {e} | debug_id={self.debug_id}", severity="error") # 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}" - ) + try: + self.log(f"[DashboardView] Updating sync status | debug_id={self.debug_id}") + 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 Exception: + 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("") - else: - self.query_one("#sync-status", Static).update("Never synced") - self.query_one("#last-sync", Static).update("") + self.log(f"[DashboardView] Sync status updated | debug_id={self.debug_id}") + except Exception as e: + self.log(f"[DashboardView] Error updating sync status: {e} | debug_id={self.debug_id}", severity="error") except Exception as e: - self.log(f"Error populating dashboard: {e}", severity="error") + self.log(f"[DashboardView] Error populating dashboard: {e} | debug_id={self.debug_id}", severity="error") + self.error_message = f"Failed to populate dashboard: {e}" + finally: + self.log(f"[DashboardView] populate_dashboard completed | debug_id={self.debug_id}") 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() \ No newline at end of file + self.log(f"[DashboardView] watch_loading: loading={loading} | debug_id={self.debug_id}") + # Force recomposition when loading state changes + if self.is_mounted: + self.call_after_refresh(self._refresh_view) + + def watch_error_message(self, error_message: str) -> None: + """React to error message changes.""" + self.log(f"[DashboardView] watch_error_message: error_message={error_message} | debug_id={self.debug_id}") + if self.is_mounted: + self.call_after_refresh(self._refresh_view) + + async def _refresh_view(self) -> None: + """Force view refresh by recomposing.""" + self.log(f"[DashboardView] _refresh_view called | debug_id={self.debug_id}") + self.refresh(layout=True) \ No newline at end of file diff --git a/tui/views/rules.py b/tui/views/rules.py index 1fea4f6..1445935 100644 --- a/tui/views/rules.py +++ b/tui/views/rules.py @@ -211,16 +211,21 @@ class RuleView(Widget): async def on_mount(self) -> None: """Load rules when mounted.""" + self.log("Mounting Rules view") await self.load_rules() async def load_rules(self) -> None: """Load rules from database.""" + self.log("Starting rules load") self.loading = True self.refresh() try: + self.log("Creating database session") async with AsyncSessionLocal() as db: + self.log("Creating RuleService") rule_service = RuleService(db) + self.log("Fetching rules from database") self.rules = await rule_service.get_rules() self.log(f"Loaded {len(self.rules)} rules from database") await self.populate_rules_table() @@ -229,22 +234,26 @@ class RuleView(Widget): self.error_message = error_msg self.log(error_msg, severity="error") finally: + self.log("Finished loading rules") self.loading = False self.refresh() async def populate_rules_table(self) -> None: """Populate rules table with data.""" try: + self.log("Querying for rules table widget") rules_table = self.query_one("#rules-table", DataTable) if not rules_table: self.log("Rules table widget not found", severity="error") return + self.log("Clearing rules table") rules_table.clear() self.log(f"Populating table with {len(self.rules)} rules") if not self.rules: # Add placeholder row when no rules exist + self.log("Adding placeholder for empty rules") rules_table.add_row("No rules found", "", "", "", "") self.log("No rules to display") return diff --git a/tui/views/test_view.py b/tui/views/test_view.py new file mode 100644 index 0000000..68c2d5d --- /dev/null +++ b/tui/views/test_view.py @@ -0,0 +1,18 @@ +""" +Simple test view to verify TUI framework functionality. +""" +from textual.app import ComposeResult +from textual.widgets import Static +from textual.widget import Widget + +class TestView(Widget): + """Test view with static content to verify TUI rendering.""" + + def compose(self) -> ComposeResult: + yield Static("TUI Framework Test", classes="view-title") + yield Static("This is a simple test view to verify TUI functionality.") + yield Static("If you see this text, the TUI framework is working correctly.") + yield Static("Check marks: [✓] Text rendering [✓] Basic layout") + + def on_mount(self) -> None: + self.log("TestView mounted successfully")