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):
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:
"""Get consolidated dashboard data."""
try:
# Test database connection first
if not await self.test_connection():
raise Exception("Database connection test failed")
# Recent workouts (last 7 days)
workout_result = await self.db.execute(
select(Workout)
@@ -87,6 +99,10 @@ class DashboardService:
async def get_weekly_stats(self) -> Dict:
"""Get weekly workout statistics."""
try:
# Test database connection first
if not await self.test_connection():
raise Exception("Database connection test failed")
week_start = datetime.now() - timedelta(days=7)
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.
"""
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.widget import Widget
from textual.reactive import reactive
from textual import work
try:
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):
"""Main dashboard view showing workout summary and stats."""
# Reactive attributes to store data
dashboard_data = reactive({})
loading = reactive(True)
# Reactive attributes to store data - set init=False to prevent immediate refresh
dashboard_data = reactive({}, init=False)
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 = """
.view-title {
@@ -48,15 +59,74 @@ class DashboardView(Widget):
.stat-item {
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:
"""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")
if self.loading:
# Always show the structure - use conditional content
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")
else:
# Show content with optional refresh indicator
if self.loading:
with Container(classes="loading-overlay"):
yield LoadingIndicator()
yield Static("Refreshing...")
yield from self._compose_dashboard_content()
def _compose_dashboard_content(self) -> ComposeResult:
"""Compose the main dashboard content."""
with ScrollableContainer():
with Horizontal():
# Left column - Recent workouts
@@ -85,41 +155,74 @@ class DashboardView(Widget):
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:
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()
# Generate unique debug ID for this instance
import uuid
self.debug_id = str(uuid.uuid4())[:8]
self.log(f"[DashboardView] on_mount called | debug_id={self.debug_id}")
self._mounted = True
# Use @work decorator for async operations
self.load_dashboard_data()
@work(exclusive=True)
async def load_dashboard_data(self) -> None:
"""Load and display dashboard data."""
self.log(f"[DashboardView] load_dashboard_data started | debug_id={self.debug_id}")
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:
self.log(f"[DashboardView] Database session opened | debug_id={self.debug_id}")
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()
# Update the reactive data and stop loading
self.loading = False
self.refresh()
# Log data retrieval
self.log(f"[DashboardView] Data retrieved | debug_id={self.debug_id} | workouts={len(dashboard_data.get('recent_workouts', []))} | weekly_stats={weekly_stats}")
# Populate the dashboard with data
await self.populate_dashboard(weekly_stats)
# Update reactive data
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:
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.refresh()
finally:
self.log(f"[DashboardView] load_dashboard_data completed | debug_id={self.debug_id}")
async def populate_dashboard(self, weekly_stats: dict) -> None:
"""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:
# Update recent workouts table
try:
self.log(f"[DashboardView] Updating workout table | debug_id={self.debug_id}")
workout_table = self.query_one("#recent-workouts", DataTable)
workout_table.clear()
@@ -130,7 +233,7 @@ class DashboardView(Widget):
try:
dt = datetime.fromisoformat(workout["start_time"].replace('Z', '+00:00'))
start_time = dt.strftime("%m/%d %H:%M")
except:
except Exception:
start_time = workout["start_time"][:10] # Fallback to date only
# Format duration
@@ -156,8 +259,13 @@ class DashboardView(Widget):
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
try:
self.log(f"[DashboardView] Updating weekly stats | debug_id={self.debug_id}")
self.query_one("#week-workouts", Static).update(
f"Workouts: {weekly_stats.get('workout_count', 0)}"
)
@@ -167,8 +275,13 @@ class DashboardView(Widget):
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
try:
self.log(f"[DashboardView] Updating current plan | debug_id={self.debug_id}")
current_plan = self.dashboard_data.get("current_plan")
if current_plan:
plan_text = f"Plan v{current_plan.get('version', 'N/A')}"
@@ -176,13 +289,18 @@ class DashboardView(Widget):
try:
dt = datetime.fromisoformat(current_plan["created_at"].replace('Z', '+00:00'))
plan_text += f" ({dt.strftime('%m/%d/%Y')})"
except:
except Exception:
pass
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
try:
self.log(f"[DashboardView] Updating sync status | debug_id={self.debug_id}")
last_sync = self.dashboard_data.get("last_sync")
if last_sync:
status = last_sync.get("status", "unknown")
@@ -199,7 +317,7 @@ class DashboardView(Widget):
self.query_one("#last-sync", Static).update(
f"Last: {sync_time} ({activities_count} activities)"
)
except:
except Exception:
self.query_one("#last-sync", Static).update(
f"Activities: {activities_count}"
)
@@ -208,12 +326,30 @@ class DashboardView(Widget):
else:
self.query_one("#sync-status", Static).update("Never synced")
self.query_one("#last-sync", Static).update("")
self.log(f"[DashboardView] Sync status updated | debug_id={self.debug_id}")
except Exception as e:
self.log(f"[DashboardView] Error updating sync status: {e} | debug_id={self.debug_id}", severity="error")
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:
"""React to loading state changes."""
# Trigger recomposition when loading state changes
if hasattr(self, '_mounted') and self._mounted:
self.refresh()
self.log(f"[DashboardView] watch_loading: loading={loading} | debug_id={self.debug_id}")
# Force recomposition when loading state changes
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:
"""Load rules when mounted."""
self.log("Mounting Rules view")
await self.load_rules()
async def load_rules(self) -> None:
"""Load rules from database."""
self.log("Starting rules load")
self.loading = True
self.refresh()
try:
self.log("Creating database session")
async with AsyncSessionLocal() as db:
self.log("Creating RuleService")
rule_service = RuleService(db)
self.log("Fetching rules from database")
self.rules = await rule_service.get_rules()
self.log(f"Loaded {len(self.rules)} rules from database")
await self.populate_rules_table()
@@ -229,22 +234,26 @@ class RuleView(Widget):
self.error_message = error_msg
self.log(error_msg, severity="error")
finally:
self.log("Finished loading rules")
self.loading = False
self.refresh()
async def populate_rules_table(self) -> None:
"""Populate rules table with data."""
try:
self.log("Querying for rules table widget")
rules_table = self.query_one("#rules-table", DataTable)
if not rules_table:
self.log("Rules table widget not found", severity="error")
return
self.log("Clearing rules table")
rules_table.clear()
self.log(f"Populating table with {len(self.rules)} rules")
if not self.rules:
# Add placeholder row when no rules exist
self.log("Adding placeholder for empty rules")
rules_table.add_row("No rules found", "", "", "", "")
self.log("No rules to display")
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")