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

@@ -107,6 +107,40 @@ def team_attendance_rate(team, season):
return round(100 * counts["present"] / answered) if answered else None
def member_attendance_sparkline(member, season, *, limit=12):
"""This member's most recent ``limit`` attendance rows in ``season``,
oldest first, each bucketed into the three states the member detail
page's sparkline draws: 'present'/'absent' for a past event (mirrors
team_attendance_rate's own present-vs-everything-else-that-isn't-present
definition -- excused/no_response/etc. all read as a miss here, same as
there) or 'upcoming' for one that hasn't happened yet."""
now = timezone.now()
rows = list(Attendance.objects.filter(member=member, event__season=season).select_related("event").order_by("-event__start")[:limit])
rows.reverse()
bars = []
for row in rows:
if row.event.start > now:
state = "upcoming"
elif row.status == Attendance.AttendanceStatus.PRESENT:
state = "present"
else:
state = "absent"
bars.append({"event": row.event, "state": state})
return bars
def member_attendance_counts(member, season):
"""Present/Absent/No-reply totals for the season, alongside the
sparkline -- counts every past attendance row, not just the (possibly
truncated) ones the sparkline itself displays."""
return Attendance.objects.filter(member=member, 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)),
no_reply=Count("id", filter=Q(status=Attendance.AttendanceStatus.NO_RESPONSE)),
)
def player_attendance_rankings(team, season, *, minimum_responses=3):
"""Each player's turnout this season, best first:
``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes