mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-05 14:01:58 +00:00
change to TUI
This commit is contained in:
18
tui/views/__init__.py
Normal file
18
tui/views/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
TUI Views package.
|
||||
Contains all the main view components for the application screens.
|
||||
"""
|
||||
|
||||
from .dashboard import DashboardView
|
||||
from .workouts import WorkoutView
|
||||
from .plans import PlanView
|
||||
from .rules import RuleView
|
||||
from .routes import RouteView
|
||||
|
||||
__all__ = [
|
||||
'DashboardView',
|
||||
'WorkoutView',
|
||||
'PlanView',
|
||||
'RuleView',
|
||||
'RouteView'
|
||||
]
|
||||
BIN
tui/views/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/dashboard.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/dashboard.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/plans.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/plans.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/routes.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/routes.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/rules.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/rules.cpython-313.pyc
Normal file
Binary file not shown.
BIN
tui/views/__pycache__/workouts.cpython-313.pyc
Normal file
BIN
tui/views/__pycache__/workouts.cpython-313.pyc
Normal file
Binary file not shown.
219
tui/views/dashboard.py
Normal file
219
tui/views/dashboard.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Dashboard view for AI Cycling Coach TUI.
|
||||
Displays overview of recent workouts, plans, and key metrics.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import Static, DataTable, LoadingIndicator
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.dashboard_service import DashboardService
|
||||
|
||||
|
||||
class DashboardView(Widget):
|
||||
"""Main dashboard view showing workout summary and stats."""
|
||||
|
||||
# Reactive attributes to store data
|
||||
dashboard_data = reactive({})
|
||||
loading = reactive(True)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.dashboard-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
margin: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create dashboard layout."""
|
||||
yield Static("AI Cycling Coach Dashboard", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="dashboard-loader")
|
||||
else:
|
||||
with ScrollableContainer():
|
||||
with Horizontal():
|
||||
# Left column - Recent workouts
|
||||
with Vertical(classes="dashboard-column"):
|
||||
yield Static("Recent Workouts", classes="section-title")
|
||||
workout_table = DataTable(id="recent-workouts")
|
||||
workout_table.add_columns("Date", "Type", "Duration", "Distance", "Avg HR")
|
||||
yield workout_table
|
||||
|
||||
# Right column - Quick stats and current plan
|
||||
with Vertical(classes="dashboard-column"):
|
||||
# Weekly stats
|
||||
with Container(classes="stats-container"):
|
||||
yield Static("This Week", classes="section-title")
|
||||
yield Static("Workouts: 0", id="week-workouts", classes="stat-item")
|
||||
yield Static("Distance: 0 km", id="week-distance", classes="stat-item")
|
||||
yield Static("Time: 0h 0m", id="week-time", classes="stat-item")
|
||||
|
||||
# Active plan
|
||||
with Container(classes="stats-container"):
|
||||
yield Static("Current Plan", classes="section-title")
|
||||
yield Static("No active plan", id="active-plan", classes="stat-item")
|
||||
|
||||
# Sync status
|
||||
with Container(classes="stats-container"):
|
||||
yield Static("Garmin Sync", classes="section-title")
|
||||
yield Static("Never synced", id="sync-status", classes="stat-item")
|
||||
yield Static("", id="last-sync", classes="stat-item")
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load dashboard data when mounted."""
|
||||
try:
|
||||
await self.load_dashboard_data()
|
||||
except Exception as e:
|
||||
self.log(f"Dashboard loading error: {e}", severity="error")
|
||||
# Show error state instead of loading indicator
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_dashboard_data(self) -> None:
|
||||
"""Load and display dashboard data."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
dashboard_service = DashboardService(db)
|
||||
self.dashboard_data = await dashboard_service.get_dashboard_data()
|
||||
weekly_stats = await dashboard_service.get_weekly_stats()
|
||||
|
||||
# Update the reactive data and stop loading
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate the dashboard with data
|
||||
await self.populate_dashboard(weekly_stats)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading dashboard data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_dashboard(self, weekly_stats: dict) -> None:
|
||||
"""Populate dashboard widgets with loaded data."""
|
||||
try:
|
||||
# Update recent workouts table
|
||||
workout_table = self.query_one("#recent-workouts", DataTable)
|
||||
workout_table.clear()
|
||||
|
||||
for workout in self.dashboard_data.get("recent_workouts", []):
|
||||
# Format datetime for display
|
||||
start_time = "N/A"
|
||||
if workout.get("start_time"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
|
||||
start_time = dt.strftime("%m/%d %H:%M")
|
||||
except:
|
||||
start_time = workout["start_time"][:10] # Fallback to date only
|
||||
|
||||
# Format duration
|
||||
duration = "N/A"
|
||||
if workout.get("duration_seconds"):
|
||||
minutes = workout["duration_seconds"] // 60
|
||||
duration = f"{minutes}min"
|
||||
|
||||
# Format distance
|
||||
distance = "N/A"
|
||||
if workout.get("distance_m"):
|
||||
distance = f"{workout['distance_m'] / 1000:.1f}km"
|
||||
|
||||
# Format heart rate
|
||||
avg_hr = workout.get("avg_hr", "N/A")
|
||||
if avg_hr != "N/A":
|
||||
avg_hr = f"{avg_hr}bpm"
|
||||
|
||||
workout_table.add_row(
|
||||
start_time,
|
||||
workout.get("activity_type", "Unknown") or "Unknown",
|
||||
duration,
|
||||
distance,
|
||||
str(avg_hr)
|
||||
)
|
||||
|
||||
# Update weekly stats
|
||||
self.query_one("#week-workouts", Static).update(
|
||||
f"Workouts: {weekly_stats.get('workout_count', 0)}"
|
||||
)
|
||||
self.query_one("#week-distance", Static).update(
|
||||
f"Distance: {weekly_stats.get('total_distance_km', 0)}km"
|
||||
)
|
||||
self.query_one("#week-time", Static).update(
|
||||
f"Time: {weekly_stats.get('total_time_hours', 0):.1f}h"
|
||||
)
|
||||
|
||||
# Update current plan
|
||||
current_plan = self.dashboard_data.get("current_plan")
|
||||
if current_plan:
|
||||
plan_text = f"Plan v{current_plan.get('version', 'N/A')}"
|
||||
if current_plan.get("created_at"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(current_plan["created_at"].replace('Z', '+00:00'))
|
||||
plan_text += f" ({dt.strftime('%m/%d/%Y')})"
|
||||
except:
|
||||
pass
|
||||
self.query_one("#active-plan", Static).update(plan_text)
|
||||
else:
|
||||
self.query_one("#active-plan", Static).update("No active plan")
|
||||
|
||||
# Update sync status
|
||||
last_sync = self.dashboard_data.get("last_sync")
|
||||
if last_sync:
|
||||
status = last_sync.get("status", "unknown")
|
||||
activities_count = last_sync.get("activities_synced", 0)
|
||||
|
||||
self.query_one("#sync-status", Static).update(
|
||||
f"Status: {status.title()}"
|
||||
)
|
||||
|
||||
if last_sync.get("last_sync_time"):
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_sync["last_sync_time"].replace('Z', '+00:00'))
|
||||
sync_time = dt.strftime("%m/%d %H:%M")
|
||||
self.query_one("#last-sync", Static).update(
|
||||
f"Last: {sync_time} ({activities_count} activities)"
|
||||
)
|
||||
except:
|
||||
self.query_one("#last-sync", Static).update(
|
||||
f"Activities: {activities_count}"
|
||||
)
|
||||
else:
|
||||
self.query_one("#last-sync", Static).update("")
|
||||
else:
|
||||
self.query_one("#sync-status", Static).update("Never synced")
|
||||
self.query_one("#last-sync", Static).update("")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating dashboard: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
# Trigger recomposition when loading state changes
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
348
tui/views/plans.py
Normal file
348
tui/views/plans.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
Plan view for AI Cycling Coach TUI.
|
||||
Displays training plans, plan generation, and plan management.
|
||||
"""
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import (
|
||||
Static, DataTable, Button, Input, TextArea, Select, LoadingIndicator,
|
||||
Collapsible, TabbedContent, TabPane, Label
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.plan_service import PlanService
|
||||
from tui.services.rule_service import RuleService
|
||||
|
||||
|
||||
class PlanGenerationForm(Widget):
|
||||
"""Form for generating new training plans."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create plan generation form."""
|
||||
yield Label("Plan Generation")
|
||||
|
||||
with Vertical():
|
||||
yield Label("Training Goals:")
|
||||
yield Input(placeholder="e.g., Build endurance, Improve power", id="goals-input")
|
||||
|
||||
yield Label("Weekly Training Days:")
|
||||
yield Select(
|
||||
[(str(i), str(i)) for i in range(1, 8)],
|
||||
value="4",
|
||||
id="training-days-select"
|
||||
)
|
||||
|
||||
yield Label("Select Training Rules:")
|
||||
yield Select(
|
||||
[("loading", "Loading rules...")],
|
||||
id="rules-select",
|
||||
allow_multiple=True
|
||||
)
|
||||
|
||||
with Horizontal():
|
||||
yield Button("Generate Plan", id="generate-plan-btn", variant="primary")
|
||||
yield Button("Clear Form", id="clear-form-btn")
|
||||
|
||||
|
||||
class PlanDetailsModal(Widget):
|
||||
"""Modal for viewing plan details."""
|
||||
|
||||
def __init__(self, plan_data: Dict):
|
||||
super().__init__()
|
||||
self.plan_data = plan_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create plan details modal."""
|
||||
yield Label(f"Plan Details - Version {self.plan_data.get('version', 'N/A')}")
|
||||
|
||||
with ScrollableContainer():
|
||||
yield Label(f"Created: {self.plan_data.get('created_at', 'Unknown')[:10]}")
|
||||
|
||||
# Display plan content
|
||||
plan_content = str(self.plan_data.get('plan_data', {}))
|
||||
yield TextArea(plan_content, read_only=True, id="plan-content")
|
||||
|
||||
with Horizontal():
|
||||
yield Button("Close", id="close-modal-btn")
|
||||
yield Button("Edit Plan", id="edit-plan-btn", variant="primary")
|
||||
|
||||
|
||||
class PlanView(Widget):
|
||||
"""Training plan management view."""
|
||||
|
||||
# Reactive attributes
|
||||
plans = reactive([])
|
||||
rules = reactive([])
|
||||
loading = reactive(True)
|
||||
current_view = reactive("list") # list, generate, details
|
||||
selected_plan = reactive(None)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.plan-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
class PlanSelected(Message):
|
||||
"""Message sent when a plan is selected."""
|
||||
def __init__(self, plan_id: int):
|
||||
super().__init__()
|
||||
self.plan_id = plan_id
|
||||
|
||||
class PlanGenerated(Message):
|
||||
"""Message sent when a new plan is generated."""
|
||||
def __init__(self, plan_data: Dict):
|
||||
super().__init__()
|
||||
self.plan_data = plan_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create plan view layout."""
|
||||
yield Static("Training Plans", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="plans-loader")
|
||||
else:
|
||||
with TabbedContent():
|
||||
with TabPane("Plan List", id="plan-list-tab"):
|
||||
yield self.compose_plan_list()
|
||||
|
||||
with TabPane("Generate Plan", id="generate-plan-tab"):
|
||||
yield self.compose_plan_generator()
|
||||
|
||||
def compose_plan_list(self) -> ComposeResult:
|
||||
"""Create plan list view."""
|
||||
with Container():
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Refresh", id="refresh-plans-btn")
|
||||
yield Button("New Plan", id="new-plan-btn", variant="primary")
|
||||
|
||||
# Plans table
|
||||
plans_table = DataTable(id="plans-table")
|
||||
plans_table.add_columns("ID", "Version", "Created", "Actions")
|
||||
yield plans_table
|
||||
|
||||
def compose_plan_generator(self) -> ComposeResult:
|
||||
"""Create plan generation view."""
|
||||
with Container():
|
||||
yield PlanGenerationForm()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load plan data when mounted."""
|
||||
try:
|
||||
await self.load_plans_data()
|
||||
except Exception as e:
|
||||
self.log(f"Plans loading error: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_plans_data(self) -> None:
|
||||
"""Load plans and rules data."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
plan_service = PlanService(db)
|
||||
rule_service = RuleService(db)
|
||||
|
||||
# Load plans and rules
|
||||
self.plans = await plan_service.get_plans()
|
||||
self.rules = await rule_service.get_rules()
|
||||
|
||||
# Update loading state
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate UI elements
|
||||
await self.populate_plans_table()
|
||||
await self.populate_rules_select()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading plans data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_plans_table(self) -> None:
|
||||
"""Populate the plans table."""
|
||||
try:
|
||||
plans_table = self.query_one("#plans-table", DataTable)
|
||||
plans_table.clear()
|
||||
|
||||
for plan in self.plans:
|
||||
created_date = "Unknown"
|
||||
if plan.get("created_at"):
|
||||
created_date = plan["created_at"][:10] # Extract date part
|
||||
|
||||
plans_table.add_row(
|
||||
str(plan["id"]),
|
||||
f"v{plan['version']}",
|
||||
created_date,
|
||||
"View | Edit"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating plans table: {e}", severity="error")
|
||||
|
||||
async def populate_rules_select(self) -> None:
|
||||
"""Populate the rules select dropdown."""
|
||||
try:
|
||||
rules_select = self.query_one("#rules-select", Select)
|
||||
|
||||
# Create options from rules
|
||||
options = [(str(rule["id"]), f"{rule['name']} (v{rule['version']})")
|
||||
for rule in self.rules]
|
||||
|
||||
rules_select.set_options(options)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating rules select: {e}", severity="error")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press events."""
|
||||
try:
|
||||
if event.button.id == "refresh-plans-btn":
|
||||
await self.refresh_plans()
|
||||
elif event.button.id == "new-plan-btn":
|
||||
await self.show_plan_generator()
|
||||
elif event.button.id == "generate-plan-btn":
|
||||
await self.generate_new_plan()
|
||||
elif event.button.id == "clear-form-btn":
|
||||
await self.clear_generation_form()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Button press error: {e}", severity="error")
|
||||
|
||||
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection in plans table."""
|
||||
try:
|
||||
if event.data_table.id == "plans-table":
|
||||
# Get selected plan ID from the first column
|
||||
row_data = event.data_table.get_row(event.row_key)
|
||||
plan_id = int(row_data[0])
|
||||
|
||||
# Find the selected plan
|
||||
selected_plan = next((p for p in self.plans if p["id"] == plan_id), None)
|
||||
if selected_plan:
|
||||
self.selected_plan = selected_plan
|
||||
await self.show_plan_details(selected_plan)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Row selection error: {e}", severity="error")
|
||||
|
||||
async def refresh_plans(self) -> None:
|
||||
"""Refresh the plans list."""
|
||||
self.loading = True
|
||||
self.refresh()
|
||||
await self.load_plans_data()
|
||||
|
||||
async def show_plan_generator(self) -> None:
|
||||
"""Switch to the plan generator tab."""
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "generate-plan-tab"
|
||||
|
||||
async def show_plan_details(self, plan_data: Dict) -> None:
|
||||
"""Show detailed view of a plan."""
|
||||
# For now, just log the plan details
|
||||
# In a full implementation, this would show a modal or detailed view
|
||||
self.log(f"Showing details for plan {plan_data['id']}")
|
||||
|
||||
# Post message that plan was selected
|
||||
self.post_message(self.PlanSelected(plan_data["id"]))
|
||||
|
||||
async def generate_new_plan(self) -> None:
|
||||
"""Generate a new training plan."""
|
||||
try:
|
||||
# Get form values
|
||||
goals_input = self.query_one("#goals-input", Input)
|
||||
training_days_select = self.query_one("#training-days-select", Select)
|
||||
rules_select = self.query_one("#rules-select", Select)
|
||||
|
||||
goals_text = goals_input.value.strip()
|
||||
if not goals_text:
|
||||
self.log("Please enter training goals", severity="warning")
|
||||
return
|
||||
|
||||
# Get selected rule IDs
|
||||
selected_rule_ids = []
|
||||
if hasattr(rules_select, 'selected') and rules_select.selected:
|
||||
if isinstance(rules_select.selected, list):
|
||||
selected_rule_ids = [int(rule_id) for rule_id in rules_select.selected]
|
||||
else:
|
||||
selected_rule_ids = [int(rules_select.selected)]
|
||||
|
||||
if not selected_rule_ids:
|
||||
self.log("Please select at least one training rule", severity="warning")
|
||||
return
|
||||
|
||||
# Generate plan
|
||||
async with AsyncSessionLocal() as db:
|
||||
plan_service = PlanService(db)
|
||||
|
||||
goals_dict = {
|
||||
"description": goals_text,
|
||||
"training_days_per_week": int(training_days_select.value),
|
||||
"focus": "general_fitness"
|
||||
}
|
||||
|
||||
result = await plan_service.generate_plan(
|
||||
rule_ids=selected_rule_ids,
|
||||
goals=goals_dict
|
||||
)
|
||||
|
||||
# Add new plan to local list
|
||||
self.plans.insert(0, result["plan"])
|
||||
|
||||
# Refresh the table
|
||||
await self.populate_plans_table()
|
||||
|
||||
# Post message about new plan
|
||||
self.post_message(self.PlanGenerated(result["plan"]))
|
||||
|
||||
# Switch back to list view
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "plan-list-tab"
|
||||
|
||||
self.log(f"Successfully generated new training plan!", severity="info")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error generating plan: {e}", severity="error")
|
||||
|
||||
async def clear_generation_form(self) -> None:
|
||||
"""Clear the plan generation form."""
|
||||
try:
|
||||
self.query_one("#goals-input", Input).value = ""
|
||||
self.query_one("#training-days-select", Select).value = "4"
|
||||
# Note: Rules select clearing might need different approach depending on Textual version
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error clearing form: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
451
tui/views/routes.py
Normal file
451
tui/views/routes.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Routes view for AI Cycling Coach TUI.
|
||||
Displays GPX routes, route management, and visualization.
|
||||
"""
|
||||
import math
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import (
|
||||
Static, DataTable, Button, Input, TextArea, LoadingIndicator,
|
||||
TabbedContent, TabPane, Label, DirectoryTree
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.route_service import RouteService
|
||||
|
||||
|
||||
class GPXVisualization(Widget):
|
||||
"""ASCII-based GPX route visualization."""
|
||||
|
||||
def __init__(self, route_data: Dict):
|
||||
super().__init__()
|
||||
self.route_data = route_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create GPX visualization."""
|
||||
yield Label(f"Route: {self.route_data.get('name', 'Unknown')}")
|
||||
|
||||
# Route summary
|
||||
distance = self.route_data.get('total_distance', 0)
|
||||
elevation = self.route_data.get('elevation_gain', 0)
|
||||
|
||||
summary = f"Distance: {distance/1000:.2f} km | Elevation Gain: {elevation:.0f} m"
|
||||
yield Static(summary)
|
||||
|
||||
# ASCII route visualization
|
||||
if self.route_data.get('track_points'):
|
||||
yield self.create_route_map()
|
||||
yield self.create_elevation_profile()
|
||||
else:
|
||||
yield Static("No track data available for visualization")
|
||||
|
||||
def create_route_map(self) -> Static:
|
||||
"""Create ASCII map of the route."""
|
||||
track_points = self.route_data.get('track_points', [])
|
||||
if not track_points:
|
||||
return Static("No track points available")
|
||||
|
||||
# Extract coordinates
|
||||
lats = [float(p.get('lat', 0)) for p in track_points if p.get('lat')]
|
||||
lons = [float(p.get('lon', 0)) for p in track_points if p.get('lon')]
|
||||
|
||||
if not lats or not lons:
|
||||
return Static("Invalid coordinate data")
|
||||
|
||||
# Normalize coordinates to terminal space
|
||||
width, height = 60, 20
|
||||
|
||||
min_lat, max_lat = min(lats), max(lats)
|
||||
min_lon, max_lon = min(lons), max(lons)
|
||||
|
||||
# Avoid division by zero
|
||||
lat_range = max_lat - min_lat if max_lat != min_lat else 1
|
||||
lon_range = max_lon - min_lon if max_lon != min_lon else 1
|
||||
|
||||
# Create ASCII grid
|
||||
grid = [[' ' for _ in range(width)] for _ in range(height)]
|
||||
|
||||
# Plot route points
|
||||
for i, (lat, lon) in enumerate(zip(lats, lons)):
|
||||
x = int((lon - min_lon) / lon_range * (width - 1))
|
||||
y = int((lat - min_lat) / lat_range * (height - 1))
|
||||
|
||||
# Use different characters for start, end, and middle
|
||||
if i == 0:
|
||||
char = 'S' # Start
|
||||
elif i == len(lats) - 1:
|
||||
char = 'E' # End
|
||||
else:
|
||||
char = '●' # Route point
|
||||
|
||||
if 0 <= y < height and 0 <= x < width:
|
||||
grid[height - 1 - y][x] = char # Flip Y axis
|
||||
|
||||
# Convert grid to string
|
||||
map_lines = [''.join(row) for row in grid]
|
||||
map_text = "Route Map:\n" + '\n'.join(map_lines)
|
||||
map_text += f"\nS = Start, E = End, ● = Route"
|
||||
|
||||
return Static(map_text)
|
||||
|
||||
def create_elevation_profile(self) -> Static:
|
||||
"""Create ASCII elevation profile."""
|
||||
track_points = self.route_data.get('track_points', [])
|
||||
elevations = [float(p.get('ele', 0)) for p in track_points if p.get('ele')]
|
||||
|
||||
if not elevations:
|
||||
return Static("No elevation data available")
|
||||
|
||||
# Normalize elevation data
|
||||
width = 60
|
||||
height = 10
|
||||
|
||||
min_ele, max_ele = min(elevations), max(elevations)
|
||||
ele_range = max_ele - min_ele if max_ele != min_ele else 1
|
||||
|
||||
# Sample elevations to fit width
|
||||
if len(elevations) > width:
|
||||
step = len(elevations) // width
|
||||
elevations = elevations[::step][:width]
|
||||
|
||||
# Create elevation profile
|
||||
profile_lines = []
|
||||
for h in range(height):
|
||||
line = []
|
||||
threshold = min_ele + (height - h) / height * ele_range
|
||||
|
||||
for ele in elevations:
|
||||
if ele >= threshold:
|
||||
line.append('█')
|
||||
else:
|
||||
line.append(' ')
|
||||
profile_lines.append(''.join(line))
|
||||
|
||||
# Add elevation markers
|
||||
profile_text = f"Elevation Profile ({min_ele:.0f}m - {max_ele:.0f}m):\n"
|
||||
profile_text += '\n'.join(profile_lines)
|
||||
|
||||
return Static(profile_text)
|
||||
|
||||
|
||||
class RouteFileUpload(Widget):
|
||||
"""File upload widget for GPX files."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create file upload interface."""
|
||||
yield Label("Upload GPX Files")
|
||||
yield Button("Browse Files", id="browse-gpx-btn", variant="primary")
|
||||
yield Static("", id="upload-status")
|
||||
|
||||
# Directory tree for local file browsing
|
||||
yield Label("Or browse local files:")
|
||||
yield DirectoryTree("./data/gpx", id="gpx-directory")
|
||||
|
||||
|
||||
class RouteView(Widget):
|
||||
"""Route management and visualization view."""
|
||||
|
||||
# Reactive attributes
|
||||
routes = reactive([])
|
||||
selected_route = reactive(None)
|
||||
loading = reactive(True)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.route-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.upload-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.visualization-container {
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
min-height: 30;
|
||||
}
|
||||
"""
|
||||
|
||||
class RouteSelected(Message):
|
||||
"""Message sent when a route is selected."""
|
||||
def __init__(self, route_id: int):
|
||||
super().__init__()
|
||||
self.route_id = route_id
|
||||
|
||||
class RouteUploaded(Message):
|
||||
"""Message sent when a route is uploaded."""
|
||||
def __init__(self, route_data: Dict):
|
||||
super().__init__()
|
||||
self.route_data = route_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create route view layout."""
|
||||
yield Static("Routes & GPX Files", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="routes-loader")
|
||||
else:
|
||||
with TabbedContent():
|
||||
with TabPane("Route List", id="route-list-tab"):
|
||||
yield self.compose_route_list()
|
||||
|
||||
with TabPane("Upload GPX", id="upload-gpx-tab"):
|
||||
yield self.compose_file_upload()
|
||||
|
||||
if self.selected_route:
|
||||
with TabPane("Route Visualization", id="route-viz-tab"):
|
||||
yield self.compose_route_visualization()
|
||||
|
||||
def compose_route_list(self) -> ComposeResult:
|
||||
"""Create route list view."""
|
||||
with Container():
|
||||
with Horizontal(classes="button-row"):
|
||||
yield Button("Refresh", id="refresh-routes-btn")
|
||||
yield Button("Import GPX", id="import-gpx-btn", variant="primary")
|
||||
yield Button("Analyze Routes", id="analyze-routes-btn")
|
||||
|
||||
# Routes table
|
||||
routes_table = DataTable(id="routes-table")
|
||||
routes_table.add_columns("Name", "Distance", "Elevation", "Sections", "Actions")
|
||||
yield routes_table
|
||||
|
||||
# Route sections (if any)
|
||||
yield Static("Route Sections", classes="section-title")
|
||||
sections_table = DataTable(id="sections-table")
|
||||
sections_table.add_columns("Section", "Distance", "Grade", "Difficulty")
|
||||
yield sections_table
|
||||
|
||||
def compose_file_upload(self) -> ComposeResult:
|
||||
"""Create file upload view."""
|
||||
with Container(classes="upload-container"):
|
||||
yield RouteFileUpload()
|
||||
|
||||
def compose_route_visualization(self) -> ComposeResult:
|
||||
"""Create route visualization view."""
|
||||
if not self.selected_route:
|
||||
yield Static("No route selected")
|
||||
return
|
||||
|
||||
with Container(classes="visualization-container"):
|
||||
yield GPXVisualization(self.selected_route)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load route data when mounted."""
|
||||
try:
|
||||
await self.load_routes_data()
|
||||
except Exception as e:
|
||||
self.log(f"Routes loading error: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_routes_data(self) -> None:
|
||||
"""Load routes data."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
route_service = RouteService(db)
|
||||
self.routes = await route_service.get_routes()
|
||||
|
||||
# Update loading state
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate UI elements
|
||||
await self.populate_routes_table()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading routes data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_routes_table(self) -> None:
|
||||
"""Populate the routes table."""
|
||||
try:
|
||||
routes_table = self.query_one("#routes-table", DataTable)
|
||||
routes_table.clear()
|
||||
|
||||
for route in self.routes:
|
||||
distance_km = route.get("total_distance", 0) / 1000
|
||||
elevation_m = route.get("elevation_gain", 0)
|
||||
|
||||
routes_table.add_row(
|
||||
route.get("name", "Unknown"),
|
||||
f"{distance_km:.1f} km",
|
||||
f"{elevation_m:.0f} m",
|
||||
"0", # TODO: Count sections
|
||||
"View | Edit"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating routes table: {e}", severity="error")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press events."""
|
||||
try:
|
||||
if event.button.id == "refresh-routes-btn":
|
||||
await self.refresh_routes()
|
||||
elif event.button.id == "import-gpx-btn":
|
||||
await self.show_file_upload()
|
||||
elif event.button.id == "browse-gpx-btn":
|
||||
await self.browse_gpx_files()
|
||||
elif event.button.id == "analyze-routes-btn":
|
||||
await self.analyze_routes()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Button press error: {e}", severity="error")
|
||||
|
||||
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection in routes table."""
|
||||
try:
|
||||
if event.data_table.id == "routes-table":
|
||||
# Get route index from row selection
|
||||
row_index = event.row_key.value if hasattr(event.row_key, 'value') else event.cursor_row
|
||||
|
||||
if 0 <= row_index < len(self.routes):
|
||||
selected_route = self.routes[row_index]
|
||||
await self.show_route_visualization(selected_route)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Row selection error: {e}", severity="error")
|
||||
|
||||
async def on_directory_tree_file_selected(self, event) -> None:
|
||||
"""Handle file selection from directory tree."""
|
||||
try:
|
||||
file_path = str(event.path)
|
||||
if file_path.lower().endswith('.gpx'):
|
||||
await self.load_gpx_file(file_path)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"File selection error: {e}", severity="error")
|
||||
|
||||
async def show_route_visualization(self, route_data: Dict) -> None:
|
||||
"""Show visualization for a route."""
|
||||
try:
|
||||
# Load additional route data if needed
|
||||
async with AsyncSessionLocal() as db:
|
||||
route_service = RouteService(db)
|
||||
full_route_data = await route_service.load_gpx_file(
|
||||
route_data.get("gpx_file_path", "")
|
||||
)
|
||||
|
||||
self.selected_route = full_route_data
|
||||
self.refresh()
|
||||
|
||||
# Switch to visualization tab
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "route-viz-tab"
|
||||
|
||||
# Post message that route was selected
|
||||
self.post_message(self.RouteSelected(route_data["id"]))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error showing route visualization: {e}", severity="error")
|
||||
|
||||
async def refresh_routes(self) -> None:
|
||||
"""Refresh the routes list."""
|
||||
self.loading = True
|
||||
self.refresh()
|
||||
await self.load_routes_data()
|
||||
|
||||
async def show_file_upload(self) -> None:
|
||||
"""Switch to the file upload tab."""
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "upload-gpx-tab"
|
||||
|
||||
async def browse_gpx_files(self) -> None:
|
||||
"""Browse for GPX files."""
|
||||
try:
|
||||
# Update status
|
||||
status = self.query_one("#upload-status", Static)
|
||||
status.update("Click on a .gpx file in the directory tree below")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error browsing files: {e}", severity="error")
|
||||
|
||||
async def load_gpx_file(self, file_path: str) -> None:
|
||||
"""Load a GPX file and create route visualization."""
|
||||
try:
|
||||
self.log(f"Loading GPX file: {file_path}", severity="info")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
route_service = RouteService(db)
|
||||
route_data = await route_service.load_gpx_file(file_path)
|
||||
|
||||
# Create visualization
|
||||
self.selected_route = route_data
|
||||
self.refresh()
|
||||
|
||||
# Switch to visualization tab
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "route-viz-tab"
|
||||
|
||||
# Update upload status
|
||||
status = self.query_one("#upload-status", Static)
|
||||
status.update(f"Loaded: {route_data.get('name', 'Unknown Route')}")
|
||||
|
||||
# Post message about route upload
|
||||
self.post_message(self.RouteUploaded(route_data))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading GPX file: {e}", severity="error")
|
||||
|
||||
# Update status with error
|
||||
try:
|
||||
status = self.query_one("#upload-status", Static)
|
||||
status.update(f"Error: {str(e)}")
|
||||
except:
|
||||
pass
|
||||
|
||||
async def analyze_routes(self) -> None:
|
||||
"""Analyze all routes for insights."""
|
||||
try:
|
||||
if not self.routes:
|
||||
self.log("No routes to analyze", severity="warning")
|
||||
return
|
||||
|
||||
# Calculate route statistics
|
||||
total_distance = sum(r.get("total_distance", 0) for r in self.routes) / 1000
|
||||
total_elevation = sum(r.get("elevation_gain", 0) for r in self.routes)
|
||||
avg_distance = total_distance / len(self.routes)
|
||||
|
||||
analysis = f"""Route Analysis:
|
||||
• Total Routes: {len(self.routes)}
|
||||
• Total Distance: {total_distance:.1f} km
|
||||
• Total Elevation: {total_elevation:.0f} m
|
||||
• Average Distance: {avg_distance:.1f} km
|
||||
• Average Elevation: {total_elevation / len(self.routes):.0f} m"""
|
||||
|
||||
self.log("Route Analysis Complete", severity="info")
|
||||
self.log(analysis, severity="info")
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error analyzing routes: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
18
tui/views/rules.py
Normal file
18
tui/views/rules.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Rules view for AI Cycling Coach TUI.
|
||||
Displays training rules, rule creation and editing.
|
||||
"""
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container
|
||||
from textual.widgets import Static, Placeholder
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class RuleView(Widget):
|
||||
"""Training rules management view."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create rules view layout."""
|
||||
with Container():
|
||||
yield Static("Training Rules", classes="view-title")
|
||||
yield Placeholder("Rule creation and editing will be displayed here")
|
||||
537
tui/views/workouts.py
Normal file
537
tui/views/workouts.py
Normal file
@@ -0,0 +1,537 @@
|
||||
"""
|
||||
Workout view for AI Cycling Coach TUI.
|
||||
Displays workout list, analysis, and import functionality.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
||||
from textual.widgets import (
|
||||
Static, DataTable, Button, Input, TextArea, LoadingIndicator,
|
||||
TabbedContent, TabPane, Label, ProgressBar, Collapsible
|
||||
)
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import reactive
|
||||
from textual.message import Message
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
from backend.app.database import AsyncSessionLocal
|
||||
from tui.services.workout_service import WorkoutService
|
||||
|
||||
|
||||
class WorkoutMetricsChart(Widget):
|
||||
"""ASCII-based workout metrics visualization."""
|
||||
|
||||
def __init__(self, metrics_data: List[Dict]):
|
||||
super().__init__()
|
||||
self.metrics_data = metrics_data
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create metrics chart view."""
|
||||
if not self.metrics_data:
|
||||
yield Static("No metrics data available")
|
||||
return
|
||||
|
||||
# Create simple ASCII charts for key metrics
|
||||
yield Label("Workout Metrics Overview")
|
||||
|
||||
# Heart Rate Chart (simple bar representation)
|
||||
hr_values = [m.get("heart_rate", 0) for m in self.metrics_data if m.get("heart_rate")]
|
||||
if hr_values:
|
||||
yield self.create_ascii_chart("Heart Rate (BPM)", hr_values, max_width=50)
|
||||
|
||||
# Power Chart
|
||||
power_values = [m.get("power", 0) for m in self.metrics_data if m.get("power")]
|
||||
if power_values:
|
||||
yield self.create_ascii_chart("Power (W)", power_values, max_width=50)
|
||||
|
||||
# Speed Chart
|
||||
speed_values = [m.get("speed", 0) for m in self.metrics_data if m.get("speed")]
|
||||
if speed_values:
|
||||
yield self.create_ascii_chart("Speed (km/h)", speed_values, max_width=50)
|
||||
|
||||
def create_ascii_chart(self, title: str, values: List[float], max_width: int = 50) -> Static:
|
||||
"""Create a simple ASCII bar chart."""
|
||||
if not values:
|
||||
return Static(f"{title}: No data")
|
||||
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
avg_val = sum(values) / len(values)
|
||||
|
||||
# Create a simple representation
|
||||
chart_text = f"{title}:\n"
|
||||
chart_text += f"Min: {min_val:.1f} | Max: {max_val:.1f} | Avg: {avg_val:.1f}\n"
|
||||
|
||||
# Simple histogram representation
|
||||
if max_val > min_val:
|
||||
normalized = [(v - min_val) / (max_val - min_val) for v in values[:20]] # Take first 20 points
|
||||
chart_text += "["
|
||||
for norm_val in normalized:
|
||||
bar_length = int(norm_val * 10)
|
||||
chart_text += "█" * bar_length + "░" * (10 - bar_length) + " "
|
||||
chart_text += "]\n"
|
||||
|
||||
return Static(chart_text)
|
||||
|
||||
|
||||
class WorkoutAnalysisPanel(Widget):
|
||||
"""Panel showing AI analysis of a workout."""
|
||||
|
||||
def __init__(self, workout_data: Dict, analyses: List[Dict]):
|
||||
super().__init__()
|
||||
self.workout_data = workout_data
|
||||
self.analyses = analyses
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create analysis panel layout."""
|
||||
yield Label("AI Analysis")
|
||||
|
||||
if not self.analyses:
|
||||
yield Static("No analysis available for this workout.")
|
||||
yield Button("Analyze Workout", id="analyze-workout-btn", variant="primary")
|
||||
return
|
||||
|
||||
# Show existing analyses
|
||||
with ScrollableContainer():
|
||||
for i, analysis in enumerate(self.analyses):
|
||||
with Collapsible(title=f"Analysis {i+1} - {analysis.get('analysis_type', 'Unknown')}"):
|
||||
|
||||
# Feedback section
|
||||
feedback = analysis.get('feedback', {})
|
||||
if feedback:
|
||||
yield Label("Feedback:")
|
||||
feedback_text = self.format_feedback(feedback)
|
||||
yield TextArea(feedback_text, read_only=True)
|
||||
|
||||
# Suggestions section
|
||||
suggestions = analysis.get('suggestions', {})
|
||||
if suggestions:
|
||||
yield Label("Suggestions:")
|
||||
suggestions_text = self.format_suggestions(suggestions)
|
||||
yield TextArea(suggestions_text, read_only=True)
|
||||
|
||||
# Analysis metadata
|
||||
created_at = analysis.get('created_at', '')
|
||||
approved = analysis.get('approved', False)
|
||||
|
||||
with Horizontal():
|
||||
yield Static(f"Created: {created_at[:19] if created_at else 'Unknown'}")
|
||||
if not approved:
|
||||
yield Button("Approve", id=f"approve-analysis-{analysis['id']}", variant="success")
|
||||
|
||||
# Button to run new analysis
|
||||
yield Button("Run New Analysis", id="analyze-workout-btn", variant="primary")
|
||||
|
||||
def format_feedback(self, feedback: Dict) -> str:
|
||||
"""Format feedback dictionary as readable text."""
|
||||
if isinstance(feedback, str):
|
||||
return feedback
|
||||
|
||||
formatted = []
|
||||
for key, value in feedback.items():
|
||||
formatted.append(f"{key.replace('_', ' ').title()}: {value}")
|
||||
return "\n".join(formatted)
|
||||
|
||||
def format_suggestions(self, suggestions: Dict) -> str:
|
||||
"""Format suggestions dictionary as readable text."""
|
||||
if isinstance(suggestions, str):
|
||||
return suggestions
|
||||
|
||||
formatted = []
|
||||
for key, value in suggestions.items():
|
||||
formatted.append(f"• {key.replace('_', ' ').title()}: {value}")
|
||||
return "\n".join(formatted)
|
||||
|
||||
|
||||
class WorkoutView(Widget):
|
||||
"""Workout management view."""
|
||||
|
||||
# Reactive attributes
|
||||
workouts = reactive([])
|
||||
selected_workout = reactive(None)
|
||||
workout_analyses = reactive([])
|
||||
loading = reactive(True)
|
||||
sync_status = reactive({})
|
||||
|
||||
DEFAULT_CSS = """
|
||||
.view-title {
|
||||
text-align: center;
|
||||
color: $accent;
|
||||
text-style: bold;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.workout-column {
|
||||
width: 1fr;
|
||||
margin: 0 1;
|
||||
}
|
||||
|
||||
.sync-container {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
.metrics-container {
|
||||
border: solid $secondary;
|
||||
padding: 1;
|
||||
margin: 1 0;
|
||||
}
|
||||
"""
|
||||
|
||||
class WorkoutSelected(Message):
|
||||
"""Message sent when a workout is selected."""
|
||||
def __init__(self, workout_id: int):
|
||||
super().__init__()
|
||||
self.workout_id = workout_id
|
||||
|
||||
class AnalysisRequested(Message):
|
||||
"""Message sent when analysis is requested."""
|
||||
def __init__(self, workout_id: int):
|
||||
super().__init__()
|
||||
self.workout_id = workout_id
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Create workout view layout."""
|
||||
yield Static("Workout Management", classes="view-title")
|
||||
|
||||
if self.loading:
|
||||
yield LoadingIndicator(id="workouts-loader")
|
||||
else:
|
||||
with TabbedContent():
|
||||
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"):
|
||||
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)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Load workout data when mounted."""
|
||||
try:
|
||||
await self.load_workouts_data()
|
||||
except Exception as e:
|
||||
self.log(f"Workouts loading error: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def load_workouts_data(self) -> None:
|
||||
"""Load workouts and sync status."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
|
||||
# Load workouts and sync status
|
||||
self.workouts = await workout_service.get_workouts(limit=50)
|
||||
self.sync_status = await workout_service.get_sync_status()
|
||||
|
||||
# Update loading state
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
# Populate UI elements
|
||||
await self.populate_workouts_table()
|
||||
await self.update_sync_status()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error loading workouts data: {e}", severity="error")
|
||||
self.loading = False
|
||||
self.refresh()
|
||||
|
||||
async def populate_workouts_table(self) -> None:
|
||||
"""Populate the workouts table."""
|
||||
try:
|
||||
workouts_table = self.query_one("#workouts-table", DataTable)
|
||||
workouts_table.clear()
|
||||
|
||||
for workout in self.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"
|
||||
|
||||
workouts_table.add_row(
|
||||
date_str,
|
||||
workout.get("activity_type", "Unknown") or "Unknown",
|
||||
duration_str,
|
||||
distance_str,
|
||||
f"{workout.get('avg_hr', 'N/A')} BPM" if workout.get('avg_hr') else "N/A",
|
||||
f"{workout.get('avg_power', 'N/A')} W" if workout.get('avg_power') else "N/A",
|
||||
"View | Analyze"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error populating workouts table: {e}", severity="error")
|
||||
|
||||
async def update_sync_status(self) -> None:
|
||||
"""Update sync status display."""
|
||||
try:
|
||||
status_text = self.query_one("#sync-status-text", Static)
|
||||
|
||||
status = self.sync_status.get("status", "unknown")
|
||||
last_sync = self.sync_status.get("last_sync_time", "Never")
|
||||
activities_count = self.sync_status.get("activities_synced", 0)
|
||||
|
||||
if last_sync and last_sync != "Never":
|
||||
try:
|
||||
dt = datetime.fromisoformat(last_sync.replace('Z', '+00:00'))
|
||||
last_sync = dt.strftime("%Y-%m-%d %H:%M")
|
||||
except:
|
||||
pass
|
||||
|
||||
status_display = f"Status: {status.title()} | Last Sync: {last_sync} | Activities: {activities_count}"
|
||||
|
||||
if self.sync_status.get("error_message"):
|
||||
status_display += f" | Error: {self.sync_status['error_message']}"
|
||||
|
||||
status_text.update(status_display)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error updating sync status: {e}", severity="error")
|
||||
|
||||
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button press events."""
|
||||
try:
|
||||
if event.button.id == "refresh-workouts-btn":
|
||||
await self.refresh_workouts()
|
||||
elif event.button.id == "sync-garmin-btn":
|
||||
await self.sync_garmin_activities()
|
||||
elif event.button.id == "check-sync-btn":
|
||||
await self.check_sync_status()
|
||||
elif event.button.id == "analyze-workout-btn":
|
||||
await self.analyze_selected_workout()
|
||||
elif event.button.id.startswith("approve-analysis-"):
|
||||
analysis_id = int(event.button.id.split("-")[-1])
|
||||
await self.approve_analysis(analysis_id)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Button press error: {e}", severity="error")
|
||||
|
||||
async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
||||
"""Handle row selection in workouts table."""
|
||||
try:
|
||||
if event.data_table.id == "workouts-table":
|
||||
# Get workout index from row selection
|
||||
row_index = event.row_key.value if hasattr(event.row_key, 'value') else event.cursor_row
|
||||
|
||||
if 0 <= row_index < len(self.workouts):
|
||||
selected_workout = self.workouts[row_index]
|
||||
await self.show_workout_details(selected_workout)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Row selection error: {e}", severity="error")
|
||||
|
||||
async def show_workout_details(self, workout: Dict) -> None:
|
||||
"""Show detailed view of a workout."""
|
||||
try:
|
||||
self.selected_workout = workout
|
||||
|
||||
# Load analyses for this workout
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
self.workout_analyses = await workout_service.get_workout_analyses(workout["id"])
|
||||
|
||||
# Refresh to show the details tab
|
||||
self.refresh()
|
||||
|
||||
# Switch to details tab
|
||||
tabs = self.query_one(TabbedContent)
|
||||
tabs.active = "workout-details-tab"
|
||||
|
||||
# Post message that workout was selected
|
||||
self.post_message(self.WorkoutSelected(workout["id"]))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error showing workout details: {e}", severity="error")
|
||||
|
||||
async def refresh_workouts(self) -> None:
|
||||
"""Refresh the workouts list."""
|
||||
self.loading = True
|
||||
self.refresh()
|
||||
await self.load_workouts_data()
|
||||
|
||||
async def sync_garmin_activities(self) -> None:
|
||||
"""Sync with Garmin Connect."""
|
||||
try:
|
||||
self.log("Starting Garmin sync...", severity="info")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
result = await workout_service.sync_garmin_activities(days_back=14)
|
||||
|
||||
if result["status"] == "success":
|
||||
self.log(f"Sync completed: {result['activities_synced']} activities", severity="info")
|
||||
else:
|
||||
self.log(f"Sync failed: {result['message']}", severity="error")
|
||||
|
||||
# Refresh sync status and workouts
|
||||
await self.check_sync_status()
|
||||
await self.refresh_workouts()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error syncing Garmin activities: {e}", severity="error")
|
||||
|
||||
async def check_sync_status(self) -> None:
|
||||
"""Check current sync status."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
self.sync_status = await workout_service.get_sync_status()
|
||||
await self.update_sync_status()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error checking sync status: {e}", severity="error")
|
||||
|
||||
async def analyze_selected_workout(self) -> None:
|
||||
"""Analyze the currently selected workout."""
|
||||
if not self.selected_workout:
|
||||
self.log("No workout selected for analysis", severity="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
self.log("Starting workout analysis...", severity="info")
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
result = await workout_service.analyze_workout(self.selected_workout["id"])
|
||||
|
||||
self.log(f"Analysis completed: {result['message']}", severity="info")
|
||||
|
||||
# Reload analyses for this workout
|
||||
self.workout_analyses = await workout_service.get_workout_analyses(self.selected_workout["id"])
|
||||
self.refresh()
|
||||
|
||||
# Post message that analysis was requested
|
||||
self.post_message(self.AnalysisRequested(self.selected_workout["id"]))
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error analyzing workout: {e}", severity="error")
|
||||
|
||||
async def approve_analysis(self, analysis_id: int) -> None:
|
||||
"""Approve a workout analysis."""
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
workout_service = WorkoutService(db)
|
||||
result = await workout_service.approve_analysis(analysis_id)
|
||||
|
||||
self.log(f"Analysis approved: {result['message']}", severity="info")
|
||||
|
||||
# Reload analyses to update approval status
|
||||
if self.selected_workout:
|
||||
self.workout_analyses = await workout_service.get_workout_analyses(self.selected_workout["id"])
|
||||
self.refresh()
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error approving analysis: {e}", severity="error")
|
||||
|
||||
def watch_loading(self, loading: bool) -> None:
|
||||
"""React to loading state changes."""
|
||||
if hasattr(self, '_mounted') and self._mounted:
|
||||
self.refresh()
|
||||
Reference in New Issue
Block a user