Build the club metrics

The club page now leads with its own numbers that should be zero, then the health
signals underneath.

- Teams with no coach. Not a statistic but a defect in the club's setup: with
  nobody in a management position the access service grants no authority over that
  team, so nobody can pick the squad. A physio does not count -- the query keys on
  Position.management_position, and on this season only.
- Unrostered members: active, paid, and on no team.
- Unpaid money bucketed by age. "€250 overdue past 60 days" drives a phone call;
  "€250 outstanding" does not.
- Renewal rate -- last season's actives who signed up again. Exactly computable
  because memberships are season-scoped.
- Turnout, plus the share who never responded. Silence is not an absence, so it is
  excluded from turnout and reported separately: no-response is the leading
  indicator, since it measures whether members use the app at all.

Two of these return None rather than a number, deliberately: a club in its first
season has not failed to renew anyone, and a season with no past events has no
turnout. Rendering either as 0% would libel the club, so the page says why instead.

Money is pinned to two decimals -- SQLite's Sum() drops trailing zeros, so an
aggregate rendered "€250" next to a "€0.00" constant on the same card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 01:08:37 +02:00
parent 21d3947d08
commit f2b78bb1dd
6 changed files with 508 additions and 18 deletions

View File

@@ -17,7 +17,7 @@ from waffle import get_waffle_flag_model
from authentication.middleware import ELEVATED_ROLES 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 Attendance, Event
from members.models import Member from members.models import Member
from shop.models import Cart, Order from shop.models import Cart, Order
from teams.models import StaffAssignment, Team, TeamMembership from teams.models import StaffAssignment, Team, TeamMembership
@@ -150,6 +150,138 @@ def _money(queryset):
return queryset.aggregate(total=Sum("total"))["total"] or ZERO return queryset.aggregate(total=Sum("total"))["total"] or ZERO
def previous_season(club, season):
"""The season immediately before ``season``. Seasons are ordered by name (which is
derived from the years), so go by the date instead — a club may skip a year."""
if season is None:
return None
return Season.objects.filter(club=club, end_date__lt=season.start_date).order_by("-end_date").first()
def renewal_rate(club, season):
"""Share of last season's active members who signed up again.
The single best health signal a club has, and it is exactly computable here because
memberships are season-scoped. Returns None when there is no season to compare
against — a first-season club has not failed to renew anyone, and rendering that as
0% would libel it.
"""
previous = previous_season(club, season)
if previous is None:
return None
was_active = ClubMembership.objects.filter(club=club, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
total = was_active.count()
if not total:
return None
returned = ClubMembership.objects.filter(club=club, season=season, member__in=was_active.values("member")).count()
return round(100 * returned / total)
def teams_without_a_manager(club, season):
"""Teams with nobody in a management position this season.
A defect in the club's own setup, not a statistic: without a coach or manager the
access service grants nobody authority over that team, so nobody can pick the squad.
"""
if season is None:
return Team.objects.none()
return Team.objects.filter(club=club).exclude(staff_assignments__season=season, staff_assignments__position__management_position=True)
def unrostered_members(club, season):
"""Active members who are on no team this season — people who paid and play nowhere."""
if season is None:
return Member.objects.none()
rostered = TeamMembership.objects.filter(team__club=club, season=season).values("member")
return Member.objects.filter(member_of__club=club, member_of__season=season, member_of__status=ClubMembership.StatusChoices.ACTIVE).exclude(pk__in=rostered).distinct()
def fee_aging(club):
"""Unpaid orders bucketed by age. "€2,400 overdue past 60 days" drives a phone call;
"€2,400 outstanding" does not."""
now = timezone.now()
owed = Order.objects.filter(club=club, status__in=OWED_STATUSES)
buckets = []
for label, older_than, newer_than in (("0-30 days", 0, 30), ("30-60 days", 30, 60), ("60+ days", 60, None)):
rows = owed.filter(created__lte=now - timedelta(days=older_than))
if newer_than is not None:
rows = rows.filter(created__gt=now - timedelta(days=newer_than))
buckets.append({"label": label, "total": _money(rows), "count": rows.count(), "overdue": newer_than is None})
return buckets
def attendance_rates(club, season):
"""Turnout, and how many never answered.
The no-response share is the leading indicator: it measures whether members are using
the app at all, which every other number here depends on.
"""
if season is None:
return {"turnout": None, "no_response": None, "responses": 0}
counts = Attendance.objects.filter(event__club=club, event__season=season, event__start__lt=timezone.now()).aggregate(
present=Count("id", filter=Q(status=Attendance.AttendanceStatus.PRESENT)),
absent=Count("id", filter=Q(status=Attendance.AttendanceStatus.ABSENT)),
silent=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)),
total=Count("id"),
)
answered = counts["present"] + counts["absent"]
return {
"turnout": round(100 * counts["present"] / answered) if answered else None,
"no_response": round(100 * counts["silent"] / counts["total"]) if counts["total"] else None,
"responses": counts["total"],
}
def club_attention(club):
"""A club's own numbers that are supposed to be zero."""
season = Season.covering(club, timezone.localdate())
memberships = ClubMembership.objects.filter(club=club)
return {
"season": season,
"no_season": season is None,
"outstanding": _money(Order.objects.filter(club=club, status__in=OWED_STATUSES)),
"aging": fee_aging(club),
"unpaid_members": memberships.filter(season=season, fee_status=ClubMembership.FeeStatus.UNPAID).count() if season else 0,
"pending_approvals": memberships.filter(status=ClubMembership.StatusChoices.PENDING).count(),
"teams_without_manager": teams_without_a_manager(club, season).count(),
"unrostered": unrostered_members(club, season).count(),
"renewal_rate": renewal_rate(club, season),
"attendance": attendance_rates(club, season),
}
def club_charts(club):
season = Season.covering(club, timezone.localdate())
memberships = ClubMembership.objects.filter(club=club, season=season) if season else ClubMembership.objects.none()
return {
"signups": _monthly(ClubMembership.objects.filter(club=club, signed_up_at__isnull=False), "signed_up_at", Count("id")),
# Fee status this season, in the order a treasurer cares about.
"fees": [
{"label": label, "value": memberships.filter(fee_status=status).count()}
for status, label in (
(ClubMembership.FeeStatus.PAID, "Paid"),
(ClubMembership.FeeStatus.PARTIALLY_PAID, "Partial"),
(ClubMembership.FeeStatus.UNPAID, "Unpaid"),
(ClubMembership.FeeStatus.WAIVED, "Waived"),
)
],
}
def club_statistics(club): def club_statistics(club):
"""Stat groups for one club. Add new groups here as the domain grows.""" """Stat groups for one club. Add new groups here as the domain grows."""
season = Season.covering(club, timezone.localdate()) season = Season.covering(club, timezone.localdate())

