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

@@ -5,11 +5,17 @@ means adding an entry here and nothing else. ``clubs_with_totals`` annotates in
a single query — the club list must not fan out into N+1. a single query — the club list must not fan out into N+1.
""" """
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from allauth.mfa.models import Authenticator
from django.contrib.auth import get_user_model
from django.db.models import Count, Q, Sum from django.db.models import Count, Q, Sum
from django.db.models.functions import TruncMonth
from django.utils import timezone from django.utils import timezone
from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES
from club.models import Club, ClubMembership, ClubRole, Season from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event from events.models import Event
from members.models import Member from members.models import Member
@@ -21,6 +27,10 @@ ZERO = Decimal("0.00")
PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED) PAID_STATUSES = (Order.OrderStatus.PAID, Order.OrderStatus.DELIVERED)
OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID) OWED_STATUSES = (Order.OrderStatus.PENDING, Order.OrderStatus.PARTIALLY_PAID)
#: A club with nothing scheduled inside this window has stopped using the product.
DORMANT_DAYS = 30
MONTHS_OF_HISTORY = 12
def clubs_with_totals(queryset=None): def clubs_with_totals(queryset=None):
"""Clubs annotated with headline counts (one query, no N+1).""" """Clubs annotated with headline counts (one query, no N+1)."""
@@ -42,6 +52,100 @@ def platform_totals():
} }
def clubs_without_a_season(today=None):
"""Clubs with no season covering today.
Not cosmetic: seasons scope memberships, rosters and events, so a club without
one cannot take a signup or schedule a match. It fails silently — nothing errors,
the club is simply inert — which is exactly why it belongs on a dashboard.
"""
today = today or timezone.localdate()
return Club.objects.active().exclude(seasons__start_date__lte=today, seasons__end_date__gte=today)
def dormant_clubs(days=DORMANT_DAYS):
"""Active clubs with nothing on the calendar in the next ``days``. Churn signal."""
now = timezone.now()
return Club.objects.active().exclude(events__start__gte=now, events__start__lte=now + timedelta(days=days))
def admins_pending_mfa():
"""Privileged users who have not enrolled a second factor.
They are locked out until they do (RequireMFAMiddleware redirects them to the
enrolment page), so this is a support queue rather than a statistic. The rule is
the middleware's own: platform staff, plus anyone holding an elevated ClubRole.
"""
User = get_user_model()
elevated = User.objects.filter(Q(is_staff=True) | Q(is_superuser=True) | Q(member__roles__role__in=ELEVATED_ROLES))
return elevated.exclude(pk__in=Authenticator.objects.values("user")).distinct()
def onboarding_funnel():
"""How far each active club got: created → has members → has a team → has events.
Separates working clubs from empty shells someone created and walked away from,
and shows which step people stall on.
"""
clubs = clubs_with_totals(Club.objects.active())
total = len(clubs)
return [
{"label": "Clubs", "count": total, "icon": "building-2"},
{"label": "With members", "count": sum(1 for club in clubs if club.member_count), "icon": "users"},
{"label": "With a team", "count": sum(1 for club in clubs if club.team_count), "icon": "shield"},
{"label": "With events", "count": sum(1 for club in clubs if club.event_count), "icon": "calendar-days"},
]
def flag_adoption():
"""Clubs per feature flag. `everyone` overrides club targeting, so a flag set that
way is on (or off) everywhere and its club count says nothing — hence `overridden`."""
Flag = get_waffle_flag_model()
return [{"name": flag.name, "clubs": flag.clubs.count(), "everyone": flag.everyone, "overridden": flag.everyone is not None} for flag in Flag.objects.annotate(club_total=Count("clubs")).order_by("name")]
def platform_attention():
"""The numbers that are supposed to be zero. A dashboard of healthy counts is a
dashboard nobody opens."""
members = Member.objects.count()
return {
"clubs_without_season": clubs_without_a_season().count(),
"dormant_clubs": dormant_clubs().count(),
"admins_pending_mfa": admins_pending_mfa().count(),
"outstanding": _money(Order.objects.filter(status__in=OWED_STATUSES)),
"members_without_login": Member.objects.filter(user__isnull=True).count(),
"members": members,
}
def _monthly(queryset, field, value, months=MONTHS_OF_HISTORY):
"""A dense month-by-month series — zero-filled, because a chart that silently skips
empty months draws a smooth line over a month where nothing happened."""
start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
rows = queryset.filter(**{f"{field}__gte": start}).annotate(month=TruncMonth(field)).values("month").annotate(value=value).order_by("month")
found = {row["month"].strftime("%Y-%m"): row["value"] or 0 for row in rows if row["month"]}
series, cursor = [], start
while cursor <= timezone.now():
key = cursor.strftime("%Y-%m")
series.append({"month": cursor.strftime("%b %Y"), "value": float(found.get(key, 0))})
cursor = (cursor + timedelta(days=32)).replace(day=1)
return series
def platform_charts():
return {
"signups": _monthly(ClubMembership.objects.filter(signed_up_at__isnull=False), "signed_up_at", Count("id")),
"revenue": _monthly(Order.objects.filter(status__in=PAID_STATUSES), "created", Sum("total")),
}
def _money(queryset): def _money(queryset):
return queryset.aggregate(total=Sum("total"))["total"] or ZERO return queryset.aggregate(total=Sum("total"))["total"] or ZERO

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %} {% load static lucide %}
{% block heading %}RosterChief Platform Dashboard{% endblock heading %} {% block heading %}RosterChief Platform Dashboard{% endblock heading %}
{% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %} {% block subheading %}Welcome back {{ user.member.first_name }}!{% endblock subheading %}
@@ -9,6 +9,118 @@
{% endblock actions %} {% endblock actions %}
{% block panel %} {% 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="stats mb-6 w-full bg-base-100 shadow">
<div class="stat"> <div class="stat">
<div class="stat-title">Active clubs</div> <div class="stat-title">Active clubs</div>
@@ -21,12 +133,14 @@
<div class="stat"> <div class="stat">
<div class="stat-title">Members</div> <div class="stat-title">Members</div>
<div class="stat-value">{{ totals.members }}</div> <div class="stat-value">{{ totals.members }}</div>
<div class="stat-desc">{{ attention.members_without_login }} without a login</div>
</div> </div>
<div class="stat"> <div class="stat">
<div class="stat-title">Club admins</div> <div class="stat-title">Club admins</div>
<div class="stat-value">{{ totals.admins }}</div> <div class="stat-value">{{ totals.admins }}</div>
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
<div class="card-body"> <div class="card-body">
<h2 class="card-title">Clubs</h2> <h2 class="card-title">Clubs</h2>
@@ -64,3 +178,56 @@
</div> </div>
</div> </div>
{% endblock panel %} {% 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 %}

View File

@@ -1,3 +1,4 @@
import datetime
from decimal import Decimal from decimal import Decimal
from allauth.mfa.models import Authenticator from allauth.mfa.models import Authenticator
@@ -12,13 +13,14 @@ from django.utils import timezone
from waffle import get_waffle_flag_model, get_waffle_switch_model from waffle import get_waffle_flag_model, get_waffle_switch_model
from club.models import Club, ClubMembership, ClubRole, Season from club.models import Club, ClubMembership, ClubRole, Season
from events.models import Event
from members.models import Member from members.models import Member
from shop.models import Order from shop.models import Order
from teams.models import Position, Team, TeamMembership from teams.models import Position, Team, TeamMembership
from .services.admins import grant_club_admin from .services.admins import grant_club_admin
from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access from .services.platform_admins import PlatformAdminError, is_last_superuser, set_platform_access
from .services.statistics import club_statistics, clubs_with_totals, platform_totals from .services.statistics import admins_pending_mfa, club_statistics, clubs_with_totals, clubs_without_a_season, dormant_clubs, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
from .templatetags.ui import as_alert, daisy, excluded, field_icon from .templatetags.ui import as_alert, daisy, excluded, field_icon
User = get_user_model() User = get_user_model()
@@ -553,3 +555,164 @@ class ExcludedFilterTests(TestCase):
def test_nothing_is_excluded_without_a_list(self): def test_nothing_is_excluded_without_a_list(self):
self.assertFalse(excluded(self.field("remember"), None)) self.assertFalse(excluded(self.field("remember"), None))
class PlatformAttentionTests(TestCase):
"""The numbers that are supposed to be zero."""
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.today = timezone.localdate()
def season(self, club, start, end):
return Season.objects.create(club=club, start_date=start, end_date=end)
def test_a_club_with_no_season_covering_today_is_flagged(self):
self.assertIn(self.club, clubs_without_a_season())
self.season(self.club, self.today - datetime.timedelta(days=10), self.today + datetime.timedelta(days=10))
self.assertNotIn(self.club, clubs_without_a_season())
def test_a_past_season_does_not_count_as_a_current_one(self):
self.season(self.club, self.today - datetime.timedelta(days=400), self.today - datetime.timedelta(days=40))
self.assertIn(self.club, clubs_without_a_season())
def test_an_archived_club_is_not_chased(self):
self.club.archive()
self.assertNotIn(self.club, clubs_without_a_season())
self.assertNotIn(self.club, dormant_clubs())
def test_a_club_with_nothing_scheduled_is_dormant(self):
self.assertIn(self.club, dormant_clubs())
Event.objects.create(club=self.club, title="Training", start=timezone.now() + datetime.timedelta(days=3))
self.assertNotIn(self.club, dormant_clubs())
def test_an_event_beyond_the_horizon_does_not_wake_a_club(self):
Event.objects.create(club=self.club, title="Far off", start=timezone.now() + datetime.timedelta(days=90))
self.assertIn(self.club, dormant_clubs())
def test_a_past_event_does_not_wake_a_club(self):
Event.objects.create(club=self.club, title="Gone", start=timezone.now() - datetime.timedelta(days=3))
self.assertIn(self.club, dormant_clubs())
class MfaPendingTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
def test_staff_without_a_second_factor_are_pending(self):
user = User.objects.create_user(email="staff@example.com", password="pw-secret-123", is_staff=True)
self.assertIn(user, admins_pending_mfa())
enrol_mfa(user)
self.assertNotIn(user, admins_pending_mfa())
def test_a_club_admin_without_a_second_factor_is_pending(self):
# They are locked out until they enrol, so this is a support queue.
user = User.objects.create_user(email="admin@example.com", password="pw-secret-123")
member = Member.objects.create(first_name="Ada", last_name="Lovelace", user=user)
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN)
self.assertIn(user, admins_pending_mfa())
def test_an_ordinary_member_is_not_chased(self):
user = User.objects.create_user(email="member@example.com", password="pw-secret-123")
member = Member.objects.create(first_name="Bob", last_name="Bobson", user=user)
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.MEMBER)
self.assertNotIn(user, admins_pending_mfa())
def test_each_pending_admin_is_counted_once(self):
# Two elevated roles in two clubs is still one person to chase.
other = Club.objects.create(name="Feyenoord")
user = User.objects.create_user(email="admin@example.com", password="pw-secret-123", is_staff=True)
member = Member.objects.create(first_name="Ada", last_name="Lovelace", user=user)
ClubRole.objects.create(club=self.club, member=member, role=ClubRole.Roles.ADMIN)
ClubRole.objects.create(club=other, member=member, role=ClubRole.Roles.EDITOR)
self.assertEqual(admins_pending_mfa().count(), 1)
class OnboardingFunnelTests(TestCase):
def test_the_funnel_narrows_as_clubs_stall(self):
empty = Club.objects.create(name="Empty FC") # noqa: F841 — a shell, counted only at step one
with_member = Club.objects.create(name="Members FC")
season = Season.objects.create(club=with_member, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30))
member = Member.objects.create(first_name="Ada", last_name="Lovelace")
ClubMembership.objects.create(club=with_member, season=season, member=member)
steps = {step["label"]: step["count"] for step in onboarding_funnel()}
self.assertEqual(steps["Clubs"], 2)
self.assertEqual(steps["With members"], 1)
self.assertEqual(steps["With a team"], 0)
self.assertEqual(steps["With events"], 0)
class FlagAdoptionTests(TestCase):
def setUp(self):
cache.clear()
self.addCleanup(cache.clear)
self.club = Club.objects.create(name="Ajax United")
def test_clubs_are_counted_per_flag(self):
flag = Flag.objects.create(name="shop")
flag.clubs.add(self.club)
self.assertEqual(flag_adoption(), [{"name": "shop", "clubs": 1, "everyone": None, "overridden": False}])
def test_an_everyone_flag_reports_itself_as_overridden(self):
# `everyone` beats club targeting, so the club count would be a lie.
Flag.objects.create(name="shop", everyone=True)
self.assertTrue(flag_adoption()[0]["overridden"])
class PlatformChartTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.season = Season.objects.create(club=self.club, start_date=timezone.localdate(), end_date=timezone.localdate() + datetime.timedelta(days=30))
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace")
def test_the_series_is_dense(self):
# Zero-filled: a chart that skips empty months draws a smooth line over a month
# in which nothing happened.
series = platform_charts()["signups"]
self.assertEqual(len(series), 13)
self.assertTrue(all(point["value"] == 0 for point in series))
def test_signups_land_in_the_month_they_happened(self):
ClubMembership.objects.create(club=self.club, season=self.season, member=self.member, signed_up_at=timezone.localdate())
series = platform_charts()["signups"]
self.assertEqual(series[-1]["value"], 1)
def test_only_paid_orders_count_as_revenue(self):
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("50.00"), status=Order.OrderStatus.PAID)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("30.00"), status=Order.OrderStatus.PENDING)
self.assertEqual(platform_charts()["revenue"][-1]["value"], 50.0)
self.assertEqual(platform_attention()["outstanding"], Decimal("30.00"))
class DashboardMetricsTests(ControlPanelTestBase):
def test_the_dashboard_renders_its_metrics_and_charts(self):
response = self.client.get(reverse("controlpanel:dashboard"))
self.assertContains(response, "No current season")
self.assertContains(response, "MFA pending")
self.assertContains(response, 'id="signups-chart"')
self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"])

