Add dashboard summary tiles

Shows rider count, activities imported in the last 7 days, 7-day
sync success rate, and how many riders currently need attention,
right above the rider list where an admin looks first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Bastian Wagner
2026-08-16 12:48:44 +02:00
parent 9df6a619d2
commit a0890126bc
6 changed files with 232 additions and 5 deletions

View File

@@ -1,13 +1,14 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from sqlalchemy import and_, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db.models import ( from app.db.models import (
Activity, Activity,
ActivityStatus, ActivityStatus,
HealthState,
SchedulerSettings, SchedulerSettings,
SyncRun, SyncRun,
SyncRunStatus, SyncRunStatus,
@@ -19,6 +20,15 @@ from app.db.models import (
_SCHEDULER_SETTINGS_ID = 1 _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) @dataclass(frozen=True)
class UserDashboardRow: class UserDashboardRow:
id: int id: int
@@ -78,6 +88,39 @@ class UserRepository:
)) ))
return rows 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: class ActivityRepository:
def __init__(self, session: Session) -> None: def __init__(self, session: Session) -> None:

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request, status 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.admin import password_matches, require_admin
from app.auth.csrf import ensure_csrf_token, validate_csrf 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.db.repositories import ActivityRepository, SyncRunRepository, UserRepository
from app.security.credentials import CredentialCipher from app.security.credentials import CredentialCipher
from app.web.forms import UserFormData from app.web.forms import UserFormData
DASHBOARD_SUMMARY_WINDOW = timedelta(days=7)
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates")) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent / "templates"))
@@ -70,11 +73,13 @@ def login(
def dashboard(request: Request): def dashboard(request: Request):
require_admin(request) require_admin(request)
with request.app.state.session_factory() as session: 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( return templates.TemplateResponse(
request, request,
"dashboard.html", "dashboard.html",
{"rows": rows, "csrf_token": ensure_csrf_token(request)}, {"rows": rows, "summary": summary, "csrf_token": ensure_csrf_token(request)},
) )

View File

@@ -116,6 +116,44 @@ h2 {
flex-wrap: wrap; 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 { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -5,6 +5,26 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div class="stat-tiles">
<div class="stat-tile">
<span class="stat-tile-label">Riders</span>
<span class="stat-tile-value">{{ summary.rider_total }}</span>
<span class="stat-tile-sub">{{ summary.rider_enabled }} enabled</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Imported &middot; 7d</span>
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Success rate &middot; 7d</span>
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}&ndash;{% endif %}</span>
</div>
<div class="stat-tile">
<span class="stat-tile-label">Action required</span>
<span class="stat-tile-value">{{ summary.action_required_count }}</span>
</div>
</div>
<div class="page-actions"> <div class="page-actions">
<a class="btn secondary" href="/users/new">Add user</a> <a class="btn secondary" href="/users/new">Add user</a>
<form method="post" action="/sync-all" class="inline-form"> <form method="post" action="/sync-all" class="inline-form">

View File

@@ -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: 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.night_start_hour == 20
assert reloaded.day_interval_minutes == 10 assert reloaded.day_interval_minutes == 10
assert reloaded.night_interval_minutes == 45 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

View File

@@ -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 '<span class="stat-tile-value">2</span>' in response.text
assert "1 enabled" in response.text
assert '<span class="stat-tile-value">3</span>' in response.text
assert '<span class="stat-tile-value">100%</span>' in response.text
assert '<span class="stat-tile-value">1</span>' 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 '<span class="stat-tile-value">&ndash;</span>' in response.text