diff --git a/app/db/repositories.py b/app/db/repositories.py index eaba7b7..7344573 100644 --- a/app/db/repositories.py +++ b/app/db/repositories.py @@ -1,13 +1,14 @@ from dataclasses import dataclass from datetime import datetime -from sqlalchemy import and_, or_, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.db.models import ( Activity, ActivityStatus, + HealthState, SchedulerSettings, SyncRun, SyncRunStatus, @@ -19,6 +20,15 @@ from app.db.models import ( _SCHEDULER_SETTINGS_ID = 1 +@dataclass(frozen=True) +class DashboardSummary: + rider_total: int + rider_enabled: int + imported_recent: int + success_rate_recent: float | None + action_required_count: int + + @dataclass(frozen=True) class UserDashboardRow: id: int @@ -78,6 +88,39 @@ class UserRepository: )) return rows + def dashboard_summary(self, *, since: datetime) -> DashboardSummary: + rider_total = self.session.scalar(select(func.count()).select_from(SyncUser)) or 0 + rider_enabled = self.session.scalar( + select(func.count()).select_from(SyncUser).where(SyncUser.enabled.is_(True)) + ) or 0 + action_required_count = self.session.scalar( + select(func.count()).select_from(SyncUser).where(SyncUser.health_state == HealthState.ACTION_REQUIRED) + ) or 0 + imported_recent = self.session.scalar( + select(func.coalesce(func.sum(SyncRun.imported_count), 0)).where(SyncRun.started_at >= since) + ) or 0 + + finished_runs = list( + self.session.scalars( + select(SyncRun).where(SyncRun.started_at >= since, SyncRun.finished_at.is_not(None)) + ) + ) + if finished_runs: + successful = sum( + 1 for run in finished_runs if run.status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL) + ) + success_rate_recent = (successful / len(finished_runs)) * 100 + else: + success_rate_recent = None + + return DashboardSummary( + rider_total=rider_total, + rider_enabled=rider_enabled, + imported_recent=imported_recent, + success_rate_recent=success_rate_recent, + action_required_count=action_required_count, + ) + class ActivityRepository: def __init__(self, session: Session) -> None: diff --git a/app/web/routes.py b/app/web/routes.py index 54aad43..3b34b8b 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -1,3 +1,4 @@ +from datetime import timedelta from pathlib import Path from fastapi import APIRouter, Form, HTTPException, Request, status @@ -6,11 +7,13 @@ from fastapi.templating import Jinja2Templates from app.auth.admin import password_matches, require_admin from app.auth.csrf import ensure_csrf_token, validate_csrf -from app.db.models import SyncUser +from app.db.models import SyncUser, utcnow from app.db.repositories import ActivityRepository, SyncRunRepository, UserRepository from app.security.credentials import CredentialCipher from app.web.forms import UserFormData +DASHBOARD_SUMMARY_WINDOW = timedelta(days=7) + router = APIRouter() templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) @@ -70,11 +73,13 @@ def login( def dashboard(request: Request): require_admin(request) with request.app.state.session_factory() as session: - rows = UserRepository(session).dashboard_rows() + repository = UserRepository(session) + rows = repository.dashboard_rows() + summary = repository.dashboard_summary(since=utcnow() - DASHBOARD_SUMMARY_WINDOW) return templates.TemplateResponse( request, "dashboard.html", - {"rows": rows, "csrf_token": ensure_csrf_token(request)}, + {"rows": rows, "summary": summary, "csrf_token": ensure_csrf_token(request)}, ) diff --git a/app/web/static/style.css b/app/web/static/style.css index 27edfbd..5299fd4 100644 --- a/app/web/static/style.css +++ b/app/web/static/style.css @@ -116,6 +116,44 @@ h2 { flex-wrap: wrap; } +.stat-tiles { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.stat-tile { + display: flex; + flex-direction: column; + gap: 0.3rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 0.9rem 1.1rem; +} + +.stat-tile-label { + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.stat-tile-value { + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-size: 1.6rem; + font-weight: 700; + color: var(--accent); +} + +.stat-tile-sub { + color: var(--text-muted); + font-size: 0.78rem; +} + .card { background: var(--surface); border: 1px solid var(--border); diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index 7b700e1..53e116c 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -5,6 +5,26 @@ {% block content %}

Dashboard

+
+
+ Riders + {{ summary.rider_total }} + {{ summary.rider_enabled }} enabled +
+
+ Imported · 7d + {{ summary.imported_recent }} +
+
+ Success rate · 7d + {% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}–{% endif %} +
+
+ Action required + {{ summary.action_required_count }} +
+
+
Add user
diff --git a/tests/db/test_repositories.py b/tests/db/test_repositories.py index 370049c..3348c89 100644 --- a/tests/db/test_repositories.py +++ b/tests/db/test_repositories.py @@ -1,4 +1,6 @@ -from app.db.models import ActivityStatus, HealthState +from datetime import datetime, timedelta, timezone + +from app.db.models import ActivityStatus, HealthState, SyncRunStatus def test_create_two_independent_users(db_session, user_repository) -> None: @@ -129,3 +131,79 @@ def test_scheduler_settings_update_persists_all_fields(scheduler_settings_reposi assert reloaded.night_start_hour == 20 assert reloaded.day_interval_minutes == 10 assert reloaded.night_interval_minutes == 45 + + +def _make_user(repo, name, *, enabled=True, health_state=HealthState.HEALTHY): + return repo.create( + name=name, + enabled=enabled, + health_state=health_state, + mywhoosh_email_enc=f"mw-{name}", + mywhoosh_password_enc=f"mw-pw-{name}", + garmin_email_enc=f"g-{name}", + garmin_password_enc=f"g-pw-{name}", + ) + + +def test_dashboard_summary_counts_riders_total_and_enabled(user_repository) -> None: + _make_user(user_repository, "Alex", enabled=True) + _make_user(user_repository, "Jamie", enabled=True) + _make_user(user_repository, "Paused", enabled=False) + + summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7)) + + assert summary.rider_total == 3 + assert summary.rider_enabled == 2 + + +def test_dashboard_summary_counts_action_required_riders(user_repository) -> None: + _make_user(user_repository, "Healthy", health_state=HealthState.HEALTHY) + _make_user(user_repository, "Blocked", health_state=HealthState.ACTION_REQUIRED) + _make_user(user_repository, "AlsoBlocked", health_state=HealthState.ACTION_REQUIRED) + + summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7)) + + assert summary.action_required_count == 2 + + +def test_dashboard_summary_sums_imported_count_within_window_only(user_repository, sync_run_repository) -> None: + user = _make_user(user_repository, "Alex") + now = datetime.now(timezone.utc) + + recent_run = sync_run_repository.start(user.id) + recent_run.started_at = now - timedelta(days=1) + sync_run_repository.finish( + recent_run.id, status=SyncRunStatus.SUCCESS, discovered=5, imported=5, skipped=0, failed=0 + ) + + old_run = sync_run_repository.start(user.id) + old_run.started_at = now - timedelta(days=30) + sync_run_repository.finish( + old_run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0 + ) + + summary = user_repository.dashboard_summary(since=now - timedelta(days=7)) + + assert summary.imported_recent == 5 + + +def test_dashboard_summary_computes_success_rate_from_finished_runs_in_window( + user_repository, sync_run_repository +) -> None: + user = _make_user(user_repository, "Alex") + now = datetime.now(timezone.utc) + + for status in (SyncRunStatus.SUCCESS, SyncRunStatus.PARTIAL, SyncRunStatus.FAILED, SyncRunStatus.FAILED): + run = sync_run_repository.start(user.id) + run.started_at = now - timedelta(hours=1) + sync_run_repository.finish(run.id, status=status, discovered=1, imported=0, skipped=0, failed=1) + + summary = user_repository.dashboard_summary(since=now - timedelta(days=7)) + + assert summary.success_rate_recent == 50.0 + + +def test_dashboard_summary_success_rate_is_none_without_finished_runs_in_window(user_repository) -> None: + summary = user_repository.dashboard_summary(since=datetime.now(timezone.utc) - timedelta(days=7)) + + assert summary.success_rate_recent is None diff --git a/tests/web/test_dashboard_summary.py b/tests/web/test_dashboard_summary.py new file mode 100644 index 0000000..33d3536 --- /dev/null +++ b/tests/web/test_dashboard_summary.py @@ -0,0 +1,43 @@ +from datetime import datetime, timedelta, timezone + +from app.db.models import HealthState, SyncRunStatus +from app.db.repositories import SyncRunRepository, UserRepository + + +def _seed_user(session, name, *, enabled=True, health_state=HealthState.HEALTHY): + return UserRepository(session).create( + name=name, + enabled=enabled, + health_state=health_state, + mywhoosh_email_enc=f"mw-{name}", + mywhoosh_password_enc=f"mw-pw-{name}", + garmin_email_enc=f"g-{name}", + garmin_password_enc=f"g-pw-{name}", + ) + + +def test_dashboard_shows_summary_tiles(app, authenticated_client) -> None: + with app.state.session_factory() as session: + alex = _seed_user(session, "Alex", enabled=True) + _seed_user(session, "Jamie", enabled=False, health_state=HealthState.ACTION_REQUIRED) + + run_repo = SyncRunRepository(session) + run = run_repo.start(alex.id) + run.started_at = datetime.now(timezone.utc) - timedelta(hours=2) + run_repo.finish(run.id, status=SyncRunStatus.SUCCESS, discovered=3, imported=3, skipped=0, failed=0) + + response = authenticated_client.get("/") + + assert response.status_code == 200 + assert '2' in response.text + assert "1 enabled" in response.text + assert '3' in response.text + assert '100%' in response.text + assert '1' in response.text + + +def test_dashboard_shows_dash_for_success_rate_without_recent_runs(authenticated_client) -> None: + response = authenticated_client.get("/") + + assert response.status_code == 200 + assert '' in response.text