Add the D7 attendance sparkline to member detail; tighten the News list rows

Member detail: a 12-bar season attendance sparkline (present/absent/upcoming)
beside Present/Absent/No-reply totals, shown for any rostered non-guardian
member with events this season (events.services.attendance.member_attendance_
sparkline/_counts). News list: rows are one dense line (status pill inline
with the headline, everything else folded into a single meta line) instead
of the previous multi-line stacked layout that read as a small card per item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-20 08:55:44 +02:00
parent 71a96a8212
commit 8baf3f7b3d
8 changed files with 223 additions and 21 deletions

View File

@@ -167,6 +167,38 @@
</div>
</div>
{% if show_attendance %}
{# D7's season card: a 12-bar attendance sparkline (present/absent/upcoming, oldest first) beside the season's Present/Absent/No-reply totals. #}
<div class="card card-body mt-4">
<h2 class="card-title">{% lucide "calendar-check" size=18 %} {% trans "Attendance" %}</h2>
{% if not attendance_sparkline %}
<p class="text-sm text-muted">{% trans "No events yet this season." %}</p>
{% else %}
<div class="flex flex-wrap items-center gap-8">
<div class="attendance-sparkline">
{% for bar in attendance_sparkline %}
<span class="attendance-bar attendance-bar-{{ bar.state }}" title="{{ bar.event.title }} — {{ bar.event.start|date:"j M" }}"></span>
{% endfor %}
</div>
<div class="flex gap-6">
<div>
<div class="font-display text-2xl leading-none font-extrabold text-ok tabular-nums">{{ attendance_counts.present }}</div>
<div class="mt-1 font-display text-[11px] font-bold tracking-[.1em] text-muted uppercase">{% trans "Present" %}</div>
</div>
<div>
<div class="font-display text-2xl leading-none font-extrabold text-club tabular-nums">{{ attendance_counts.absent }}</div>
<div class="mt-1 font-display text-[11px] font-bold tracking-[.1em] text-muted uppercase">{% trans "Absent" %}</div>
</div>
<div>
<div class="font-display text-2xl leading-none font-extrabold text-dim tabular-nums">{{ attendance_counts.no_reply }}</div>
<div class="mt-1 font-display text-[11px] font-bold tracking-[.1em] text-muted uppercase">{% trans "No reply" %}</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% comment %}
Onboarding checklist -- club.services.onboarding.checklist_for, paired with
MemberRequirementStatus if one exists. Any staff can mark an item done or

View File

