Files
AICyclingCoach/main.py
2025-11-17 06:26:36 -08:00

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