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:
@@ -1709,3 +1709,30 @@
|
|||||||
color: var(--color-ink);
|
color: var(--color-ink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Member attendance sparkline (management/templates/management/member_detail.html) ---
|
||||||
|
D7's 12-bar strip: uniform-height blocks, not a value chart -- each bar is
|
||||||
|
just one event's outcome (ok present, club absent, edge upcoming), not a
|
||||||
|
magnitude, so there's nothing for a bar's height to encode. */
|
||||||
|
@layer components {
|
||||||
|
.attendance-sparkline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-bar {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--color-edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-bar-present {
|
||||||
|
background: var(--color-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attendance-bar-absent {
|
||||||
|
background: var(--color-club);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,40 @@ def team_attendance_rate(team, season):
|
|||||||
return round(100 * counts["present"] / answered) if answered else None
|
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):
|
def player_attendance_rankings(team, season, *, minimum_responses=3):
|
||||||
"""Each player's turnout this season, best first:
|
"""Each player's turnout this season, best first:
|
||||||
``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes
|
``[{"member": Member, "rate": int, "responses": int}, ...]``. Excludes
|
||||||
|
|||||||
@@ -167,6 +167,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% comment %}
|
||||||
Onboarding checklist -- club.services.onboarding.checklist_for, paired with
|
Onboarding checklist -- club.services.onboarding.checklist_for, paired with
|
||||||
MemberRequirementStatus if one exists. Any staff can mark an item done or
|
MemberRequirementStatus if one exists. Any staff can mark an item done or
|
||||||
|
|||||||
@@ -22,24 +22,26 @@
|
|||||||
|
|
||||||
<div class="card overflow-hidden divide-y">
|
<div class="card overflow-hidden divide-y">
|
||||||
{% for item in news_items %}
|
{% 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 %}">
|
{# 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. #}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<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" %}
|
{% if item.status == "draft" %}
|
||||||
<span class="badge badge-sm">{% trans "Draft" %}</span>
|
<span class="badge badge-sm mt-0.5 shrink-0">{% trans "Draft" %}</span>
|
||||||
{% elif item.is_scheduled %}
|
{% elif item.is_scheduled %}
|
||||||
<span class="badge badge-info badge-sm">{% trans "Scheduled" %}</span>
|
<span class="badge badge-info badge-sm mt-0.5 shrink-0">{% trans "Scheduled" %}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge badge-success badge-sm">{% trans "Published" %}</span>
|
<span class="badge badge-success badge-sm mt-0.5 shrink-0">{% trans "Published" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="font-mono text-xs text-dim">
|
<div class="min-w-0 flex-1">
|
||||||
{% if item.status == "draft" %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
|
<div class="truncate font-display text-sm font-bold text-ink uppercase">{{ item.title }}</div>
|
||||||
{% else %}{{ item.published_at|date:"j M Y" }}{% endif %}
|
<div class="truncate text-xs text-muted">
|
||||||
</span>
|
<span class="font-mono text-dim">
|
||||||
</div>
|
{% if item.status == "draft" %}{% blocktrans with time=item.modified|timesince %}Edited {{ time }} ago{% endblocktrans %}
|
||||||
<div class="truncate font-display text-base font-extrabold text-ink uppercase">{{ item.title }}</div>
|
{% else %}{{ item.published_at|date:"j M Y" }}{% endif %}
|
||||||
<div class="truncate text-[13px] text-muted">
|
</span>
|
||||||
{% if item.created_by %}{{ item.created_by }} · {% endif %}
|
·
|
||||||
{% for team in item.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}{% trans "Club-wide" %}{% endfor %}
|
{% if item.created_by %}{{ item.created_by }} · {% endif %}
|
||||||
|
{% for team in item.teams.all %}{{ team.short_name }}{% if not forloop.last %}, {% endif %}{% empty %}{% trans "Club-wide" %}{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -321,6 +321,96 @@ class MemberManagementTests(ManagementTestBase):
|
|||||||
self.assertEqual(member.first_name, "New")
|
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):
|
class TeamManagementTests(ManagementTestBase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from controlpanel.messages import notify
|
|||||||
from controlpanel.mixins import RedirectOnInvalidMixin
|
from controlpanel.mixins import RedirectOnInvalidMixin
|
||||||
from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members
|
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.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.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.competitions import CompetitionFetchError, fetch_game_info
|
||||||
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
|
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)
|
is_admin = is_club_admin(self.request.user, self.request.club)
|
||||||
referee_profile = RefereeProfile.objects.filter(member=self.object).select_related("level").first()
|
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(
|
return super().get_context_data(
|
||||||
family_groups=family_groups,
|
family_groups=family_groups,
|
||||||
@@ -740,6 +748,9 @@ class MemberDetailView(ClubStaffRequiredMixin, DetailView):
|
|||||||
guardians=self.object.guardians,
|
guardians=self.object.guardians,
|
||||||
referee_profile=referee_profile,
|
referee_profile=referee_profile,
|
||||||
referee_eligibility_form=MemberRefereeEligibilityForm(club=self.request.club, member=self.object) if is_admin else None,
|
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,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4008,6 +4008,12 @@
|
|||||||
.gap-5 {
|
.gap-5 {
|
||||||
gap: calc(var(--spacing) * 5);
|
gap: calc(var(--spacing) * 5);
|
||||||
}
|
}
|
||||||
|
.gap-6 {
|
||||||
|
gap: calc(var(--spacing) * 6);
|
||||||
|
}
|
||||||
|
.gap-8 {
|
||||||
|
gap: calc(var(--spacing) * 8);
|
||||||
|
}
|
||||||
.gap-10 {
|
.gap-10 {
|
||||||
gap: calc(var(--spacing) * 10);
|
gap: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user