mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2025-12-05 23:52:06 +00:00
sync - still working on the TUI
This commit is contained in:
33
main.py
33
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,6 +136,7 @@ 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."""
|
||||
@@ -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())
|
||||
main()
|
||||
|
||||
11
test_textual.py
Normal file
11
test_textual.py
Normal 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()
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
49
tui/views/stub_views.py
Normal file
49
tui/views/stub_views.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user