From 72b5cc3aaa707dc94974f16adfa1ddbd87e59f24 Mon Sep 17 00:00:00 2001 From: sstent Date: Fri, 26 Sep 2025 10:19:56 -0700 Subject: [PATCH] sync - still working on the TUI --- main.py | 37 ++++++----- test_textual.py | 11 +++ tui/services/workout_service.py | 5 +- tui/views/base_view.py | 13 +++- tui/views/stub_views.py | 49 ++++++++++++++ tui/views/workouts.py | 114 ++++++++------------------------ 6 files changed, 124 insertions(+), 105 deletions(-) create mode 100644 test_textual.py create mode 100644 tui/views/stub_views.py diff --git a/main.py b/main.py index 5ee63f0..21677cb 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ Entry point for the terminal-based cycling training coach. import asyncio import logging from pathlib import Path +import sys from typing import Optional from textual.app import App, ComposeResult @@ -14,8 +15,8 @@ from textual.widgets import ( Header, Footer, Static, Button, DataTable, Placeholder, TabbedContent, TabPane ) -from textual import on from textual.logging import TextualHandler +from textual import on from backend.app.config import settings from backend.app.database import init_db @@ -100,6 +101,7 @@ class CyclingCoachApp(App): def compose(self) -> ComposeResult: """Create the main application layout.""" + sys.stdout.write("CyclingCoachApp.compose: START\n") yield Header() with Container(): @@ -134,12 +136,13 @@ class CyclingCoachApp(App): yield RouteView(id="route-view") yield Footer() - + sys.stdout.write("CyclingCoachApp.compose: END\n") + async def on_mount(self) -> None: """Initialize the application when mounted.""" # Set initial active navigation self.query_one("#nav-dashboard").add_class("-active") - + async def on_button_pressed(self, event: Button.Pressed) -> None: """Handle navigation button presses.""" button_id = event.button.id @@ -169,17 +172,25 @@ class CyclingCoachApp(App): @on(TabbedContent.TabActivated) async def on_tab_activated(self, event: TabbedContent.TabActivated) -> None: + sys.stdout.write(f"CyclingCoachApp.on_tab_activated: Tab {event.pane.id} activated\n") """Handle tab activation to load data for the active tab.""" if event.pane.id == "workouts-tab": workout_view = self.query_one("#workout-view", WorkoutView) + sys.stdout.write("CyclingCoachApp.on_tab_activated: Calling workout_view.load_data()\n") workout_view.load_data() def action_quit(self) -> None: - """Quit the application.""" self.exit() +async def init_db_async(): + try: + await init_db() + sys.stdout.write("Database initialized successfully\n") + except Exception as e: + sys.stdout.write(f"Database initialization failed: {e}\n") + sys.exit(1) -async def main(): +def main(): """Main entry point for the CLI application.""" # Create data directory if it doesn't exist data_dir = Path("data") @@ -188,19 +199,15 @@ async def main(): (data_dir / "sessions").mkdir(exist_ok=True) # Initialize database BEFORE starting the app - try: - await init_db() - print("Database initialized successfully") # Use print as app logging isn't available yet - except Exception as e: - print(f"Database initialization failed: {e}") - # Exit if database initialization fails - import sys - sys.exit(1) + asyncio.run(init_db_async()) # Run the TUI application + sys.stdout.write("main(): Initializing CyclingCoachApp\n") app = CyclingCoachApp() - await app.run_async() + sys.stdout.write("main(): CyclingCoachApp initialized. Running app.run()\n") + app.run() + sys.stdout.write("main(): app.run() finished.\n") if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + main() diff --git a/test_textual.py b/test_textual.py new file mode 100644 index 0000000..441eea5 --- /dev/null +++ b/test_textual.py @@ -0,0 +1,11 @@ +from textual.app import App, ComposeResult +from textual.widgets import Header, Footer, Static +class BasicApp(App): + def compose(self) -> ComposeResult: + yield Header() + yield Static("Hello, Textual!") + yield Footer() + +if __name__ == "__main__": + app = BasicApp() + app.run() diff --git a/tui/services/workout_service.py b/tui/services/workout_service.py index c40dde3..3eb2f6a 100644 --- a/tui/services/workout_service.py +++ b/tui/services/workout_service.py @@ -47,7 +47,10 @@ class WorkoutService: ] except Exception as e: - raise Exception(f"Error fetching workouts: {str(e)}") + # Log error properly + import logging + logging.error(f"Error fetching workouts: {str(e)}") + return [] async def get_workout(self, workout_id: int) -> Optional[Dict]: """Get a specific workout by ID.""" diff --git a/tui/views/base_view.py b/tui/views/base_view.py index 71094c8..b0c684d 100644 --- a/tui/views/base_view.py +++ b/tui/views/base_view.py @@ -1,10 +1,8 @@ -""" -Base view class for TUI application with common async utilities. -""" from textual.app import ComposeResult from textual.widget import Widget from textual.worker import Worker, get_current_worker from textual import work, on +import sys from typing import Callable, Any, Coroutine, Optional from tui.widgets.error_modal import ErrorModal @@ -13,20 +11,27 @@ class BaseView(Widget): def run_async(self, coro: Coroutine, callback: Callable[[Any], None] = None) -> Worker: """Run an async task in the background with proper error handling.""" + sys.stdout.write("BaseView.run_async: START\n") worker = self.run_worker( self._async_wrapper(coro, callback), exclusive=True, group="db_operations" ) + sys.stdout.write("BaseView.run_async: END\n") return worker async def _async_wrapper(self, coro: Coroutine, callback: Callable[[Any], None] = None) -> None: """Wrapper for async operations with cancellation support.""" + sys.stdout.write("BaseView._async_wrapper: START\n") try: + sys.stdout.write("BaseView._async_wrapper: Before await coro\n") result = await coro + sys.stdout.write("BaseView._async_wrapper: After await coro\n") if callback: + sys.stdout.write("BaseView._async_wrapper: Calling callback\n") self.call_after_refresh(callback, result) except Exception as e: + sys.stdout.write(f"BaseView._async_wrapper: ERROR: {str(e)}\n") self.log(f"Async operation failed: {str(e)}", severity="error") self.app.bell() self.call_after_refresh( @@ -35,8 +40,10 @@ class BaseView(Widget): lambda: self.run_async(coro, callback) ) finally: + sys.stdout.write("BaseView._async_wrapper: FINALLY\n") worker = get_current_worker() if worker and worker.is_cancelled: + sys.stdout.write("BaseView._async_wrapper: Worker cancelled\n") self.log("Async operation cancelled") def show_error(self, message: str, retry_action: Optional[Callable] = None) -> None: diff --git a/tui/views/stub_views.py b/tui/views/stub_views.py new file mode 100644 index 0000000..8c40cf1 --- /dev/null +++ b/tui/views/stub_views.py @@ -0,0 +1,49 @@ +""" +Stub views for Plans, Rules, and Routes. +These can be expanded later following the same async loading pattern. +""" +from textual.app import ComposeResult +from textual.widgets import Static +from tui.views.base_view import BaseView + + +class PlanView(BaseView): + """Training plan management view.""" + + def compose(self) -> ComposeResult: + """Create plan view layout.""" + yield Static("Training Plans") + yield Static("Coming soon - this will show your training plans") + + def load_data_if_needed(self) -> None: + """Load plan data if needed.""" + # Implement similar to WorkoutView when ready + pass + + +class RuleView(BaseView): + """Training rule management view.""" + + def compose(self) -> ComposeResult: + """Create rule view layout.""" + yield Static("Training Rules") + yield Static("Coming soon - this will show your training rules") + + def load_data_if_needed(self) -> None: + """Load rule data if needed.""" + # Implement similar to WorkoutView when ready + pass + + +class RouteView(BaseView): + """Route management view.""" + + def compose(self) -> ComposeResult: + """Create route view layout.""" + yield Static("Routes") + yield Static("Coming soon - this will show your routes") + + def load_data_if_needed(self) -> None: + """Load route data if needed.""" + # Implement similar to WorkoutView when ready + pass \ No newline at end of file diff --git a/tui/views/workouts.py b/tui/views/workouts.py index b5d11fb..95c1895 100644 --- a/tui/views/workouts.py +++ b/tui/views/workouts.py @@ -13,6 +13,7 @@ from textual.widgets import ( from textual.widget import Widget from textual.reactive import reactive from textual.message import Message +import sys from typing import List, Dict, Optional from backend.app.database import AsyncSessionLocal @@ -206,6 +207,7 @@ class WorkoutView(BaseView): def compose(self) -> ComposeResult: """Create workout view layout.""" + sys.stdout.write("WorkoutView.compose: START\n") yield Static("Workout Management", classes="view-title") if self.loading: @@ -215,121 +217,57 @@ class WorkoutView(BaseView): 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"): + with TabPane("Workout Details", id="workout-details-tab"): + if self.selected_workout: 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) - + else: + yield Static("Select a workout to view details", id="workout-details-placeholder") + sys.stdout.write("WorkoutView.compose: END\n") + def on_mount(self) -> None: """Load workout data when mounted.""" + sys.stdout.write("WorkoutView.on_mount: START\n") self.loading = True + self.load_data() + sys.stdout.write("WorkoutView.on_mount: END\n") def load_data(self) -> None: """Public method to trigger data loading for the workout view.""" + sys.stdout.write("WorkoutView.load_data: START\n") self.loading = True - self.refresh() self.run_async(self._load_workouts_data(), self.on_workouts_loaded) + sys.stdout.write("WorkoutView.load_data: END\n") async def _load_workouts_data(self) -> tuple[list, dict]: """Load workouts and sync status (async worker).""" + sys.stdout.write("WorkoutView._load_workouts_data: START\n") self.log("Attempting to load workouts data...") try: + sys.stdout.write("WorkoutView._load_workouts_data: Before AsyncSessionLocal\n") async with AsyncSessionLocal() as db: workout_service = WorkoutService(db) + sys.stdout.write("WorkoutView._load_workouts_data: Before get_workouts\n") workouts = await workout_service.get_workouts(limit=50) + sys.stdout.write("WorkoutView._load_workouts_data: After get_workouts\n") sync_status = await workout_service.get_sync_status() + sys.stdout.write("WorkoutView._load_workouts_data: After get_sync_status\n") self.log(f"Workouts data loaded: {len(workouts)} workouts, sync status: {sync_status}") + sys.stdout.write("WorkoutView._load_workouts_data: Before return\n") return workouts, sync_status except Exception as e: + sys.stdout.write(f"WorkoutView._load_workouts_data: ERROR: {str(e)}\n") self.log(f"Error loading workouts: {str(e)}", severity="error") raise + finally: + sys.stdout.write("WorkoutView._load_workouts_data: FINALLY\n") def on_workouts_loaded(self, result: tuple[list, dict]) -> None: """Handle loaded workout data.""" + sys.stdout.write("WorkoutView.on_workouts_loaded: START\n") self.log("Entering on_workouts_loaded") try: workouts, sync_status = result + sys.stdout.write(f"WorkoutView.on_workouts_loaded: received: {len(workouts)} workouts, sync status: {sync_status}\n") self.log(f"on_workouts_loaded received: {len(workouts)} workouts, sync status: {sync_status}") self.workouts = workouts self.sync_status = sync_status @@ -337,10 +275,14 @@ Elevation Gain: {workout.get('elevation_gain_m', 'N/A')} m self.refresh(layout=True) self.populate_workouts_table() self.update_sync_status() + sys.stdout.write("WorkoutView.on_workouts_loaded: UI updated\n") except Exception as e: + sys.stdout.write(f"WorkoutView.on_workouts_loaded: ERROR: {e}\n") self.log(f"Error in on_workouts_loaded: {e}", severity="error") self.loading = False self.refresh() + finally: + sys.stdout.write("WorkoutView.on_workouts_loaded: FINALLY\n") async def populate_workouts_table(self) -> None: """Populate the workouts table."""