View File

@@ -1,5 +1,5 @@
{% extends "controlpanel/base.html" %} {% extends "controlpanel/base.html" %}
{% load lucide %} {% load static lucide %}
{% block heading %}{{ club.name }}{% endblock heading %} {% block heading %}{{ club.name }}{% endblock heading %}
@@ -33,6 +33,120 @@
<span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span> <span>This club is archived: its subdomain no longer resolves. Nothing has been deleted — restore it to bring it back.</span>
</div> </div>
{% endif %} {% endif %}
{% if attention.no_season %}
<div class="alert alert-warning mb-6">
{% lucide "calendar-x" size=20 %}
<span>
No season covers today, so this club cannot take a signup or schedule a match. Nothing errors — it is simply inert.
</span>
</div>
{% endif %}
{% comment %}
The club's own numbers that should be zero. Teams without a manager is a defect in
the club's setup, not a statistic: with nobody in a management position the access
service grants no authority over that team, so nobody can pick the squad.
{% 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.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|floatformat:2 }}</div>
<div class="text-xs opacity-60">{{ attention.unpaid_members }} member{{ attention.unpaid_members|pluralize }} unpaid this season</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.teams_without_manager %}border-l-4 border-error{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-x" size=16 %} No coach</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.teams_without_manager }}</div>
<div class="text-xs opacity-60">Teams nobody can pick a squad for</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.unrostered %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "user-minus" size=16 %} Unrostered</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.unrostered }}</div>
<div class="text-xs opacity-60">Active members on no team</div>
</div>
</div>
<div class="card bg-base-100 shadow {% if attention.pending_approvals %}border-l-4 border-warning{% endif %}">
<div class="card-body p-4">
<div class="flex items-center gap-2 text-sm opacity-70">{% lucide "clock" size=16 %} Pending</div>
<div class="text-3xl font-bold tabular-nums">{{ attention.pending_approvals }}</div>
<div class="text-xs opacity-60">Memberships awaiting approval</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 lg:grid-cols-3">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2>
{% if attention.renewal_rate is None %}
{# No prior season to compare against: a first-season club has not failed to renew anyone. #}
<p class="text-sm opacity-60">No previous season to compare against yet.</p>
{% else %}
<div class="text-4xl font-bold tabular-nums">{{ attention.renewal_rate }}%</div>
<p class="text-sm opacity-70">of last season's active members signed up again</p>
<progress class="progress progress-primary w-full" value="{{ attention.renewal_rate }}" max="100"></progress>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "user-check" size=18 %} Attendance</h2>
{% if attention.attendance.turnout is None %}
<p class="text-sm opacity-60">No past events with responses this season.</p>
{% else %}
<div class="text-4xl font-bold tabular-nums">{{ attention.attendance.turnout }}%</div>
<p class="text-sm opacity-70">turnout of those who answered</p>
<p class="mt-2 text-sm">
{# The leading indicator: it measures whether members use the app at all. #}
<span class="font-semibold {% if attention.attendance.no_response > 30 %}text-warning{% endif %}">{{ attention.attendance.no_response }}%</span>
<span class="opacity-70">never responded</span>
</p>
{% endif %}
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h2 class="card-title text-base">{% lucide "hourglass" size=18 %} Unpaid, by age</h2>
<table class="table table-sm">
<tbody>
{% for bucket in attention.aging %}
<tr>
<td class="{% if bucket.overdue and bucket.total %}font-semibold text-error{% endif %}">{{ bucket.label }}</td>
<td class="text-right tabular-nums">€{{ bucket.total|floatformat:2 }}</td>
<td class="text-right opacity-60">{{ bucket.count }} order{{ bucket.count|pluralize }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</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>
<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 "wallet" size=18 %} Fee status this season</h2>
<div class="h-56">
<canvas id="fees-chart"></canvas>
</div>
</div>
</div>
</div>
<div class="mb-6 grid gap-4 md:grid-cols-2"> <div class="mb-6 grid gap-4 md:grid-cols-2">
{% for group in groups %} {% for group in groups %}
<div class="card bg-base-100 shadow"> <div class="card bg-base-100 shadow">
@@ -127,3 +241,66 @@
</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);
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 signups = new Chart(document.getElementById("signups-chart"), {
type: "bar",
data: {
labels: data.signups.map((point) => point.month),
datasets: [{ label: "Signups", data: data.signups.map((point) => point.value), backgroundColor: css("--color-primary", "#4f46e5") }],
},
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 } },
},
},
});
// Colour carries the meaning here — unpaid must read as a problem, waived must
// not — so the slices are pinned to the semantic theme colours, in order.
const fees = new Chart(document.getElementById("fees-chart"), {
type: "doughnut",
data: {
labels: data.fees.map((slice) => slice.label),
datasets: [
{
data: data.fees.map((slice) => slice.value),
backgroundColor: [css("--color-success", "#16a34a"), css("--color-warning", "#f59e0b"), css("--color-error", "#dc2626"), css("--color-neutral", "#6b7280")],
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "right", labels: { color: ink } } },
},
});
return [signups, fees];
};
let charts = render();
// "auto" removes data-theme entirely, so watch the attribute rather than 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

