diff --git a/controlpanel/services/statistics.py b/controlpanel/services/statistics.py index a0e4570..1b23dc1 100644 --- a/controlpanel/services/statistics.py +++ b/controlpanel/services/statistics.py @@ -5,6 +5,7 @@ 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. """ +from collections import defaultdict from datetime import timedelta from decimal import Decimal @@ -181,6 +182,49 @@ def renewal_rate(club, season): return round(100 * returned / total) +def new_members(club, season): + """Members whose first-ever season at this club is ``season``. + + Keyed on "has no membership in an earlier season", not on "signed up recently" — a + member who lapsed for a year and came back is a renewal, not a new member, and + counting them as new would flatter every recovery into growth. + """ + if season is None: + return Member.objects.none() + + seen_before = ClubMembership.objects.filter(club=club, season__start_date__lt=season.start_date).values("member") + + return Member.objects.filter(member_of__club=club, member_of__season=season).exclude(pk__in=seen_before).distinct() + + +def signup_split(club, months=MONTHS_OF_HISTORY): + """Signups per month, split into first-timers and returners. + + Which season a signup belongs to decides the split, so the member's earliest season at + this club is looked up once for everyone rather than per row — the same question asked + inside a loop is a query per membership. + """ + start = (timezone.now() - timedelta(days=30 * months)).replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + first_season = {} + for member_id, season_start in ClubMembership.objects.filter(club=club).values_list("member_id", "season__start_date"): + if member_id not in first_season or season_start < first_season[member_id]: + first_season[member_id] = season_start + + counts = defaultdict(lambda: {"new": 0, "returning": 0}) + for member_id, season_start, signed_up_at in ClubMembership.objects.filter(club=club, signed_up_at__isnull=False, signed_up_at__gte=start).values_list("member_id", "season__start_date", "signed_up_at"): + kind = "new" if season_start == first_season[member_id] else "returning" + counts[signed_up_at.strftime("%Y-%m")][kind] += 1 + + series, cursor = [], start + while cursor <= timezone.now(): + month = counts[cursor.strftime("%Y-%m")] + series.append({"month": cursor.strftime("%b %Y"), "new": month["new"], "returning": month["returning"]}) + cursor = (cursor + timedelta(days=32)).replace(day=1) + + return series + + def teams_without_a_manager(club, season): """Teams with nobody in a management position this season. @@ -258,6 +302,7 @@ def club_attention(club): "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(), + "new_members": new_members(club, season).count(), "renewal_rate": renewal_rate(club, season), "attendance": attendance_rates(club, season), } @@ -268,7 +313,7 @@ def club_charts(club): 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")), + "signups": signup_split(club), # Fee status this season, in the order a treasurer cares about. "fees": [ {"label": label, "value": memberships.filter(fee_status=status).count()} diff --git a/controlpanel/templates/controlpanel/club_detail.html b/controlpanel/templates/controlpanel/club_detail.html index 49d5504..58b619d 100644 --- a/controlpanel/templates/controlpanel/club_detail.html +++ b/controlpanel/templates/controlpanel/club_detail.html @@ -78,7 +78,16 @@ -
+
+
+
+

{% lucide "sparkles" size=18 %} New members

