sync - still working on the TUI

This commit is contained in:
2025-09-26 10:19:56 -07:00
parent 6c7e49d093
commit 72b5cc3aaa
6 changed files with 124 additions and 105 deletions

37
main.py
View File

@@ -6,6 +6,7 @@ Entry point for the terminal-based cycling training coach.
import asyncio import asyncio
import logging import logging
from pathlib import Path from pathlib import Path
import sys
from typing import Optional from typing import Optional
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
@@ -14,8 +15,8 @@ from textual.widgets import (
Header, Footer, Static, Button, DataTable, Header, Footer, Static, Button, DataTable,
Placeholder, TabbedContent, TabPane Placeholder, TabbedContent, TabPane
) )
from textual import on
from textual.logging import TextualHandler from textual.logging import TextualHandler
from textual import on
from backend.app.config import settings from backend.app.config import settings
from backend.app.database import init_db from backend.app.database import init_db
@@ -100,6 +101,7 @@ class CyclingCoachApp(App):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the main application layout.""" """Create the main application layout."""
sys.stdout.write("CyclingCoachApp.compose: START\n")
yield Header() yield Header()
with Container(): with Container():
@@ -134,12 +136,13 @@ class CyclingCoachApp(App):
yield RouteView(id="route-view") yield RouteView(id="route-view")
yield Footer() yield Footer()
sys.stdout.write("CyclingCoachApp.compose: END\n")
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Initialize the application when mounted.""" """Initialize the application when mounted."""
# Set initial active navigation # Set initial active navigation
self.query_one("#nav-dashboard").add_class("-active") self.query_one("#nav-dashboard").add_class("-active")
async def on_button_pressed(self, event: Button.Pressed) -> None: async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle navigation button presses.""" """Handle navigation button presses."""
button_id = event.button.id button_id = event.button.id
@@ -169,17 +172,25 @@ class CyclingCoachApp(App):
@on(TabbedContent.TabActivated) @on(TabbedContent.TabActivated)
async def on_tab_activated(self, event: TabbedContent.TabActivated) -> None: 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.""" """Handle tab activation to load data for the active tab."""
if event.pane.id == "workouts-tab": if event.pane.id == "workouts-tab":
workout_view = self.query_one("#workout-view", WorkoutView) 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() workout_view.load_data()
def action_quit(self) -> None: def action_quit(self) -> None:
"""Quit the application."""
self.exit() 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.""" """Main entry point for the CLI application."""
# Create data directory if it doesn't exist # Create data directory if it doesn't exist
data_dir = Path("data") data_dir = Path("data")
@@ -188,19 +199,15 @@ async def main():
(data_dir / "sessions").mkdir(exist_ok=True) (data_dir / "sessions").mkdir(exist_ok=True)
# Initialize database BEFORE starting the app # Initialize database BEFORE starting the app
try: asyncio.run(init_db_async())
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)
# Run the TUI application # Run the TUI application
sys.stdout.write("main(): Initializing CyclingCoachApp\n")
app = CyclingCoachApp() 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__": if __name__ == "__main__":
asyncio.run(main()) main()

11
test_textual.py Normal file
View File

@@ -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()

View File

@@ -47,7 +47,10 @@ class WorkoutService:
] ]
except Exception as e: 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]: async def get_workout(self, workout_id: int) -> Optional[Dict]:
"""Get a specific workout by ID.""" """Get a specific workout by ID."""

View File

@@ -1,10 +1,8 @@
"""
Base view class for TUI application with common async utilities.
"""
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.widget import Widget from textual.widget import Widget
from textual.worker import Worker, get_current_worker from textual.worker import Worker, get_current_worker
from textual import work, on from textual import work, on
import sys
from typing import Callable, Any, Coroutine, Optional from typing import Callable, Any, Coroutine, Optional
from tui.widgets.error_modal import ErrorModal 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: def run_async(self, coro: Coroutine, callback: Callable[[Any], None] = None) -> Worker:
"""Run an async task in the background with proper error handling.""" """Run an async task in the background with proper error handling."""
sys.stdout.write("BaseView.run_async: START\n")
worker = self.run_worker( worker = self.run_worker(
self._async_wrapper(coro, callback), self._async_wrapper(coro, callback),
exclusive=True, exclusive=True,
group="db_operations" group="db_operations"
) )
sys.stdout.write("BaseView.run_async: END\n")
return worker return worker
async def _async_wrapper(self, coro: Coroutine, callback: Callable[[Any], None] = None) -> None: async def _async_wrapper(self, coro: Coroutine, callback: Callable[[Any], None] = None) -> None:
"""Wrapper for async operations with cancellation support.""" """Wrapper for async operations with cancellation support."""
sys.stdout.write("BaseView._async_wrapper: START\n")
try: try:
sys.stdout.write("BaseView._async_wrapper: Before await coro\n")
result = await coro result = await coro
sys.stdout.write("BaseView._async_wrapper: After await coro\n")
if callback: if callback:
sys.stdout.write("BaseView._async_wrapper: Calling callback\n")
self.call_after_refresh(callback, result) self.call_after_refresh(callback, result)
except Exception as e: 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.log(f"Async operation failed: {str(e)}", severity="error")
self.app.bell() self.app.bell()
self.call_after_refresh( self.call_after_refresh(
@@ -35,8 +40,10 @@ class BaseView(Widget):
lambda: self.run_async(coro, callback) lambda: self.run_async(coro, callback)
) )
finally: finally:
sys.stdout.write("BaseView._async_wrapper: FINALLY\n")
worker = get_current_worker() worker = get_current_worker()
if worker and worker.is_cancelled: if worker and worker.is_cancelled:
sys.stdout.write("BaseView._async_wrapper: Worker cancelled\n")
self.log("Async operation cancelled") self.log("Async operation cancelled")
def show_error(self, message: str, retry_action: Optional[Callable] = None) -> None: def show_error(self, message: str, retry_action: Optional[Callable] = None) -> None:

49
tui/views/stub_views.py Normal file
View File

@@ -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

View File

@@ -13,6 +13,7 @@ from textual.widgets import (
from textual.widget import Widget from textual.widget import Widget
from textual.reactive import reactive from textual.reactive import reactive
from textual.message import Message from textual.message import Message
import sys
from typing import List, Dict, Optional from typing import List, Dict, Optional
from backend.app.database import AsyncSessionLocal from backend.app.database import AsyncSessionLocal
@@ -206,6 +207,7 @@ class WorkoutView(BaseView):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create workout view layout.""" """Create workout view layout."""
sys.stdout.write("WorkoutView.compose: START\n")
yield Static("Workout Management", classes="view-title") yield Static("Workout Management", classes="view-title")
if self.loading: if self.loading:
@@ -215,121 +217,57 @@ class WorkoutView(BaseView):
with TabPane("Workout List", id="workout-list-tab"): with TabPane("Workout List", id="workout-list-tab"):
yield self.compose_workout_list() 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() yield self.compose_workout_details()
else:
def compose_workout_list(self) -> ComposeResult: yield Static("Select a workout to view details", id="workout-details-placeholder")
"""Create workout list view.""" sys.stdout.write("WorkoutView.compose: END\n")
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: def on_mount(self) -> None:
"""Load workout data when mounted.""" """Load workout data when mounted."""
sys.stdout.write("WorkoutView.on_mount: START\n")
self.loading = True self.loading = True
self.load_data()
sys.stdout.write("WorkoutView.on_mount: END\n")
def load_data(self) -> None: def load_data(self) -> None:
"""Public method to trigger data loading for the workout view.""" """Public method to trigger data loading for the workout view."""
sys.stdout.write("WorkoutView.load_data: START\n")
self.loading = True self.loading = True
self.refresh()
self.run_async(self._load_workouts_data(), self.on_workouts_loaded) 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]: async def _load_workouts_data(self) -> tuple[list, dict]:
"""Load workouts and sync status (async worker).""" """Load workouts and sync status (async worker)."""
sys.stdout.write("WorkoutView._load_workouts_data: START\n")
self.log("Attempting to load workouts data...") self.log("Attempting to load workouts data...")
try: try:
sys.stdout.write("WorkoutView._load_workouts_data: Before AsyncSessionLocal\n")
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
workout_service = WorkoutService(db) workout_service = WorkoutService(db)
sys.stdout.write("WorkoutView._load_workouts_data: Before get_workouts\n")
workouts = await workout_service.get_workouts(limit=50) 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() 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}") 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 return workouts, sync_status
except Exception as e: 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") self.log(f"Error loading workouts: {str(e)}", severity="error")
raise raise
finally:
sys.stdout.write("WorkoutView._load_workouts_data: FINALLY\n")
def on_workouts_loaded(self, result: tuple[list, dict]) -> None: def on_workouts_loaded(self, result: tuple[list, dict]) -> None:
"""Handle loaded workout data.""" """Handle loaded workout data."""
sys.stdout.write("WorkoutView.on_workouts_loaded: START\n")
self.log("Entering on_workouts_loaded") self.log("Entering on_workouts_loaded")
try: try:
workouts, sync_status = result 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.log(f"on_workouts_loaded received: {len(workouts)} workouts, sync status: {sync_status}")
self.workouts = workouts self.workouts = workouts
self.sync_status = sync_status self.sync_status = sync_status
@@ -337,10 +275,14 @@ Elevation Gain: {workout.get('elevation_gain_m', 'N/A')} m
self.refresh(layout=True) self.refresh(layout=True)
self.populate_workouts_table() self.populate_workouts_table()
self.update_sync_status() self.update_sync_status()
sys.stdout.write("WorkoutView.on_workouts_loaded: UI updated\n")
except Exception as e: 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.log(f"Error in on_workouts_loaded: {e}", severity="error")
self.loading = False self.loading = False
self.refresh() self.refresh()
finally:
sys.stdout.write("WorkoutView.on_workouts_loaded: FINALLY\n")
async def populate_workouts_table(self) -> None: async def populate_workouts_table(self) -> None:
"""Populate the workouts table.""" """Populate the workouts table."""