Let a parent sign up a referee-eligible managed child, and hide scrollbars

Referee sign-up (Calendar row and event detail's own card) was scoped to
self.me only; a referee-eligible child is exactly as real as a referee-
eligible parent, and a parent signing one up is no different from
answering an RSVP on their behalf -- both surfaces and the respond view now
cover every managed person, and the calendar row names whose invite it is
once there's more than one managed person to tell apart. Event detail's
single-card layout became a per-person list, same shape as "Your answers".

Also hide the scrollbar on both mobile shells (the member shell's own
document scroll, and the coach shell's still-inner-scrolling .coach-sheet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 18:15:38 +02:00
parent e349fbbf03
commit 5db885c809
6 changed files with 139 additions and 78 deletions

View File

@@ -1,20 +1,22 @@
{% load i18n lucide %} {% load i18n lucide %}
{% comment %} {% comment %}
One referee sign-up row on M3's Calendar -- a home game the signed-in One referee sign-up row on M3's Calendar -- a home game the signed-in
account is eligible (and, once responded, confirmed) to referee for. account (or a managed person -- a referee-eligible child is exactly as
Merged into the same chronological list as the account's own RSVP rows real as a referee-eligible parent, mobile/views.py's CalendarView) is
(mobile/_calendar_row.html), but in the referee accent (assets/mobile. eligible (and, once responded, confirmed) to referee for. Merged into
css's --color-referee/.pill-referee) so it reads as a different kind of the same chronological list as the account's own RSVP rows (mobile/
commitment at a glance -- and scoped to self.me only (mobile/views.py's _calendar_row.html), but in the referee accent (assets/mobile.css's
CalendarView), never managed_people, since a referee is an adult acting --color-referee/.pill-referee) so it reads as a different kind of
on their own behalf, not something a parent does for a child. commitment at a glance.
Expects ``row`` ({event, referee_signup}) in scope. Accept/Decline are Expects ``row`` ({event, referee_signup, referee_member}) in scope --
two small forms, not a single multi-button one -- and, unlike this referee_member is only set once there's more than one managed person to
app's RSVP forms, boosted (no hx-boost="false"): nothing here is tell apart, same rule mobile/_calendar_row.html's own ``member`` uses.
Alpine-owned/toggled, so there's no htmx/Alpine conflict to dodge, and a Accept/Decline are two small forms, not a single multi-button one --
boosted POST avoids the jarring full-page reload a plain form submit and, unlike this app's RSVP forms, boosted (no hx-boost="false"):
would cause on a page the user is mid-scroll on. nothing here is Alpine-owned/toggled, so there's no htmx/Alpine conflict
to dodge, and a boosted POST avoids the jarring full-page reload a plain
form submit would cause on a page the user is mid-scroll on.
{% endcomment %} {% endcomment %}
<div class="flex items-center gap-3 bg-white px-4 py-3"> <div class="flex items-center gap-3 bg-white px-4 py-3">
<div class="w-9.5 shrink-0 text-center"> <div class="w-9.5 shrink-0 text-center">
@@ -29,6 +31,7 @@
</div> </div>
<div class="truncate text-xs text-muted"> <div class="truncate text-xs text-muted">
{{ row.event.start|date:"H:i" }} {{ row.event.start|date:"H:i" }}
{% if row.referee_member %}&middot; {{ row.referee_member.first_name }}{% endif %}
{% for team in row.event.teams.all %}&middot; {{ team.name }}{% endfor %} {% for team in row.event.teams.all %}&middot; {{ team.name }}{% endfor %}
{% if row.event.location %}&middot; {{ row.event.location.name }}{% endif %} {% if row.event.location %}&middot; {{ row.event.location.name }}{% endif %}
</div> </div>

View File

@@ -70,7 +70,7 @@
{% block extra_head %}{% endblock extra_head %} {% block extra_head %}{% endblock extra_head %}
</head> </head>
<body class="flex min-h-screen flex-col bg-paper font-sans text-slate" hx-boost="true" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-vapid-public-key="{{ vapid_public_key }}" data-csrftoken="{{ csrf_token }}"> <body class="scrollbar-hide flex min-h-screen flex-col bg-paper font-sans text-slate" hx-boost="true" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-vapid-public-key="{{ vapid_public_key }}" data-csrftoken="{{ csrf_token }}">
<header class="app-header sticky top-0 z-20"> <header class="app-header sticky top-0 z-20">
<div class="flex min-h-11 items-center gap-2.5"> <div class="flex min-h-11 items-center gap-2.5">
<a class="flex items-center gap-2.5" href="{% url "mobile:home" %}"> <a class="flex items-center gap-2.5" href="{% url "mobile:home" %}">

View File

@@ -98,7 +98,7 @@
{% block header_extra %}{% endblock header_extra %} {% block header_extra %}{% endblock header_extra %}
</header> </header>
<main class="coach-sheet"> <main class="coach-sheet scrollbar-hide">
<div class="flex flex-col gap-4 px-4 py-5"> <div class="flex flex-col gap-4 px-4 py-5">
{% if messages %} {% if messages %}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">

View File

@@ -58,31 +58,36 @@
{% endif %} {% endif %}
</div> </div>
{% if referee_signup %} {% if referee_signups %}
<div class="m-card p-4"> <div class="m-card p-4">
<div class="flex items-center justify-between"> <span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Refereeing" %}</span>
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Refereeing" %}</span> <div class="mt-3 flex flex-col gap-3">
{% if referee_signup.status == "accepted" %}<span class="pill pill-referee">{% trans "Confirmed" %}</span>{% endif %} {% for signup in referee_signups %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<div>
<div class="mb-2 flex items-center justify-between gap-2.5">
<span class="text-[15px] font-semibold text-ink">{{ signup.member.get_full_name }}</span>
{% if signup.status == "accepted" %}<span class="pill pill-referee shrink-0">{% trans "Confirmed" %}</span>{% endif %}
</div>
{% if signup.status != "accepted" %}
<div class="grid grid-cols-2 gap-2">
<form method="post" action="{% url "mobile:referee_signup_respond" signup.pk %}">
{% csrf_token %}
<input type="hidden" name="response" value="accept">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn w-full bg-referee text-white">{% trans "I'll ref" %}</button>
</form>
<form method="post" action="{% url "mobile:referee_signup_respond" signup.pk %}">
{% csrf_token %}
<input type="hidden" name="response" value="decline">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn btn-secondary w-full">{% trans "Can't" %}</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div> </div>
{% if referee_signup.status == "accepted" %}
<p class="mt-2 text-sm text-muted">{% trans "You're confirmed to referee this game." %}</p>
{% else %}
<p class="mt-2 text-sm text-ink">{% trans "You're eligible to referee this game -- are you in?" %}</p>
<div class="mt-3 grid grid-cols-2 gap-2">
<form method="post" action="{% url "mobile:referee_signup_respond" referee_signup.pk %}">
{% csrf_token %}
<input type="hidden" name="response" value="accept">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn w-full bg-referee text-white">{% trans "I'll ref" %}</button>
</form>
<form method="post" action="{% url "mobile:referee_signup_respond" referee_signup.pk %}">
{% csrf_token %}
<input type="hidden" name="response" value="decline">
<input type="hidden" name="next" value="event_detail">
<button type="submit" class="btn btn-secondary w-full">{% trans "Can't" %}</button>
</form>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -226,7 +231,7 @@
</div> </div>
{% endif %} {% endif %}
{% if not your_answers and not squad_summary and not lineup and not referee_signup %} {% if not your_answers and not squad_summary and not lineup and not referee_signups %}
<div class="m-card p-6 text-center"> <div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p> <p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p>
</div> </div>

View File

@@ -1010,9 +1010,9 @@ class CalendarViewTests(TestCase):
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CalendarRefereeSignupTests(TestCase): class CalendarRefereeSignupTests(TestCase):
"""M3's Calendar merges in the signed-in account's own referee sign-ups """M3's Calendar merges in every managed person's referee sign-ups, not
(self.me only, never managed_people) -- see mobile/_calendar_referee_row. just self.me -- see mobile/_calendar_referee_row.html and CalendarView's
html and CalendarView's own docstring.""" own docstring."""
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
@@ -1074,6 +1074,22 @@ class CalendarRefereeSignupTests(TestCase):
self.assertNotContains(response, "I'll ref") self.assertNotContains(response, "I'll ref")
def test_a_managed_childs_invite_shows_up_and_names_them(self):
family = Family.objects.create(name="Eree")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Kid", last_name="Eree")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
RefereeProfile.objects.create(member=child, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
other_game = Event.objects.create(club=self.club, title="Second game", kind=Event.EventKind.GAME, location=self.home_ground, start=timezone.now() + datetime.timedelta(days=2))
other_game.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertTrue(RefereeSignup.objects.filter(event=other_game, member=child).exists())
self.assertContains(response, "Kid")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class RefereeSignupRespondViewTests(TestCase): class RefereeSignupRespondViewTests(TestCase):
@@ -1153,6 +1169,21 @@ class RefereeSignupRespondViewTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_a_parent_can_respond_on_behalf_of_a_managed_child(self):
family = Family.objects.create(name="Eree")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Kid", last_name="Eree")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
RefereeProfile.objects.create(member=child, level=self.level, valid_until=timezone.localdate() + datetime.timedelta(days=30))
child_signup = RefereeSignup.objects.create(event=self.game, member=child, status=RefereeSignup.Status.INVITED)
self.client.force_login(self.user)
response = self.client.post(reverse("mobile:referee_signup_respond", kwargs={"signup_id": child_signup.pk}), {"response": "accept"}, HTTP_HOST="ajax-united.rosterchief.app")
self.assertRedirects(response, reverse("mobile:calendar"), fetch_redirect_response=False)
self.assertTrue(EventReferee.objects.filter(event=self.game, member=child).exists())
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EventDetailScreenTests(TestCase): class EventDetailScreenTests(TestCase):
@@ -1311,7 +1342,7 @@ class EventDetailScreenTests(TestCase):
response = self._get() response = self._get()
self.assertIsNone(response.context["referee_signup"]) self.assertEqual(list(response.context["referee_signups"]), [])
self.assertNotContains(response, "Refereeing") self.assertNotContains(response, "Refereeing")
def test_pending_referee_invite_shows_accept_decline(self): def test_pending_referee_invite_shows_accept_decline(self):
@@ -1339,9 +1370,24 @@ class EventDetailScreenTests(TestCase):
response = self._get() response = self._get()
self.assertIsNone(response.context["referee_signup"]) self.assertEqual(list(response.context["referee_signups"]), [])
self.assertNotContains(response, "Refereeing") self.assertNotContains(response, "Refereeing")
def test_a_managed_childs_referee_invite_shows_up_too(self):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
RefereeSignup.objects.create(event=self.event, member=child, status=RefereeSignup.Status.INVITED)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Refereeing")
self.assertContains(response, "Noor Bakker")
self.assertContains(response, "I'll ref")
def test_squad_response_counts_are_correct(self): def test_squad_response_counts_are_correct(self):
in_member = Member.objects.create(first_name="A", last_name="In") in_member = Member.objects.create(first_name="A", last_name="In")
out_member = Member.objects.create(first_name="B", last_name="Out") out_member = Member.objects.create(first_name="B", last_name="Out")

View File

@@ -349,28 +349,29 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
for attendance in attendances for attendance in attendances
] ]
# Referee sign-ups are scoped to self.me only, never managed_people -- # Referee sign-ups are scoped to every managed person, same as the
# a referee is an adult with their own account, not something a # RSVP rows above -- a referee-eligible child is exactly as real as
# parent does on a child's behalf, unlike every RSVP row above. # a referee-eligible parent, and a parent signing a kid up to
# Merged into the same chronological list (own dict shape, no # referee is no different from answering an RSVP on their behalf.
# pill_class/member) rather than a separate section, so it reads on # Merged into the same chronological list (own dict shape) rather
# the actual day it falls on -- distinct styling is what sets it # than a separate section, so it reads on the actual day it falls
# apart (mobile/_calendar_referee_row.html), not a different place # on -- distinct styling is what sets it apart (mobile/
# on the screen. Declined invites are dropped; accepted ones stay # _calendar_referee_row.html), not a different place on the screen.
# visible as a confirmed commitment. # Declined invites are dropped; accepted ones stay visible as a
if self.me is not None and kind_filter != "training": # confirmed commitment.
if self.managed_people and kind_filter != "training":
signups = ( signups = (
RefereeSignup.objects.filter( RefereeSignup.objects.filter(
member=self.me, member__in=self.managed_people,
event__club=self.request.club, event__club=self.request.club,
event__cancelled=False, event__cancelled=False,
event__start__gte=now, event__start__gte=now,
status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED], status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED],
) )
.select_related("event", "event__location", "event__opponent") .select_related("event", "event__location", "event__opponent", "member")
.prefetch_related("event__teams") .prefetch_related("event__teams")
) )
rows += [{"event": signup.event, "referee_signup": signup} for signup in signups] rows += [{"event": signup.event, "referee_signup": signup, "referee_member": signup.member if show_member else None} for signup in signups]
rows.sort(key=lambda row: row["event"].start) rows.sort(key=lambda row: row["event"].start)
@@ -395,29 +396,33 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View): class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
"""Accept/decline a referee invite -- from a Calendar row """Accept/decline a referee invite, for self.me or any managed person
(mobile/_calendar_referee_row.html) or the same event's own detail page (a referee-eligible child is exactly as real as a referee-eligible
(event_detail.html's own "Refereeing" card, for the same signup). Routes parent) -- from a Calendar row (mobile/_calendar_referee_row.html) or
through events.services.referees.accept_referee_signup/ the same event's own detail page (event_detail.html's own "Refereeing"
decline_referee_signup, so capacity is enforced in the one place the card, for the same signup). Routes through events.services.referees.
desktop admin flow already enforces it, and the referee-management accept_referee_signup/decline_referee_signup, so capacity is enforced
screen sees the result with no separate sync step. Boosted (no explicit in the one place the desktop admin flow already enforces it, and the
hx-boost="false") -- unlike event_detail's own RSVP forms, nothing here referee-management screen sees the result with no separate sync step.
is Alpine-owned/toggled, so there's no htmx/Alpine conflict to dodge.""" Boosted (no explicit hx-boost="false") -- unlike event_detail's own
RSVP forms, nothing here is Alpine-owned/toggled, so there's no
htmx/Alpine conflict to dodge."""
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member=self.me, event__club=request.club) signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member__in=self.managed_people, event__club=request.club)
response = request.POST.get("response") response = request.POST.get("response")
if response == "accept": if response == "accept":
try: try:
accept_referee_signup(signup) accept_referee_signup(signup)
notify(request, f"s|{_('Referee sign-up confirmed')}|{_('Thanks for signing up -- see you there.')}") body = _("%(name)s is confirmed to referee -- see you there.") % {"name": signup.member.get_full_name()}
notify(request, f"s|{_('Referee sign-up confirmed')}|{body}")
except RefereeAssignmentError as exc: except RefereeAssignmentError as exc:
notify(request, f"e|{_('Could not sign you up')}|{exc}") notify(request, f"e|{_('Could not sign up')}|{exc}")
elif response == "decline": elif response == "decline":
decline_referee_signup(signup) decline_referee_signup(signup)
notify(request, f"s|{_('Declined')}|{_('No problem -- thanks for letting us know.')}") body = _("%(name)s won't be refereeing this one -- thanks for letting us know.") % {"name": signup.member.get_full_name()}
notify(request, f"s|{_('Declined')}|{body}")
else: else:
return HttpResponseBadRequest(_("Unknown response.")) return HttpResponseBadRequest(_("Unknown response."))
@@ -461,13 +466,15 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
lineup = Lineup.objects.filter(event=event, published_at__isnull=False).first() lineup = Lineup.objects.filter(event=event, published_at__isnull=False).first()
lineup_categories = selected_members_by_position(lineup) if lineup is not None else [] lineup_categories = selected_members_by_position(lineup) if lineup is not None else []
# Same self.me-only scope as the Calendar row this mirrors # Same managed_people scope as the Calendar row this mirrors (mobile/
# (mobile/_calendar_referee_row.html) -- a referee is an adult # _calendar_referee_row.html) -- a referee-eligible child is exactly
# acting on their own behalf, never something a parent does for a # as real as a referee-eligible parent. Declined signups are
# managed child. Declined signups are excluded -- nothing left to do. # excluded -- nothing left to do. Rare in practice (a game usually
referee_signup = None # has one eligible referee per family, if any), so this is a plain
if self.me is not None: # list rather than the "Your answers" card's counts-only aggregate.
referee_signup = RefereeSignup.objects.filter(event=event, member=self.me, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).first() referee_signups = []
if self.managed_people:
referee_signups = list(RefereeSignup.objects.filter(event=event, member__in=self.managed_people, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).select_related("member"))
your_answers = [] your_answers = []
if self.managed_people: if self.managed_people:
@@ -513,7 +520,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
rsvp_closed=rsvp_closed, rsvp_closed=rsvp_closed,
lineup=lineup, lineup=lineup,
lineup_categories=lineup_categories, lineup_categories=lineup_categories,
referee_signup=referee_signup, referee_signups=referee_signups,
your_answers=your_answers, your_answers=your_answers,
squad_summary=squad_summary, squad_summary=squad_summary,
**kwargs, **kwargs,