Build the platform metrics dashboard

The dashboard leads with the numbers that are supposed to be zero, because a
dashboard of healthy counts is one nobody opens:

- Clubs with no season covering today. Seasons scope memberships, rosters and
  events, so such a club cannot take a signup or schedule a match -- and it fails
  silently, nothing errors, it is just inert.
- Dormant clubs: nothing on the calendar for 30 days. Churn signal.
- Admins pending MFA. RequireMFAMiddleware redirects them to enrolment, so they
  are locked out of their own club until they act: a support queue, not a stat.
- Outstanding money across every club.

Then the shape of the business: an onboarding funnel (clubs → with members → with
a team → with events, which separates working clubs from shells), feature-flag
adoption per club, and two charts -- signups and revenue per month.

Charts use chart.js, self-hosted rather than pulled from a CDN, for the same
reason as the fonts: no third-party in the render path. Two things the browser
taught me: the canvas needs a height-bounded wrapper (with maintainAspectRatio
off it sizes to its parent, and a parent with no height grew it to 3489px), and
chart.js cannot read daisyUI's CSS variables, so the charts re-render on a
data-theme change or keep the light palette in dark mode.

The month series is zero-filled: a chart that skips empty months draws a smooth
line straight over a month in which nothing happened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 00:59:28 +02:00
parent 9127be0c42
commit 016206a79e
8 changed files with 835 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %}
{% load lucide %}
{% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %}
@@ -9,6 +9,118 @@
{% endblock actions %}
{% block panel %}
{% comment %}
Needs attention first: these are the numbers that are supposed to be zero. A club with
no current season cannot take a signup or schedule a match — and it fails silently,
nothing errors — while an admin without a second factor is locked out of their own
club. Both are work queues, not statistics. The vanity totals sit further down.
{% endcomment %}
<div class="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow {% if attention.clubs_without_season %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "calendar-x" size=16 %} No current season</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.clubs_without_season }}</div>
<div class="text-xs opacity-60">Clubs that cannot take signups</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.dormant_clubs %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "moon-star" size=16 %} Dormant</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.dormant_clubs }}</div>
<div class="text-xs opacity-60">Nothing scheduled in 30 days</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.admins_pending_mfa %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "shield-alert" size=16 %} MFA pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.admins_pending_mfa }}</div>
<div class="text-xs opacity-60">Admins locked out until they enrol</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "banknote" size=16 %} Outstanding</div>
<div class="text-3xl font-bold tabular-nums">€{{ attention.outstanding }}</div>
<div class="text-xs opacity-60">Unpaid across every club</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-plus" size=18 %} Signups per month</h2>
{# The wrapper's height is what bounds the canvas: with maintainAspectRatio off,
Chart.js sizes to its parent, and a parent with no height grows without end. #}
<div class="h-56">
<canvas id="signups-chart"></canvas>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "euro" size=18 %} Revenue per month</h2>
<div class="h-56">
<canvas id="revenue-chart"></canvas>
</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-2">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "milestone" size=18 %} Onboarding</h2>
<p class="text-sm opacity-70">Where clubs stall. One with no team or no events is a shell.</p>
<div class="mt-2 space-y-3">
{% for step in funnel %}
<div>
<div class="mb-1 flex items-center justify-between text-sm">
<span class="flex items-center gap-2">{% lucide step.icon size=14 %} {{ step.label }}</span>
<span class="font-semibold tabular-nums">{{ step.count }}</span>
</div>
<progress class="progress progress-primary w-full" value="{{ step.count }}" max="{{ funnel.0.count }}"></progress>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center justify-between">
<h2 class="card-title text-base">{% lucide "toggle-right" size=18 %} Feature adoption</h2>
<a class="btn btn-ghost btn-xs" href="{% url 'controlpanel:features' %}">Manage</a>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<tbody>
{% for flag in flags %}
<tr>
<td class="font-mono font-medium">{{ flag.name }}</td>
<td class="text-right">
{% if flag.overridden %}
{# `everyone` overrides club targeting, so the club count says nothing here. #}
<span class="badge badge-sm {% if flag.everyone %}badge-success{% else %}badge-error{% endif %}">
{% if flag.everyone %}On for all{% else %}Off everywhere{% endif %}
</span>
{% else %}
<span class="tabular-nums">{{ flag.clubs }} / {{ totals.clubs }} clubs</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td class="text-center opacity-60">No features yet.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="stats mb-6 w-full bg-base-100 shadow">
<div class="stat">
<div class="stat-title">Active clubs</div>
@@ -21,12 +133,14 @@
<div class="stat">
<div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div>
<div class="stat-desc">{{ attention.members_without_login }} without a login</div>
</div>
<div class="stat">
<div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title">Clubs</h2>
@@ -64,3 +178,56 @@
</div>
</div>
{% endblock panel %}
{% block extra_body %}
{{ charts|json_script:"chart-data" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(() => {
const data = JSON.parse(document.getElementById("chart-data").textContent);
// Chart.js paints to a canvas, so it cannot inherit daisyUI's colours the way the
// rest of the page does — they are CSS variables. Read the computed values, and
// rebuild when the theme attribute changes, or the charts keep the light palette
// after a switch to dark.
const css = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
const render = () => {
const ink = css("--color-base-content", "#333");
const grid = "color-mix(in oklab, " + ink + " 15%, transparent)";
const build = (id, label, series, colour, type) =>
new Chart(document.getElementById(id), {
type,
data: {
labels: series.map((point) => point.month),
datasets: [{ label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3 }],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: ink }, grid: { color: grid } },
y: { beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } },
},
},
});
return [
build("signups-chart", "Signups", data.signups, css("--color-primary", "#4f46e5"), "bar"),
build("revenue-chart", "Revenue", data.revenue, css("--color-accent", "#0ea5e9"), "line"),
];
};
let charts = render();
// The theme toggle sets data-theme on <html>; "auto" removes it entirely, so watch
// the attribute rather than listening for a click.
new MutationObserver(() => {
charts.forEach((chart) => chart.destroy());
charts = render();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
})();
</script>
{% endblock extra_body %}