+
{{ attention.new_members }}
+ {# First season at this club — someone returning after a year away is a renewal. #} +

first season at this club

+
+
+

{% lucide "repeat" size=18 %} Renewal

@@ -132,6 +141,7 @@

{% lucide "user-plus" size=18 %} Signups per month

+

New members against returning ones.

@@ -254,19 +264,24 @@ const ink = css("--color-base-content", "#333"); const grid = "color-mix(in oklab, " + ink + " 15%, transparent)"; + // Stacked: the bar height stays "signups this month" while the split shows where + // they came from. Side-by-side bars would answer a different question. 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") }], + datasets: [ + { label: "New", data: data.signups.map((point) => point.new), backgroundColor: css("--color-primary", "#4f46e5") }, + { label: "Returning", data: data.signups.map((point) => point.returning), backgroundColor: css("--color-accent", "#0ea5e9") }, + ], }, options: { responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false } }, + plugins: { legend: { position: "bottom", labels: { color: ink } } }, scales: { - x: { ticks: { color: ink }, grid: { color: grid } }, - y: { beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } }, + x: { stacked: true, ticks: { color: ink }, grid: { color: grid } }, + y: { stacked: true, beginAtZero: true, ticks: { color: ink, precision: 0 }, grid: { color: grid } }, }, }, }); diff --git a/controlpanel/tests.py b/controlpanel/tests.py index ad415bf..acda275 100644 --- a/controlpanel/tests.py +++ b/controlpanel/tests.py @@ -30,11 +30,13 @@ from .services.statistics import ( dormant_clubs, fee_aging, flag_adoption, + new_members, onboarding_funnel, platform_attention, platform_charts, platform_totals, renewal_rate, + signup_split, teams_without_a_manager, unrostered_members, ) @@ -866,3 +868,65 @@ class ClubDetailMetricsTests(ControlPanelTestBase): response = self.client.get(reverse("controlpanel:club_detail", args=[self.club.pk])) self.assertContains(response, "cannot take a signup") + + +class NewMemberTests(TestCase): + def setUp(self): + self.club = Club.objects.create(name="Ajax United") + self.today = timezone.localdate() + self.previous = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + 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.veteran = Member.objects.create(first_name="Ada", last_name="Lovelace") + self.rookie = Member.objects.create(first_name="Bob", last_name="Bobson") + + def membership(self, member, season, signed_up_at=None): + return ClubMembership.objects.create(club=self.club, season=season, member=member, status=ClubMembership.StatusChoices.ACTIVE, signed_up_at=signed_up_at) + + def test_only_first_timers_count_as_new(self): + self.membership(self.veteran, self.previous) + self.membership(self.veteran, self.season) + self.membership(self.rookie, self.season) + + new = new_members(self.club, self.season) + + self.assertIn(self.rookie, new) + self.assertNotIn(self.veteran, new) + + def test_a_member_returning_after_a_gap_is_not_new(self): + # They skipped a season and came back. Counting that as growth would flatter every + # recovery; they are a renewal. + self.membership(self.veteran, self.previous) + gap = Season.objects.create(club=self.club, start_date=self.today - datetime.timedelta(days=39), end_date=self.today - datetime.timedelta(days=31)) # noqa: F841 + self.membership(self.veteran, self.season) + + self.assertNotIn(self.veteran, new_members(self.club, self.season)) + + def test_a_member_of_another_club_is_new_here(self): + # "New" is per club, not per platform. + other = Club.objects.create(name="Feyenoord") + other_season = Season.objects.create(club=other, start_date=self.today - datetime.timedelta(days=400), end_date=self.today - datetime.timedelta(days=40)) + ClubMembership.objects.create(club=other, season=other_season, member=self.rookie) + self.membership(self.rookie, self.season) + + self.assertIn(self.rookie, new_members(self.club, self.season)) + + def test_a_club_with_no_season_has_no_new_members(self): + self.assertEqual(new_members(self.club, None).count(), 0) + + def test_signups_are_split_by_month_into_new_and_returning(self): + self.membership(self.veteran, self.previous, signed_up_at=self.today - datetime.timedelta(days=200)) + self.membership(self.veteran, self.season, signed_up_at=self.today) + self.membership(self.rookie, self.season, signed_up_at=self.today) + + this_month = signup_split(self.club)[-1] + + self.assertEqual(this_month["new"], 1) # the rookie + self.assertEqual(this_month["returning"], 1) # the veteran renewing + + def test_a_first_ever_signup_counts_as_new_in_its_own_month(self): + self.membership(self.veteran, self.previous, signed_up_at=self.today - datetime.timedelta(days=200)) + + series = signup_split(self.club) + + self.assertEqual(sum(month["new"] for month in series), 1) + self.assertEqual(sum(month["returning"] for month in series), 0)