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
from backend.app.database import AsyncSessionLocal
from tui.services.dashboard_service import DashboardService
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,172 +59,297 @@ 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:
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
# 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()
# 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")
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
# 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")
# 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")
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()
# 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")
def on_mount(self) -> None:
"""Load dashboard data when mounted."""
# 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
workout_table = self.query_one("#recent-workouts", DataTable)
workout_table.clear()
try:
self.log(f"[DashboardView] Updating workout table | debug_id={self.debug_id}")
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
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 Exception:
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 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 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"
# 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)
)
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
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"
)
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)}"
)
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"
)
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
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")
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')}"
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 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
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)
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")
activities_count = last_sync.get("activities_synced", 0)
self.query_one("#sync-status", Static).update(
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'))
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}"
)
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 Exception:
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("")
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")