@@ -40,7 +40,7 @@
<div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}"> <div class="card bg-base-100 shadow {% if attention.outstanding %}border-l-4 border-error{% endif %}">
<div class="card-body p-4"> <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="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-3xl font-bold tabular-nums">€{{ attention.outstanding|floatformat:2 }}</div>
<div class="text-xs opacity-60">Unpaid across every club</div> <div class="text-xs opacity-60">Unpaid across every club</div>
</div> </div>
</div> </div>
@@ -50,8 +50,6 @@
<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 text-base">{% lucide "user-plus" size=18 %} Signups per month</h2> <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"> <div class="h-56">
<canvas id="signups-chart"></canvas> <canvas id="signups-chart"></canvas>
</div> </div>
@@ -199,31 +197,31 @@
// Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of // Locale-aware, so 1234.5 reads as "€ 1.234,50" rather than "€1,234.5". Two of
// them: the axis is rounded to keep the labels short, but the tooltip keeps the // them: the axis is rounded to keep the labels short, but the tooltip keeps the
// cents — rounding a euro amount someone is reading off a chart is a lie. // cents — rounding a euro amount someone is reading off a chart is a lie.
const axisEuros = new Intl.NumberFormat("nl-BE", { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); const axisEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", maximumFractionDigits: 0});
const exactEuros = new Intl.NumberFormat("nl-BE", { style: "currency", currency: "EUR", minimumFractionDigits: 2 }); const exactEuros = new Intl.NumberFormat("nl-BE", {style: "currency", currency: "EUR", minimumFractionDigits: 2});
const build = (id, label, series, colour, type, money) => const build = (id, label, series, colour, type, money) =>
new Chart(document.getElementById(id), { new Chart(document.getElementById(id), {
type, type,
data: { data: {
labels: series.map((point) => point.month), labels: series.map((point) => point.month),
datasets: [{ label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3 }], datasets: [{label, data: series.map((point) => point.value), borderColor: colour, backgroundColor: colour, tension: 0.3}],
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
plugins: { plugins: {
legend: { display: false }, legend: {display: false},
// The tooltip carries the unit too: an axis in euros and a bare // The tooltip carries the unit too: an axis in euros and a bare
// number on hover reads as two different quantities. // number on hover reads as two different quantities.
tooltip: money ? { callbacks: { label: (item) => exactEuros.format(item.parsed.y) } } : {}, tooltip: money ? {callbacks: {label: (item) => exactEuros.format(item.parsed.y)}} : {},
}, },
scales: { scales: {
x: { ticks: { color: ink }, grid: { color: grid } }, x: {ticks: {color: ink}, grid: {color: grid}},
y: { y: {
beginAtZero: true, beginAtZero: true,
grid: { color: grid }, grid: {color: grid},
ticks: { color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined }, ticks: {color: ink, precision: 0, callback: money ? (value) => axisEuros.format(value) : undefined},
}, },
}, },
}, },
@@ -242,7 +240,7 @@
new MutationObserver(() => { new MutationObserver(() => {
charts.forEach((chart) => chart.destroy()); charts.forEach((chart) => chart.destroy());
charts = render(); charts = render();
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); }).observe(document.documentElement, {attributes: true, attributeFilter: ["data-theme"]});
})(); })();
</script> </script>
{% endblock extra_body %} {% endblock extra_body %}

View File

@@ -13,14 +13,31 @@ 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 events.models import Attendance, 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, StaffAssignment, 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 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 .services.statistics import (
admins_pending_mfa,
attendance_rates,
club_attention,
club_statistics,
clubs_with_totals,
clubs_without_a_season,
dormant_clubs,
fee_aging,
flag_adoption,
onboarding_funnel,
platform_attention,
platform_charts,
platform_totals,
renewal_rate,
teams_without_a_manager,
unrostered_members,
)
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()
@@ -716,3 +733,136 @@ class DashboardMetricsTests(ControlPanelTestBase):
self.assertContains(response, 'id="revenue-chart"') self.assertContains(response, 'id="revenue-chart"')
self.assertContains(response, "js/chart.js") self.assertContains(response, "js/chart.js")
self.assertIn("signups", response.context["charts"]) self.assertIn("signups", response.context["charts"])
class ClubAttentionTests(TestCase):
def setUp(self):
self.club = Club.objects.create(name="Ajax United")
self.today = timezone.localdate()
self.season = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=30), end_date=self.today + datetime.timedelta(days=300))
self.member = Member.objects.create(first_name="Ada", last_name="Lovelace")
def membership(self, member=None, season=None, **kwargs):
return ClubMembership.objects.create(club=self.club, season=season or self.season, member=member or self.member, **kwargs)
def test_a_team_with_no_manager_is_flagged(self):
team = Team.objects.create(club=self.club, name="U15")
self.assertIn(team, teams_without_a_manager(self.club, self.season))
coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=coach)
self.assertNotIn(team, teams_without_a_manager(self.club, self.season))
def test_a_non_management_staffer_does_not_count_as_a_coach(self):
# Somebody has to be able to pick the squad; a physio cannot.
team = Team.objects.create(club=self.club, name="U15")
physio = Position.objects.create(club=self.club, name="Physio", staff_position=True, management_position=False)
StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=physio)
self.assertIn(team, teams_without_a_manager(self.club, self.season))
def test_a_coach_from_a_previous_season_does_not_count(self):
old = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40))
team = Team.objects.create(club=self.club, name="U15")
coach = Position.objects.create(club=self.club, name="Coach", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=self.member, season=old, position=coach)
self.assertIn(team, teams_without_a_manager(self.club, self.season))
def test_an_active_member_on_no_team_is_unrostered(self):
self.membership(status=ClubMembership.StatusChoices.ACTIVE)
self.assertIn(self.member, unrostered_members(self.club, self.season))
team = Team.objects.create(club=self.club, name="U15")
position = Position.objects.create(club=self.club, name="Forward")
TeamMembership.objects.create(team=team, member=self.member, season=self.season, position=position, jersey_number=9)
self.assertNotIn(self.member, unrostered_members(self.club, self.season))
def test_a_pending_member_is_not_counted_as_unrostered(self):
# They have not been let in yet, so having no team is expected.
self.membership(status=ClubMembership.StatusChoices.PENDING)
self.assertNotIn(self.member, unrostered_members(self.club, self.season))
def test_renewal_compares_against_the_previous_season(self):
previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40))
stayed = self.member
left = Member.objects.create(first_name="Bob", last_name="Bobson")
self.membership(member=stayed, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
self.membership(member=left, season=previous, status=ClubMembership.StatusChoices.ACTIVE)
self.membership(member=stayed, season=self.season)
self.assertEqual(renewal_rate(self.club, self.season), 50)
def test_a_previous_season_with_nobody_active_yields_no_rate(self):
# There is a season to compare against but nobody to renew — dividing by that
# would blow up, and calling it 0% would be a lie.
previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40))
self.membership(season=previous, status=ClubMembership.StatusChoices.LAPSED)
self.assertIsNone(renewal_rate(self.club, self.season))
def test_a_first_season_club_has_no_renewal_rate(self):
# It has not failed to renew anyone; rendering that as 0% would libel it.
self.membership(status=ClubMembership.StatusChoices.ACTIVE)
self.assertIsNone(renewal_rate(self.club, self.season))
def test_unpaid_orders_are_bucketed_by_age(self):
old = Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("100.00"), status=Order.OrderStatus.PENDING)
Order.objects.filter(pk=old.pk).update(created=timezone.now() - datetime.timedelta(days=90))
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("40.00"), status=Order.OrderStatus.PENDING)
Order.objects.create(club=self.club, purchaser=self.member, total=Decimal("999.00"), status=Order.OrderStatus.PAID)
buckets = {bucket["label"]: bucket["total"] for bucket in fee_aging(self.club)}
self.assertEqual(buckets["0-30 days"], Decimal("40.00"))
self.assertEqual(buckets["60+ days"], Decimal("100.00")) # paid orders are not owed
def test_attendance_is_turnout_of_those_who_answered(self):
event = Event.objects.create(club=self.club, season=self.season, title="Match", start=timezone.now() - datetime.timedelta(days=1))
bob = Member.objects.create(first_name="Bob", last_name="Bobson")
carol = Member.objects.create(first_name="Carol", last_name="Carolson")
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
Attendance.objects.create(event=event, member=bob, status=Attendance.AttendanceStatus.ABSENT)
Attendance.objects.create(event=event, member=carol, status=Attendance.AttendanceStatus.NO_RESPONSE)
rates = attendance_rates(self.club, self.season)
self.assertEqual(rates["turnout"], 50) # 1 present of 2 who answered — silence is not an absence
self.assertEqual(rates["no_response"], 33) # 1 of 3 never answered
def test_a_future_event_does_not_drag_turnout_down(self):
event = Event.objects.create(club=self.club, season=self.season, title="Next week", start=timezone.now() + datetime.timedelta(days=7))
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.assertIsNone(attendance_rates(self.club, self.season)["turnout"])
def test_a_club_with_no_season_reports_no_rates(self):
Season.objects.all().delete()
attention = club_attention(self.club)
self.assertTrue(attention["no_season"])
self.assertEqual(attention["teams_without_manager"], 0)
self.assertIsNone(attention["renewal_rate"])
class ClubDetailMetricsTests(ControlPanelTestBase):
def test_the_club_page_renders_its_metrics_and_charts(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "No coach")
self.assertContains(response, "Unrostered")
self.assertContains(response, "Unpaid, by age")
self.assertContains(response, 'id="fees-chart"')
self.assertIn("fees", response.context["charts"])
def test_a_club_without_a_season_is_told_it_is_inert(self):
response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk]))
self.assertContains(response, "cannot take a signup")

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, flag_adoption, onboarding_funnel, platform_attention, platform_charts, platform_totals from .services.statistics import club_attention, club_charts, 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()
@@ -95,6 +95,8 @@ class ClubDetailView(PlatformStaffRequiredMixin, DetailView):
return super().get_context_data( return super().get_context_data(
nav="clubs", nav="clubs",
groups=club_statistics(self.object), groups=club_statistics(self.object),
attention=club_attention(self.object),
charts=club_charts(self.object),
admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"), admins=ClubRole.objects.filter(club=self.object, role=ClubRole.Roles.ADMIN).select_related("member", "member__user"),
flags=flags_for_club(self.object), flags=flags_for_club(self.object),
**kwargs, **kwargs,

View File

@@ -25,6 +25,8 @@
--text-2xl--line-height: calc(2 / 1.5); --text-2xl--line-height: calc(2 / 1.5);
--text-3xl: 1.875rem; --text-3xl: 1.875rem;
--text-3xl--line-height: calc(2.25 / 1.875); --text-3xl--line-height: calc(2.25 / 1.875);
--text-4xl: 2.25rem;
--text-4xl--line-height: calc(2.5 / 2.25);
--font-weight-medium: 500; --font-weight-medium: 500;
--font-weight-semibold: 600; --font-weight-semibold: 600;
--font-weight-bold: 700; --font-weight-bold: 700;
@@ -1545,6 +1547,23 @@
} }
} }
} }
.indicator {
@layer daisyui.l1.l2.l3 {
position: relative;
display: inline-flex;
width: max-content;
:where(.indicator-item) {
z-index: 1;
position: absolute;
white-space: nowrap;
top: var(--indicator-t, 0);
bottom: var(--indicator-b, auto);
left: var(--indicator-s, auto);
right: var(--indicator-e, 0);
translate: var(--indicator-x, 50%) var(--indicator-y, -50%);
}
}
}
.table { .table {
@layer daisyui.l1.l2.l3 { @layer daisyui.l1.l2.l3 {
font-size: 0.875rem; font-size: 0.875rem;
@@ -3553,6 +3572,10 @@
font-size: var(--text-3xl); font-size: var(--text-3xl);
line-height: var(--tw-leading, var(--text-3xl--line-height)); line-height: var(--tw-leading, var(--text-3xl--line-height));
} }
.text-4xl {
font-size: var(--text-4xl);
line-height: var(--tw-leading, var(--text-4xl--line-height));
}
.text-base { .text-base {
font-size: var(--text-base); font-size: var(--text-base);
line-height: var(--tw-leading, var(--text-base--line-height)); line-height: var(--tw-leading, var(--text-base--line-height));
@@ -3664,6 +3687,9 @@
.text-sky-500 { .text-sky-500 {
color: var(--color-sky-500); color: var(--color-sky-500);
} }
.text-warning {
color: var(--color-warning);
}
.tabular-nums { .tabular-nums {
--tw-numeric-spacing: tabular-nums; --tw-numeric-spacing: tabular-nums;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,); font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
@@ -3841,6 +3867,11 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
.lg\:grid-cols-3 {
@media (width >= 64rem) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.lg\:grid-cols-4 { .lg\:grid-cols-4 {
@media (width >= 64rem) { @media (width >= 64rem) {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));