View File

@@ -17,7 +17,7 @@ from .services.platform_admins import (
revoke_platform_access, revoke_platform_access,
set_platform_access, set_platform_access,
) )
from .services.statistics import club_statistics, clubs_with_totals, platform_totals from .services.statistics import club_statistics, clubs_with_totals, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals
Flag = get_waffle_flag_model() Flag = get_waffle_flag_model()
Switch = get_waffle_switch_model() Switch = get_waffle_switch_model()
@@ -30,6 +30,10 @@ class DashboardView(PlatformStaffRequiredMixin, TemplateView):
return super().get_context_data( return super().get_context_data(
nav="dashboard", nav="dashboard",
totals=platform_totals(), totals=platform_totals(),
attention=platform_attention(),
funnel=onboarding_funnel(),
flags=flag_adoption(),
charts=platform_charts(),
clubs=clubs_with_totals(Club.objects.active()), clubs=clubs_with_totals(Club.objects.active()),
**kwargs, **kwargs,
) )

21
package-lock.json generated
View File

@@ -5,6 +5,9 @@
"packages": { "packages": {
"": { "": {
"name": "rosterchief", "name": "rosterchief",
"dependencies": {
"chart.js": "^4.5.1"
},
"devDependencies": { "devDependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/roboto": "^5.2.10", "@fontsource-variable/roboto": "^5.2.10",
@@ -105,6 +108,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -733,6 +742,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.6.18", "version": "5.6.18",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.6.18.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.6.18.tgz",

View File

@@ -14,5 +14,8 @@
"@tailwindcss/cli": "^4.1.0", "@tailwindcss/cli": "^4.1.0",
"daisyui": "^5.0.0", "daisyui": "^5.0.0",
"tailwindcss": "^4.1.0" "tailwindcss": "^4.1.0"
},
"dependencies": {
"chart.js": "^4.5.1"
} }
} }

View File

@@ -187,6 +187,75 @@
} }
} }
@layer utilities { @layer utilities {
.tooltip {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-block;
--tt-bg: var(--color-neutral);
--tt-off: calc(100% + 0.5rem);
--tt-tail: calc(100% + 1px + 0.25rem);
--tt-tail-off: 0.5rem;
& > .tooltip-content, &[data-tip]:before {
position: absolute;
max-width: 20rem;
border-radius: var(--radius-field);
padding-inline: calc(0.25rem * 2);
padding-block: 0.25rem;
text-align: center;
white-space: normal;
color: var(--color-neutral-content);
opacity: 0%;
font-size: 0.875rem;
line-height: 1.25;
background-color: var(--tt-bg);
width: max-content;
pointer-events: none;
z-index: 2;
--tw-content: attr(data-tip);
content: var(--tw-content);
}
&:after {
opacity: 0%;
background-color: var(--tt-bg);
content: "";
pointer-events: none;
width: 0.625rem;
height: 0.25rem;
display: block;
position: absolute;
mask-repeat: no-repeat;
mask-position: -1px 0;
--mask-tooltip: url("data:image/svg+xml,%3Csvg width='10' height='4' viewBox='0 0 8 4' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.500009 1C3.5 1 3.00001 4 5.00001 4C7 4 6.5 1 9.5 1C10 1 10 0.499897 10 0H0C-1.99338e-08 0.5 0 1 0.500009 1Z' fill='black'/%3E%3C/svg%3E%0A");
mask-image: var(--mask-tooltip);
}
@media (prefers-reduced-motion: no-preference) {
& > .tooltip-content, &[data-tip]:before, &:after {
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 75ms;
}
}
&:is([data-tip]:not([data-tip=""]), :has(.tooltip-content:not(:empty))) {
&.tooltip-open, &:hover, &:has(:focus-visible) {
& > .tooltip-content, &[data-tip]:before, &:after {
opacity: 100%;
--tt-pos: 0rem;
@media (prefers-reduced-motion: no-preference) {
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1) 0s;
}
}
}
}
}
@layer daisyui.l1.l2 {
> .tooltip-content, &[data-tip]:before {
transform: translateX(var(--tt-trans, -50%)) translateY(var(--tt-pos, 0.25rem));
inset: auto auto var(--tt-off) 50%;
}
&:after {
transform: translateX(var(--tt-trans, -50%)) translateY(var(--tt-pos, 0.25rem));
inset: auto auto var(--tt-tail) 50%;
}
}
}
.tab { .tab {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
&:is(.tabs > .tab) { &:is(.tabs > .tab) {
@@ -1590,6 +1659,116 @@
} }
} }
} }
.steps {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
grid-auto-flow: column;
overflow: hidden;
overflow-x: auto;
counter-reset: step;
grid-auto-columns: 1fr;
.step {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
grid-template-columns: auto;
grid-template-rows: repeat(2, minmax(0, 1fr));
grid-template-rows: 40px 1fr;
place-items: center;
text-align: center;
min-width: 4rem;
--step-bg: var(--color-base-300);
--step-fg: var(--color-base-content);
&:before {
top: 0;
grid-column-start: 1;
grid-row-start: 1;
height: calc(0.25rem * 2);
width: 100%;
border: 1px solid;
color: var(--step-bg);
background-color: var(--step-bg);
content: "";
margin-inline-start: -100%;
}
> .step-icon, &:not(:has(.step-icon)):after {
--tw-content: counter(step);
content: var(--tw-content);
counter-increment: step;
z-index: 1;
color: var(--step-fg);
background-color: var(--step-bg);
border: 1px solid var(--step-bg);
position: relative;
grid-column-start: 1;
grid-row-start: 1;
display: grid;
height: calc(0.25rem * 8);
width: calc(0.25rem * 8);
place-items: center;
place-self: center;
border-radius: calc(infinity * 1px);
}
&:first-child:before {
--tw-content: none;
content: var(--tw-content);
}
&[data-content]:after {
--tw-content: attr(data-content);
content: var(--tw-content);
}
}
}
@layer daisyui.l1.l2 {
.step-neutral {
+ .step-neutral:before, &:after, > .step-icon {
--step-bg: var(--color-neutral);
--step-fg: var(--color-neutral-content);
}
}
.step-primary {
+ .step-primary:before, &:after, > .step-icon {
--step-bg: var(--color-primary);
--step-fg: var(--color-primary-content);
}
}
.step-secondary {
+ .step-secondary:before, &:after, > .step-icon {
--step-bg: var(--color-secondary);
--step-fg: var(--color-secondary-content);
}
}
.step-accent {
+ .step-accent:before, &:after, > .step-icon {
--step-bg: var(--color-accent);
--step-fg: var(--color-accent-content);
}
}
.step-info {
+ .step-info:before, &:after, > .step-icon {
--step-bg: var(--color-info);
--step-fg: var(--color-info-content);
}
}
.step-success {
+ .step-success:before, &:after, > .step-icon {
--step-bg: var(--color-success);
--step-fg: var(--color-success-content);
}
}
.step-warning {
+ .step-warning:before, &:after, > .step-icon {
--step-bg: var(--color-warning);
--step-fg: var(--color-warning-content);
}
}
.step-error {
+ .step-error:before, &:after, > .step-icon {
--step-bg: var(--color-error);
--step-fg: var(--color-error-content);
}
}
}
}
.select { .select {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
position: relative; position: relative;
@@ -2462,6 +2641,87 @@
} }
} }
} }
.stack\! {
@layer daisyui.l1.l2.l3 {
display: inline-grid !important;
grid-template-columns: 3px 4px 1fr 4px 3px !important;
grid-template-rows: 3px 4px 1fr 4px 3px !important;
& > * {
height: 100% !important;
width: 100% !important;
&:nth-child(n + 2) {
width: 100% !important;
opacity: 70% !important;
}
&:nth-child(2) {
z-index: 2 !important;
opacity: 90% !important;
}
&:nth-child(1) {
z-index: 3 !important;
width: 100% !important;
}
}
}
@layer daisyui.l1.l2 {
&, &.stack-bottom {
> * {
grid-column: 3 / 4 !important;
grid-row: 3 / 6 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 1 / 6 !important;
grid-row: 1 / 4 !important;
}
}
}
&.stack-top {
> * {
grid-column: 3 / 4 !important;
grid-row: 1 / 4 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 1 / 6 !important;
grid-row: 3 / 6 !important;
}
}
}
&.stack-start {
> * {
grid-column: 1 / 4 !important;
grid-row: 3 / 4 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 3 / 6 !important;
grid-row: 1 / 6 !important;
}
}
}
&.stack-end {
> * {
grid-column: 3 / 6 !important;
grid-row: 3 / 4 !important;
&:nth-child(2) {
grid-column: 2 / 5 !important;
grid-row: 2 / 5 !important;
}
&:nth-child(1) {
grid-column: 1 / 4 !important;
grid-row: 1 / 6 !important;
}
}
}
}
}
.z-10 { .z-10 {
z-index: 10; z-index: 10;
} }
@@ -2506,6 +2766,17 @@
font-weight: 800; font-weight: 800;
} }
} }
.stat-desc {
@layer daisyui.l1.l2.l3 {
grid-column-start: 1;
white-space: nowrap;
color: var(--color-base-content);
@supports (color: color-mix(in lab, red, red)) {
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
font-size: 0.75rem;
}
}
.stat-title { .stat-title {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
grid-column-start: 1; grid-column-start: 1;
@@ -2640,6 +2911,9 @@
.mt-10 { .mt-10 {
margin-top: calc(var(--spacing) * 10); margin-top: calc(var(--spacing) * 10);
} }
.mb-1 {
margin-bottom: var(--spacing);
}
.mb-2 { .mb-2 {
margin-bottom: calc(var(--spacing) * 2); margin-bottom: calc(var(--spacing) * 2);
} }
@@ -2713,6 +2987,31 @@
flex-direction: var(--tabs-direction); flex-direction: var(--tabs-direction);
} }
} }
.footer {
@layer daisyui.l1.l2.l3 {
display: grid;
width: 100%;
grid-auto-flow: row;
place-items: start;
column-gap: calc(0.25rem * 4);
row-gap: calc(0.25rem * 10);
font-size: 0.875rem;
line-height: 1.25rem;
& > *:not(script, style, template) {
display: grid;
place-items: start;
gap: calc(0.25rem * 2);
}
&.footer-center {
grid-auto-flow: column dense;
place-items: center;
text-align: center;
& > *:not(script, style, template) {
place-items: center;
}
}
}
}
.stat { .stat {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
display: inline-grid; display: inline-grid;
@@ -2877,12 +3176,18 @@
.inline-flex { .inline-flex {
display: inline-flex; display: inline-flex;
} }
.inline-grid {
display: inline-grid;
}
.table { .table {
display: table; display: table;
} }
.h-16 { .h-16 {
height: calc(var(--spacing) * 16); height: calc(var(--spacing) * 16);
} }
.h-56 {
height: calc(var(--spacing) * 56);
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -3023,6 +3328,13 @@
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse))); margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
} }
} }
.space-y-3 {
:where(& > :not(:last-child)) {
--tw-space-y-reverse: 0;
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
}
}
.divide-y { .divide-y {
:where(& > :not(:last-child)) { :where(& > :not(:last-child)) {
--tw-divide-y-reverse: 0; --tw-divide-y-reverse: 0;
@@ -3066,6 +3378,10 @@
border-bottom-style: var(--tw-border-style); border-bottom-style: var(--tw-border-style);
border-bottom-width: 1px; border-bottom-width: 1px;
} }
.border-l-4 {
border-left-style: var(--tw-border-style);
border-left-width: 4px;
}
.badge-ghost { .badge-ghost {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
border-color: var(--color-base-200); border-color: var(--color-base-200);
@@ -3092,6 +3408,12 @@
border-color: color-mix(in oklab, var(--color-base-content) 70%, transparent); border-color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
} }
} }
.border-error {
border-color: var(--color-error);
}
.border-warning {
border-color: var(--color-warning);
}
.border-white { .border-white {
border-color: var(--color-white); border-color: var(--color-white);
} }
@@ -3158,6 +3480,9 @@
--btn-shadow: 0 0 0 0 oklch(0% 0 0/0); --btn-shadow: 0 0 0 0 oklch(0% 0 0/0);
} }
} }
.mask-repeat {
mask-repeat: repeat;
}
.object-contain { .object-contain {
object-fit: contain; object-fit: contain;
} }
@@ -3170,6 +3495,17 @@
.p-4 { .p-4 {
padding: calc(var(--spacing) * 4); padding: calc(var(--spacing) * 4);
} }
.table-sm {
@layer daisyui.l1.l2 {
:not(thead, tfoot) tr {
font-size: 0.75rem;
}
:where(th, td) {
padding-inline: calc(0.25rem * 3);
padding-block: calc(0.25rem * 2);
}
}
}
.px-4 { .px-4 {
padding-inline: calc(var(--spacing) * 4); padding-inline: calc(var(--spacing) * 4);
} }
@@ -3290,6 +3626,11 @@
--alert-color: var(--color-warning); --alert-color: var(--color-warning);
} }
} }
.progress-primary {
@layer daisyui.l1.l2 {
color: var(--color-primary);
}
}
.text-base-content { .text-base-content {
color: var(--color-base-content); color: var(--color-base-content);
} }
@@ -3482,11 +3823,26 @@
} }
} }
} }
.sm\:grid-cols-2 {
@media (width >= 40rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.md\:grid-cols-2 { .md\:grid-cols-2 {
@media (width >= 48rem) { @media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-2 {
@media (width >= 64rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.lg\:grid-cols-4 {
@media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
} }
@font-face { @font-face {
font-family: "Ubuntu"; font-family: "Ubuntu";

14
static/js/chart.js Normal file

File diff suppressed because one or more lines are too long