This commit is contained in:
2025-09-12 11:08:31 -07:00
parent ad6608d00c
commit 6d0d8493aa
7 changed files with 314 additions and 135 deletions

View File

@@ -19,9 +19,21 @@ class DashboardService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
async def test_connection(self) -> None:
"""Test database connection by running a simple query."""
try:
result = await self.db.execute("SELECT 1")
return result.scalar() == 1
except Exception as e:
raise Exception(f"Database connection test failed: {str(e)}")
async def get_dashboard_data(self) -> Dict: async def get_dashboard_data(self) -> Dict:
"""Get consolidated dashboard data.""" """Get consolidated dashboard data."""
try: try:
# Test database connection first
if not await self.test_connection():
raise Exception("Database connection test failed")
# Recent workouts (last 7 days) # Recent workouts (last 7 days)
workout_result = await self.db.execute( workout_result = await self.db.execute(
select(Workout) select(Workout)
@@ -87,6 +99,10 @@ class DashboardService:
async def get_weekly_stats(self) -> Dict: async def get_weekly_stats(self) -> Dict:
"""Get weekly workout statistics.""" """Get weekly workout statistics."""
try: try:
# Test database connection first
if not await self.test_connection():
raise Exception("Database connection test failed")
week_start = datetime.now() - timedelta(days=7) week_start = datetime.now() - timedelta(days=7)
workout_result = await self.db.execute( workout_result = await self.db.execute(

View File

@@ -1,5 +1,5 @@
""" """
Dashboard view for AI Cycling Coach TUI. Fixed Dashboard view for AI Cycling Coach TUI.
Displays overview of recent workouts, plans, and key metrics. Displays overview of recent workouts, plans, and key metrics.
""" """
from datetime import datetime from datetime import datetime
@@ -8,17 +8,28 @@ from textual.containers import Container, Horizontal, Vertical, ScrollableContai
from textual.widgets import Static, DataTable, LoadingIndicator from textual.widgets import Static, DataTable, LoadingIndicator
from textual.widget import Widget from textual.widget import Widget
from textual.reactive import reactive from textual.reactive import reactive
from textual import work
from backend.app.database import AsyncSessionLocal try:
from tui.services.dashboard_service import DashboardService from backend.app.database import AsyncSessionLocal
from tui.services.dashboard_service import DashboardService
except ImportError as e:
raise ImportError(f"Critical import error: {e}. Check service dependencies.")
class DashboardView(Widget): class DashboardView(Widget):
"""Main dashboard view showing workout summary and stats.""" """Main dashboard view showing workout summary and stats."""
# Reactive attributes to store data # Reactive attributes to store data - set init=False to prevent immediate refresh
dashboard_data = reactive({}) dashboard_data = reactive({}, init=False)
loading = reactive(True) loading = reactive(True, init=False)
error_message = reactive("", init=False)
# Add unique identifier for debugging
debug_id = reactive("", init=False)
# Track if we've mounted to prevent refresh before mount
_mounted = False
DEFAULT_CSS = """ DEFAULT_CSS = """
.view-title { .view-title {
@@ -48,172 +59,297 @@ class DashboardView(Widget):
.stat-item { .stat-item {
margin: 0 1; margin: 0 1;
} }
.error-container {
border: solid $error;
padding: 1;
margin: 1 0;
color: $error;
}
.error-title {
text-style: bold;
margin-bottom: 1;
}
.error-subtitle {
text-style: underline;
margin: 1 0 0 1;
}
.error-item {
margin: 0 0 0 2;
}
.error-spacer {
height: 1;
}
.error-action {
margin-top: 1;
text-style: bold;
}
.loading-overlay {
align: right top;
offset: 1 1;
padding: 1;
background: $panel;
border: solid $primary;
}
""" """
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create dashboard layout.""" """Create dashboard layout."""
self.log(f"[DashboardView] compose called | debug_id={self.debug_id} | loading={self.loading} | error={self.error_message}")
yield Static("AI Cycling Coach Dashboard", classes="view-title") yield Static("AI Cycling Coach Dashboard", classes="view-title")
# Always show the structure - use conditional content
if self.loading: if self.error_message:
with Container(classes="error-container"):
yield Static(f"Error: {self.error_message}", classes="error-title")
yield Static("Possible causes:", classes="error-subtitle")
yield Static("- Database connection issue", classes="error-item")
yield Static("- Service dependency missing", classes="error-item")
yield Static("- Invalid configuration", classes="error-item")
yield Static("", classes="error-spacer")
yield Static("Troubleshooting steps:", classes="error-subtitle")
yield Static("- Check database configuration", classes="error-item")
yield Static("- Verify backend services are running", classes="error-item")
yield Static("- View logs for details", classes="error-item")
yield Static("", classes="error-spacer")
yield Static("Click Refresh to try again", classes="error-action")
elif self.loading and not self.dashboard_data:
# Initial load - full screen loader
yield LoadingIndicator(id="dashboard-loader") yield LoadingIndicator(id="dashboard-loader")
else: else:
with ScrollableContainer(): # Show content with optional refresh indicator
with Horizontal(): if self.loading:
# Left column - Recent workouts with Container(classes="loading-overlay"):
with Vertical(classes="dashboard-column"): yield LoadingIndicator()
yield Static("Recent Workouts", classes="section-title") yield Static("Refreshing...")
workout_table = DataTable(id="recent-workouts") yield from self._compose_dashboard_content()
workout_table.add_columns("Date", "Type", "Duration", "Distance", "Avg HR")
yield workout_table
def _compose_dashboard_content(self) -> ComposeResult:
"""Compose the main dashboard content."""
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")
# Right column - Quick stats and current plan # Active plan
with Vertical(classes="dashboard-column"): with Container(classes="stats-container"):
# Weekly stats yield Static("Current Plan", classes="section-title")
with Container(classes="stats-container"): yield Static("No active plan", id="active-plan", classes="stat-item")
yield Static("This Week", classes="section-title")
yield Static("Workouts: 0", id="week-workouts", classes="stat-item") # Sync status
yield Static("Distance: 0 km", id="week-distance", classes="stat-item") with Container(classes="stats-container"):
yield Static("Time: 0h 0m", id="week-time", classes="stat-item") yield Static("Garmin Sync", classes="section-title")
yield Static("Never synced", id="sync-status", classes="stat-item")
# Active plan yield Static("", id="last-sync", classes="stat-item")
with Container(classes="stats-container"): def on_mount(self) -> None:
yield Static("Current Plan", classes="section-title") """Load dashboard data when mounted."""
yield Static("No active plan", id="active-plan", classes="stat-item") # Generate unique debug ID for this instance
import uuid
# Sync status self.debug_id = str(uuid.uuid4())[:8]
with Container(classes="stats-container"): self.log(f"[DashboardView] on_mount called | debug_id={self.debug_id}")
yield Static("Garmin Sync", classes="section-title") self._mounted = True
yield Static("Never synced", id="sync-status", classes="stat-item")
yield Static("", id="last-sync", classes="stat-item")
async def on_mount(self) -> None: # Use @work decorator for async operations
"""Load dashboard data when mounted.""" self.load_dashboard_data()
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()
@work(exclusive=True)
async def load_dashboard_data(self) -> None: async def load_dashboard_data(self) -> None:
"""Load and display dashboard data.""" """Load and display dashboard data."""
self.log(f"[DashboardView] load_dashboard_data started | debug_id={self.debug_id}")
try: try:
self.loading = True
self.error_message = ""
try:
# Explicitly check imports again in case of lazy loading
from backend.app.database import AsyncSessionLocal
from tui.services.dashboard_service import DashboardService
except ImportError as e:
self.log(f"[DashboardView] Import error in load_dashboard_data: {e} | debug_id={self.debug_id}", severity="error")
self.error_message = f"Service dependency error: {e}"
self.loading = False
return
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
self.log(f"[DashboardView] Database session opened | debug_id={self.debug_id}")
dashboard_service = DashboardService(db) dashboard_service = DashboardService(db)
self.dashboard_data = await dashboard_service.get_dashboard_data()
# Log service initialization
self.log(f"[DashboardView] DashboardService created | debug_id={self.debug_id}")
dashboard_data = await dashboard_service.get_dashboard_data()
weekly_stats = await dashboard_service.get_weekly_stats() weekly_stats = await dashboard_service.get_weekly_stats()
# Update the reactive data and stop loading # Log data retrieval
self.loading = False self.log(f"[DashboardView] Data retrieved | debug_id={self.debug_id} | workouts={len(dashboard_data.get('recent_workouts', []))} | weekly_stats={weekly_stats}")
self.refresh()
# Populate the dashboard with data # Update reactive data
await self.populate_dashboard(weekly_stats) self.dashboard_data = dashboard_data
self.loading = False
# Force recomposition after data is loaded
await self.call_after_refresh(self.populate_dashboard, weekly_stats)
except Exception as e: except Exception as e:
self.log(f"Error loading dashboard data: {e}", severity="error") self.log(f"[DashboardView] Error loading dashboard data: {e} | debug_id={self.debug_id}", severity="error")
self.error_message = str(e)
self.loading = False self.loading = False
self.refresh() finally:
self.log(f"[DashboardView] load_dashboard_data completed | debug_id={self.debug_id}")
async def populate_dashboard(self, weekly_stats: dict) -> None: async def populate_dashboard(self, weekly_stats: dict) -> None:
"""Populate dashboard widgets with loaded data.""" """Populate dashboard widgets with loaded data."""
self.log(f"[DashboardView] populate_dashboard started | debug_id={self.debug_id}")
if self.loading or self.error_message:
self.log(f"[DashboardView] populate_dashboard skipped - loading={self.loading}, error={self.error_message} | debug_id={self.debug_id}")
return
try: try:
# Update recent workouts table # Update recent workouts table
workout_table = self.query_one("#recent-workouts", DataTable) try:
workout_table.clear() self.log(f"[DashboardView] Updating workout table | debug_id={self.debug_id}")
workout_table = self.query_one("#recent-workouts", DataTable)
for workout in self.dashboard_data.get("recent_workouts", []): workout_table.clear()
# 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 for workout in self.dashboard_data.get("recent_workouts", []):
duration = "N/A" # Format datetime for display
if workout.get("duration_seconds"): start_time = "N/A"
minutes = workout["duration_seconds"] // 60 if workout.get("start_time"):
duration = f"{minutes}min" try:
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
# Format distance start_time = dt.strftime("%m/%d %H:%M")
distance = "N/A" except Exception:
if workout.get("distance_m"): start_time = workout["start_time"][:10] # Fallback to date only
distance = f"{workout['distance_m'] / 1000:.1f}km"
# Format duration
# Format heart rate duration = "N/A"
avg_hr = workout.get("avg_hr", "N/A") if workout.get("duration_seconds"):
if avg_hr != "N/A": minutes = workout["duration_seconds"] // 60
avg_hr = f"{avg_hr}bpm" duration = f"{minutes}min"
workout_table.add_row( # Format distance
start_time, distance = "N/A"
workout.get("activity_type", "Unknown") or "Unknown", if workout.get("distance_m"):
duration, distance = f"{workout['distance_m'] / 1000:.1f}km"
distance,
str(avg_hr) # 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)
)
self.log(f"[DashboardView] Workout table updated with {len(self.dashboard_data.get('recent_workouts', []))} rows | debug_id={self.debug_id}")
except Exception as e:
self.log(f"[DashboardView] Error updating workout table: {e} | debug_id={self.debug_id}", severity="error")
# Update weekly stats # Update weekly stats
self.query_one("#week-workouts", Static).update( try:
f"Workouts: {weekly_stats.get('workout_count', 0)}" self.log(f"[DashboardView] Updating weekly stats | debug_id={self.debug_id}")
) self.query_one("#week-workouts", Static).update(
self.query_one("#week-distance", Static).update( f"Workouts: {weekly_stats.get('workout_count', 0)}"
f"Distance: {weekly_stats.get('total_distance_km', 0)}km" )
) self.query_one("#week-distance", Static).update(
self.query_one("#week-time", Static).update( f"Distance: {weekly_stats.get('total_distance_km', 0)}km"
f"Time: {weekly_stats.get('total_time_hours', 0):.1f}h" )
) self.query_one("#week-time", Static).update(
f"Time: {weekly_stats.get('total_time_hours', 0):.1f}h"
)
self.log(f"[DashboardView] Weekly stats updated | debug_id={self.debug_id}")
except Exception as e:
self.log(f"[DashboardView] Error updating stats: {e} | debug_id={self.debug_id}", severity="error")
# Update current plan # Update current plan
current_plan = self.dashboard_data.get("current_plan") try:
if current_plan: self.log(f"[DashboardView] Updating current plan | debug_id={self.debug_id}")
plan_text = f"Plan v{current_plan.get('version', 'N/A')}" current_plan = self.dashboard_data.get("current_plan")
if current_plan.get("created_at"): if current_plan:
try: plan_text = f"Plan v{current_plan.get('version', 'N/A')}"
dt = datetime.fromisoformat(current_plan["created_at"].replace('Z', '+00:00')) if current_plan.get("created_at"):
plan_text += f" ({dt.strftime('%m/%d/%Y')})" try:
except: dt = datetime.fromisoformat(current_plan["created_at"].replace('Z', '+00:00'))
pass plan_text += f" ({dt.strftime('%m/%d/%Y')})"
self.query_one("#active-plan", Static).update(plan_text) except Exception:
else: pass
self.query_one("#active-plan", Static).update("No active plan") self.query_one("#active-plan", Static).update(plan_text)
else:
self.query_one("#active-plan", Static).update("No active plan")
self.log(f"[DashboardView] Current plan updated | debug_id={self.debug_id}")
except Exception as e:
self.log(f"[DashboardView] Error updating plan: {e} | debug_id={self.debug_id}", severity="error")
# Update sync status # Update sync status
last_sync = self.dashboard_data.get("last_sync") try:
if last_sync: self.log(f"[DashboardView] Updating sync status | debug_id={self.debug_id}")
status = last_sync.get("status", "unknown") last_sync = self.dashboard_data.get("last_sync")
activities_count = last_sync.get("activities_synced", 0) if last_sync:
status = last_sync.get("status", "unknown")
self.query_one("#sync-status", Static).update( activities_count = last_sync.get("activities_synced", 0)
f"Status: {status.title()}"
) 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')) if last_sync.get("last_sync_time"):
sync_time = dt.strftime("%m/%d %H:%M") try:
self.query_one("#last-sync", Static).update( dt = datetime.fromisoformat(last_sync["last_sync_time"].replace('Z', '+00:00'))
f"Last: {sync_time} ({activities_count} activities)" sync_time = dt.strftime("%m/%d %H:%M")
) self.query_one("#last-sync", Static).update(
except: f"Last: {sync_time} ({activities_count} activities)"
self.query_one("#last-sync", Static).update( )
f"Activities: {activities_count}" except Exception:
) self.query_one("#last-sync", Static).update(
f"Activities: {activities_count}"
)
else:
self.query_one("#last-sync", Static).update("")
else: else:
self.query_one("#sync-status", Static).update("Never synced")
self.query_one("#last-sync", Static).update("") self.query_one("#last-sync", Static).update("")
else: self.log(f"[DashboardView] Sync status updated | debug_id={self.debug_id}")
self.query_one("#sync-status", Static).update("Never synced") except Exception as e:
self.query_one("#last-sync", Static).update("") self.log(f"[DashboardView] Error updating sync status: {e} | debug_id={self.debug_id}", severity="error")
except Exception as e: except Exception as e:
self.log(f"Error populating dashboard: {e}", severity="error") self.log(f"[DashboardView] Error populating dashboard: {e} | debug_id={self.debug_id}", severity="error")
self.error_message = f"Failed to populate dashboard: {e}"
finally:
self.log(f"[DashboardView] populate_dashboard completed | debug_id={self.debug_id}")
def watch_loading(self, loading: bool) -> None: def watch_loading(self, loading: bool) -> None:
"""React to loading state changes.""" """React to loading state changes."""
# Trigger recomposition when loading state changes self.log(f"[DashboardView] watch_loading: loading={loading} | debug_id={self.debug_id}")
if hasattr(self, '_mounted') and self._mounted: # Force recomposition when loading state changes
self.refresh() if self.is_mounted:
self.call_after_refresh(self._refresh_view)
def watch_error_message(self, error_message: str) -> None:
"""React to error message changes."""
self.log(f"[DashboardView] watch_error_message: error_message={error_message} | debug_id={self.debug_id}")
if self.is_mounted:
self.call_after_refresh(self._refresh_view)
async def _refresh_view(self) -> None:
"""Force view refresh by recomposing."""
self.log(f"[DashboardView] _refresh_view called | debug_id={self.debug_id}")
self.refresh(layout=True)

View File

@@ -211,16 +211,21 @@ class RuleView(Widget):
async def on_mount(self) -> None: async def on_mount(self) -> None:
"""Load rules when mounted.""" """Load rules when mounted."""
self.log("Mounting Rules view")
await self.load_rules() await self.load_rules()
async def load_rules(self) -> None: async def load_rules(self) -> None:
"""Load rules from database.""" """Load rules from database."""
self.log("Starting rules load")
self.loading = True self.loading = True
self.refresh() self.refresh()
try: try:
self.log("Creating database session")
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
self.log("Creating RuleService")
rule_service = RuleService(db) rule_service = RuleService(db)
self.log("Fetching rules from database")
self.rules = await rule_service.get_rules() self.rules = await rule_service.get_rules()
self.log(f"Loaded {len(self.rules)} rules from database") self.log(f"Loaded {len(self.rules)} rules from database")
await self.populate_rules_table() await self.populate_rules_table()
@@ -229,22 +234,26 @@ class RuleView(Widget):
self.error_message = error_msg self.error_message = error_msg
self.log(error_msg, severity="error") self.log(error_msg, severity="error")
finally: finally:
self.log("Finished loading rules")
self.loading = False self.loading = False
self.refresh() self.refresh()
async def populate_rules_table(self) -> None: async def populate_rules_table(self) -> None:
"""Populate rules table with data.""" """Populate rules table with data."""
try: try:
self.log("Querying for rules table widget")
rules_table = self.query_one("#rules-table", DataTable) rules_table = self.query_one("#rules-table", DataTable)
if not rules_table: if not rules_table:
self.log("Rules table widget not found", severity="error") self.log("Rules table widget not found", severity="error")
return return
self.log("Clearing rules table")
rules_table.clear() rules_table.clear()
self.log(f"Populating table with {len(self.rules)} rules") self.log(f"Populating table with {len(self.rules)} rules")
if not self.rules: if not self.rules:
# Add placeholder row when no rules exist # Add placeholder row when no rules exist
self.log("Adding placeholder for empty rules")
rules_table.add_row("No rules found", "", "", "", "") rules_table.add_row("No rules found", "", "", "", "")
self.log("No rules to display") self.log("No rules to display")
return return

18
tui/views/test_view.py Normal file
View File

@@ -0,0 +1,18 @@
"""
Simple test view to verify TUI framework functionality.
"""
from textual.app import ComposeResult
from textual.widgets import Static
from textual.widget import Widget
class TestView(Widget):
"""Test view with static content to verify TUI rendering."""
def compose(self) -> ComposeResult:
yield Static("TUI Framework Test", classes="view-title")
yield Static("This is a simple test view to verify TUI functionality.")
yield Static("If you see this text, the TUI framework is working correctly.")
yield Static("Check marks: [✓] Text rendering [✓] Basic layout")
def on_mount(self) -> None:
self.log("TestView mounted successfully")