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:
@@ -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:
|
||||||
|
|||||||
@@ -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)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 · 7d</span>
|
||||||
|
<span class="stat-tile-value">{{ summary.imported_recent }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-tile">
|
||||||
|
<span class="stat-tile-label">Success rate · 7d</span>
|
||||||
|
<span class="stat-tile-value">{% if summary.success_rate_recent is not none %}{{ "%.0f"|format(summary.success_rate_recent) }}%{% else %}–{% 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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
43
tests/web/test_dashboard_summary.py
Normal file
43
tests/web/test_dashboard_summary.py
Normal 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">–</span>' in response.text
|
||||||
Reference in New Issue
Block a user