mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2025-12-05 23:52:06 +00:00
329 lines
11 KiB
Python
329 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AI Cycling Coach - CLI TUI Application
|
|
Entry point for the terminal-based cycling training coach.
|
|
"""
|
|
import argparse
|
|
import asyncio
|
|
import logging
|
|
from logging.handlers import RotatingFileHandler
|
|
from pathlib import Path
|
|
import sys
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
import os
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.containers import Container, Horizontal, Vertical
|
|
from textual.widgets import (
|
|
Header, Footer, Static, Button, DataTable,
|
|
Placeholder, TabbedContent, TabPane
|
|
)
|
|
from textual.logging import TextualHandler
|
|
from textual import on
|
|
|
|
from backend.app.config import settings
|
|
from backend.app.database import init_db
|
|
# Use working dashboard with static content
|
|
from tui.views.dashboard_working import WorkingDashboardView as DashboardView
|
|
from tui.views.workouts import WorkoutView
|
|
from tui.views.plans import PlanView
|
|
from tui.views.rules import RuleView
|
|
from tui.views.routes import RouteView
|
|
from backend.app.database import AsyncSessionLocal
|
|
from tui.services.workout_service import WorkoutService
|
|
from backend.app.services.workout_sync import WorkoutSyncService
|
|
|
|
|
|
class CyclingCoachApp(App):
|
|
"""Main TUI application for AI Cycling Coach."""
|
|
|
|
CSS = """
|
|
.title {
|
|
text-align: center;
|
|
color: $accent;
|
|
text-style: bold;
|
|
padding: 1;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 20;
|
|
background: $surface;
|
|
}
|
|
|
|
.main-content {
|
|
background: $background;
|
|
}
|
|
|
|
.nav-button {
|
|
width: 100%;
|
|
height: 3;
|
|
margin: 1 0;
|
|
}
|
|
|
|
.nav-button.-active {
|
|
background: $accent;
|
|
color: $text;
|
|
}
|
|
|
|
TabbedContent {
|
|
height: 1fr;
|
|
width: 1fr;
|
|
}
|
|
|
|
TabPane {
|
|
height: 1fr;
|
|
width: 1fr;
|
|
}
|
|
"""
|
|
|
|
TITLE = "AI Cycling Coach"
|
|
SUB_TITLE = "Terminal Training Interface"
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.current_view = "dashboard"
|
|
self._setup_logging()
|
|
|
|
def _setup_logging(self, level=logging.INFO):
|
|
"""Configure logging for the TUI application."""
|
|
# Create logs directory
|
|
logs_dir = Path("logs")
|
|
logs_dir.mkdir(exist_ok=True)
|
|
|
|
# Set up logger
|
|
logger = logging.getLogger("cycling_coach")
|
|
logger.setLevel(level)
|
|
|
|
# Add Textual handler for TUI-compatible logging
|
|
textual_handler = TextualHandler()
|
|
logger.addHandler(textual_handler)
|
|
|
|
# Add file handler
|
|
# Add file handler for rotating logs
|
|
file_handler = logging.handlers.RotatingFileHandler(
|
|
logs_dir / "app.log", maxBytes=1024 * 1024 * 5, backupCount=5 # 5MB
|
|
)
|
|
file_handler.setFormatter(
|
|
logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
|
)
|
|
logger.addHandler(file_handler)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Create the main application layout."""
|
|
yield Header()
|
|
|
|
with Container():
|
|
with Horizontal():
|
|
# Sidebar navigation
|
|
with Vertical(classes="sidebar"):
|
|
yield Static("Navigation", classes="title")
|
|
yield Button("Dashboard", id="nav-dashboard", classes="nav-button")
|
|
yield Button("Workouts", id="nav-workouts", classes="nav-button")
|
|
yield Button("Plans", id="nav-plans", classes="nav-button")
|
|
yield Button("Rules", id="nav-rules", classes="nav-button")
|
|
yield Button("Routes", id="nav-routes", classes="nav-button")
|
|
yield Button("Settings", id="nav-settings", classes="nav-button")
|
|
yield Button("Quit", id="nav-quit", classes="nav-button")
|
|
|
|
# Main content area
|
|
with Container(classes="main-content"):
|
|
with TabbedContent(id="main-tabs"):
|
|
with TabPane("Dashboard", id="dashboard-tab"):
|
|
yield DashboardView(id="dashboard-view")
|
|
|
|
with TabPane("Workouts", id="workouts-tab"):
|
|
yield WorkoutView(id="workout-view")
|
|
|
|
with TabPane("Plans", id="plans-tab"):
|
|
yield PlanView(id="plan-view")
|
|
|
|
with TabPane("Rules", id="rules-tab"):
|
|
yield RuleView(id="rule-view")
|
|
|
|
with TabPane("Routes", id="routes-tab"):
|
|
yield RouteView(id="route-view")
|
|
|
|
yield Footer()
|
|
|
|
async def on_mount(self) -> None:
|
|
"""Initialize the application when mounted."""
|
|
# Set initial active navigation and tab
|
|
self.query_one("#nav-dashboard").add_class("-active")
|
|
tabs = self.query_one("#main-tabs", TabbedContent)
|
|
if tabs:
|
|
tabs.active = "dashboard-tab"
|
|
|
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
"""Handle navigation button presses."""
|
|
button_id = event.button.id
|
|
|
|
if button_id == "nav-quit":
|
|
self.exit()
|
|
return
|
|
|
|
# Handle navigation
|
|
nav_mapping = {
|
|
"nav-dashboard": "dashboard-tab",
|
|
"nav-workouts": "workouts-tab",
|
|
"nav-plans": "plans-tab",
|
|
"nav-rules": "rules-tab",
|
|
"nav-routes": "routes-tab",
|
|
}
|
|
|
|
if button_id in nav_mapping:
|
|
# Update active tab
|
|
tabs = self.query_one("#main-tabs")
|
|
tabs.active = nav_mapping[button_id]
|
|
|
|
# Update navigation button styles
|
|
for nav_button in self.query("Button"):
|
|
nav_button.remove_class("-active")
|
|
event.button.add_class("-active")
|
|
|
|
@on(TabbedContent.TabActivated)
|
|
async def on_tab_activated(self, event: TabbedContent.TabActivated) -> None:
|
|
"""Handle tab activation to load data for the active tab."""
|
|
if event.pane.id == "workouts-tab":
|
|
workout_view = self.query_one("#workout-view", WorkoutView)
|
|
workout_view.load_data()
|
|
|
|
def action_quit(self) -> None:
|
|
self.exit()
|
|
|
|
async def init_db_async():
|
|
logger = logging.getLogger("cycling_coach")
|
|
try:
|
|
await init_db()
|
|
logger.info("Database initialized successfully")
|
|
except Exception as e:
|
|
logger.error(f"Database initialization failed: {e}")
|
|
sys.exit(1)
|
|
|
|
async def sync_garmin_activities_cli():
|
|
"""Sync Garmin activities in CLI format without starting TUI."""
|
|
logger = logging.getLogger("cycling_coach")
|
|
try:
|
|
logger.info("Initializing database for Garmin sync...")
|
|
await init_db_async()
|
|
|
|
logger.info("Starting Garmin activity sync...")
|
|
async with AsyncSessionLocal() as db:
|
|
workout_sync_service = WorkoutSyncService(db)
|
|
await workout_sync_service.sync_recent_activities()
|
|
logger.info("Garmin activity sync completed successfully.")
|
|
except Exception as e:
|
|
logger.error(f"Error during Garmin activity sync: {e}")
|
|
sys.exit(1)
|
|
|
|
async def list_workouts_cli():
|
|
"""Display workouts in CLI format without starting TUI."""
|
|
logger = logging.getLogger("cycling_coach")
|
|
try:
|
|
# Initialize database
|
|
logger.info("Initializing database for listing workouts...")
|
|
await init_db_async()
|
|
|
|
# Get workouts using WorkoutService
|
|
async with AsyncSessionLocal() as db:
|
|
workout_service = WorkoutService(db)
|
|
workouts = await workout_service.get_workouts(limit=50)
|
|
|
|
if not workouts:
|
|
logger.info("No workouts found.")
|
|
return
|
|
|
|
# Print header
|
|
logger.info("AI Cycling Coach - Workouts")
|
|
logger.info("=" * 80)
|
|
logger.info(f"{'Date':<12} {'Type':<15} {'Duration':<10} {'Distance':<10} {'Avg HR':<8} {'Avg Power':<10}")
|
|
logger.info("-" * 80)
|
|
|
|
# Print each workout
|
|
for workout in 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"
|
|
|
|
# Format heart rate
|
|
hr_str = "N/A"
|
|
if workout.get("avg_hr"):
|
|
hr_str = f"{workout['avg_hr']} BPM"
|
|
|
|
# Format power
|
|
power_str = "N/A"
|
|
if workout.get("avg_power"):
|
|
power_str = f"{workout['avg_power']} W"
|
|
|
|
logger.info(f"{date_str:<12} {workout.get('activity_type', 'Unknown')[:14]:<15} {duration_str:<10} {distance_str:<10} {hr_str:<8} {power_str:<10}")
|
|
|
|
logger.info(f"\nTotal workouts: {len(workouts)}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing workouts: {e}")
|
|
sys.exit(1)
|
|
|
|
def main():
|
|
"""Main entry point for the CLI application."""
|
|
parser = argparse.ArgumentParser(description="AI Cycling Coach - Terminal Training Interface")
|
|
parser.add_argument("--list-workouts", action="store_true",
|
|
help="List all workouts in CLI format and exit")
|
|
parser.add_argument("--sync-garmin", action="store_true",
|
|
help="Sync Garmin activities and exit")
|
|
parser.add_argument("--debug", action="store_true", help="Enable debug logging")
|
|
|
|
args = parser.parse_args()
|
|
|
|
log_level = logging.DEBUG if args.debug else logging.INFO
|
|
|
|
# Handle CLI commands that don't need TUI
|
|
if args.list_workouts or args.sync_garmin:
|
|
# Configure logging using the app's setup
|
|
app = CyclingCoachApp()
|
|
app._setup_logging(level=log_level)
|
|
|
|
# Get the configured logger
|
|
cli_logger = logging.getLogger("cycling_coach")
|
|
|
|
if args.list_workouts:
|
|
asyncio.run(list_workouts_cli())
|
|
elif args.sync_garmin:
|
|
asyncio.run(sync_garmin_activities_cli())
|
|
|
|
# Exit gracefully after CLI commands
|
|
return
|
|
return
|
|
|
|
# Create data directory if it doesn't exist
|
|
data_dir = Path("data")
|
|
data_dir.mkdir(exist_ok=True)
|
|
(data_dir / "gpx").mkdir(exist_ok=True)
|
|
(data_dir / "sessions").mkdir(exist_ok=True)
|
|
|
|
# Initialize database BEFORE starting the app
|
|
asyncio.run(init_db_async())
|
|
|
|
# Run the TUI application
|
|
# Run the TUI application
|
|
app.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|