@@ -22,24 +22,26 @@
<div class="card overflow-hidden divide-y">
{% for item in news_items %}
<a href="{% querystring selected=item.pk %}" class="flex flex-col gap-1 border-l-[3px] px-4 py-3 {% if news_item and item.pk == news_item.pk %}border-club bg-row-sel{% else %}border-transparent hover:bg-subhead{% endif %}">
<div class="flex flex-wrap items-center gap-2">
{% if item.status == "draft" %}
<span class="badge badge-sm">{% trans "Draft" %}</span>
{% elif item.is_scheduled %}
<span class="badge badge-info badge-sm">{% trans "Scheduled" %}</span>
{% else %}
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
{% endif %}
<span class="font-mono text-xs text-dim">
{% if item.status == "draft" %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
{% else %}{{ item.published_at|date:"j M Y" }}{% endif %}
</span>
</div>
<div class="truncate font-display text-base font-extrabold text-ink uppercase">{{ item.title }}</div>
<div class="truncate text-[13px] text-muted">
{% if item.created_by %}{{ item.created_by }} &middot; {% endif %}
{% for team in item.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}{% trans "Club-wide" %}{% endfor %}
{# One dense line per row (D8's actual list-pane density), not a stacked mini-card: status pill inline with the headline, everything else folded into one muted meta line below. #}
<a href="{% querystring selected=item.pk %}" class="flex items-start gap-2.5 border-l-[3px] px-3.5 py-2.5 {% if news_item and item.pk == news_item.pk %}border-club bg-row-sel{% else %}border-transparent hover:bg-subhead{% endif %}">
{% if item.status == "draft" %}
<span class="badge badge-sm mt-0.5 shrink-0">{% trans "Draft" %}</span>
{% elif item.is_scheduled %}
<span class="badge badge-info badge-sm mt-0.5 shrink-0">{% trans "Scheduled" %}</span>
{% else %}
<span class="badge badge-success badge-sm mt-0.5 shrink-0">{% trans "Published" %}</span>
{% endif %}
<div class="min-w-0 flex-1">
<div class="truncate font-display text-sm font-bold text-ink uppercase">{{ item.title }}</div>
<div class="truncate text-xs text-muted">
<span class="font-mono text-dim">
{% if item.status == "draft" %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
{% else %}{{ item.published_at|date:"j M Y" }}{% endif %}
</span>
&middot;
{% if item.created_by %}{{ item.created_by }} &middot; {% endif %}
{% for team in item.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}{% trans "Club-wide" %}{% endfor %}
</div>
</div>
</a>
{% empty %}

View File

@@ -321,6 +321,96 @@ class MemberManagementTests(ManagementTestBase):
self.assertEqual(member.first_name, "New")
class MemberAttendanceSparklineTests(ManagementTestBase):
"""The D7-alike attendance card on member_detail.html --
events.services.attendance.member_attendance_sparkline/_counts."""
@classmethod
def setUpTestData(cls):
super().setUpTestData()
cls.team = Team.objects.create(club=cls.club, name="First Team", short_name="1st")
cls.position = Position.objects.create(club=cls.club, name="Forward", short_name="FW")
cls.player = Member.objects.create(first_name="Peter", last_name="Player")
ClubMembership.objects.create(club=cls.club, member=cls.player, season=cls.season, status=ClubMembership.StatusChoices.ACTIVE)
TeamMembership.objects.create(team=cls.team, member=cls.player, season=cls.season, position=cls.position)
def setUp(self):
self.client.force_login(self.admin_user)
def make_attendance(self, *, status, start, showed_up=None):
event = Event.objects.create(club=self.club, title="Training", kind=Event.EventKind.TRAINING, start=start, season=self.season)
# A future event's roster is auto-synced on event.teams.add() (see
# events/signals.py), which already creates this row -- a past event's
# isn't (history is never rewritten), so get_or_create covers both.
event.teams.add(self.team)
attendance, created = Attendance.objects.get_or_create(event=event, member=self.player, defaults={"status": status, "showed_up": showed_up})
if not created:
attendance.status, attendance.showed_up = status, showed_up
attendance.save(update_fields=["status", "showed_up"])
return attendance
def test_the_card_is_hidden_for_a_member_not_rostered_this_season(self):
unrostered = Member.objects.create(first_name="Not", last_name="Rostered")
ClubMembership.objects.create(club=self.club, member=unrostered, season=self.season, status=ClubMembership.StatusChoices.PENDING)
response = self.club_get("member_detail", unrostered.pk)
self.assertNotContains(response, "attendance-sparkline")
def test_the_card_is_hidden_for_a_guardian(self):
guardian = Member.objects.create(first_name="Gale", last_name="Guardian")
ClubMembership.objects.create(club=self.club, member=guardian, season=self.season, kind=ClubMembership.Kind.GUARDIAN, status=ClubMembership.StatusChoices.ACTIVE)
response = self.club_get("member_detail", guardian.pk)
self.assertNotContains(response, "attendance-sparkline")
def test_a_past_present_event_counts_as_present(self):
self.make_attendance(status=Attendance.AttendanceStatus.PRESENT, start=timezone.now() - datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_counts"]["present"], 1)
self.assertEqual(response.context["attendance_sparkline"][0]["state"], "present")
def test_a_past_absent_event_counts_as_absent(self):
self.make_attendance(status=Attendance.AttendanceStatus.ABSENT, start=timezone.now() - datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_counts"]["absent"], 1)
self.assertEqual(response.context["attendance_sparkline"][0]["state"], "absent")
def test_a_future_event_is_upcoming_regardless_of_rsvp_status(self):
self.make_attendance(status=Attendance.AttendanceStatus.SELECTED, start=timezone.now() + datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_sparkline"][0]["state"], "upcoming")
# Only past events feed the Present/Absent/No-reply totals.
self.assertEqual(response.context["attendance_counts"]["present"], 0)
self.assertEqual(response.context["attendance_counts"]["absent"], 0)
self.assertEqual(response.context["attendance_counts"]["no_reply"], 0)
def test_a_past_unanswered_event_counts_as_no_reply(self):
self.make_attendance(status=Attendance.AttendanceStatus.NO_RESPONSE, start=timezone.now() - datetime.timedelta(days=2))
response = self.club_get("member_detail", self.player.pk)
self.assertEqual(response.context["attendance_counts"]["no_reply"], 1)
def test_the_sparkline_is_capped_at_twelve_bars_oldest_first(self):
for day in range(15, 0, -1):
self.make_attendance(status=Attendance.AttendanceStatus.PRESENT, start=timezone.now() - datetime.timedelta(days=day))
response = self.club_get("member_detail", self.player.pk)
sparkline = response.context["attendance_sparkline"]
self.assertEqual(len(sparkline), 12)
starts = [bar["event"].start for bar in sparkline]
self.assertEqual(starts, sorted(starts))
class TeamManagementTests(ManagementTestBase):
def setUp(self):
super().setUp()

View File

@@ -33,7 +33,7 @@ from controlpanel.messages import notify
from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent
from events.services.attendance import player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
from events.services.attendance import member_attendance_counts, member_attendance_sparkline, player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
from events.services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid
from events.services.competitions import CompetitionFetchError, fetch_game_info
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
@@ -723,7 +723,15 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
is_admin = is_club_admin(self.request.user, self.request.club)
referee_profile = RefereeProfile.objects.filter(member=self.object).select_related("level").first()
current_membership = ClubMembership.objects.filter(club=self.request.club, member=self.object, season=current_season(self.request.club)).first()
season = current_season(self.request.club)
current_membership = ClubMembership.objects.filter(club=self.request.club, member=self.object, season=season).first()
# A guardian isn't rostered anywhere, so there's never an attendance
# row to show -- same "member, not guardian" gate the fee-status row
# above already uses.
show_attendance = current_membership is not None and not current_membership.is_guardian
attendance_sparkline = member_attendance_sparkline(self.object, season) if show_attendance else []
attendance_counts = member_attendance_counts(self.object, season) if show_attendance else None
return super().get_context_data(
family_groups=family_groups,
@@ -740,6 +748,9 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
guardians=self.object.guardians,
referee_profile=referee_profile,
referee_eligibility_form=MemberRefereeEligibilityForm(club=self.request.club, member=self.object) if is_admin else None,
show_attendance=show_attendance,
attendance_sparkline=attendance_sparkline,
attendance_counts=attendance_counts,
**kwargs,
)