Split club signups into new members and renewals
A "New members" card sits beside Renewal, and the signups chart becomes a stacked bar: bar height stays "signups this month" while the split shows where they came from. New means "first-ever season at this club", not "signed up recently". A member who lapsed for a year and came back is a renewal, and counting them as new would flatter every recovery into growth. It is also per club, not per platform: someone who plays for another club is still new here. The split resolves each member's earliest season once up front rather than asking per row, so the chart costs two queries instead of one per membership. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()}
|
||||
|
||||
@@ -78,7 +78,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-3">
|
||||
<div class="mb-6 grid gap-4 lg:grid-cols-4">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "sparkles" size=18 %} New members</h2>
|
||||
<div class="text-4xl font-bold tabular-nums">{{ attention.new_members }}</div>
|
||||
{# First season at this club — someone returning after a year away is a renewal. #}
|
||||
<p class="text-sm opacity-70">first season at this club</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base">{% lucide "repeat" size=18 %} Renewal</h2>
|
||||
@@ -132,6 +141,7 @@
|
||||
<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>
|
||||
<p class="text-sm opacity-70">New members against returning ones.</p>
|
||||
<div class="h-56">
|
||||
<canvas id="signups-chart"></canvas>
|
||||
</div>
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user