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 %}
{% comment %}
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.
Merged into the same chronological list as the account's own RSVP rows
(mobile/_calendar_row.html), but in the referee accent (assets/mobile.
css's --color-referee/.pill-referee) so it reads as a different kind of
commitment at a glance -- and scoped to self.me only (mobile/views.py's
CalendarView), never managed_people, since a referee is an adult acting
on their own behalf, not something a parent does for a child.
account (or a managed person -- a referee-eligible child is exactly as
real as a referee-eligible parent, mobile/views.py's CalendarView) is
eligible (and, once responded, confirmed) to referee for. Merged into
the same chronological list as the account's own RSVP rows (mobile/
_calendar_row.html), but in the referee accent (assets/mobile.css's
--color-referee/.pill-referee) so it reads as a different kind of
commitment at a glance.
Expects ``row`` ({event, referee_signup}) in scope. Accept/Decline are
two small forms, not a single multi-button one -- and, unlike this
app's RSVP forms, boosted (no hx-boost="false"): 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.
Expects ``row`` ({event, referee_signup, referee_member}) in scope --
referee_member is only set once there's more than one managed person to
tell apart, same rule mobile/_calendar_row.html's own ``member`` uses.
Accept/Decline are two small forms, not a single multi-button one --
and, unlike this app's RSVP forms, boosted (no hx-boost="false"):
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 %}
<div class="flex items-center gap-3 bg-white px-4 py-3">
<div class="w-9.5 shrink-0 text-center">
@@ -29,6 +31,7 @@
</div>
<div class="truncate text-xs text-muted">
{{ 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 %}
{% if row.event.location %}&middot; {{ row.event.location.name }}{% endif %}
</div>

View File

@@ -70,7 +70,7 @@
{% block extra_head %}{% endblock extra_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">
<div class="flex min-h-11 items-center gap-2.5">
<a class="flex items-center gap-2.5" href="{% url "mobile:home" %}">

View File

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

View File

@@ -58,31 +58,36 @@
{% endif %}
</div>
{% if referee_signup %}
{% if referee_signups %}
<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>
{% if referee_signup.status == "accepted" %}<span class="pill pill-referee">{% trans "Confirmed" %}</span>{% endif %}
<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">
{% 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>
{% 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>
{% endif %}
@@ -226,7 +231,7 @@
</div>
{% 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">
<p class="text-sm text-muted">{% trans "No one you manage is invited to this event." %}</p>
</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"])
class CalendarRefereeSignupTests(TestCase):
"""M3's Calendar merges in the signed-in account's own referee sign-ups
(self.me only, never managed_people) -- see mobile/_calendar_referee_row.
html and CalendarView's own docstring."""
"""M3's Calendar merges in every managed person's referee sign-ups, not
just self.me -- see mobile/_calendar_referee_row.html and CalendarView's
own docstring."""
@classmethod
def setUpTestData(cls):
@@ -1074,6 +1074,22 @@ class CalendarRefereeSignupTests(TestCase):
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"])
class RefereeSignupRespondViewTests(TestCase):
@@ -1153,6 +1169,21 @@ class RefereeSignupRespondViewTests(TestCase):
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"])
class EventDetailScreenTests(TestCase):
@@ -1311,7 +1342,7 @@ class EventDetailScreenTests(TestCase):
response = self._get()
self.assertIsNone(response.context["referee_signup"])
self.assertEqual(list(response.context["referee_signups"]), [])
self.assertNotContains(response, "Refereeing")
def test_pending_referee_invite_shows_accept_decline(self):
@@ -1339,9 +1370,24 @@ class EventDetailScreenTests(TestCase):
response = self._get()
self.assertIsNone(response.context["referee_signup"])
self.assertEqual(list(response.context["referee_signups"]), [])
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):
in_member = Member.objects.create(first_name="A", last_name="In")
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
]
# Referee sign-ups are scoped to self.me only, never managed_people --
# a referee is an adult with their own account, not something a
# parent does on a child's behalf, unlike every RSVP row above.
# Merged into the same chronological list (own dict shape, no
# pill_class/member) rather than a separate section, so it reads on
# the actual day it falls on -- distinct styling is what sets it
# apart (mobile/_calendar_referee_row.html), not a different place
# on the screen. Declined invites are dropped; accepted ones stay
# visible as a confirmed commitment.
if self.me is not None and kind_filter != "training":
# Referee sign-ups are scoped to every managed person, same as the
# RSVP rows above -- a referee-eligible child is exactly as real as
# a referee-eligible parent, and a parent signing a kid up to
# referee is no different from answering an RSVP on their behalf.
# Merged into the same chronological list (own dict shape) rather
# than a separate section, so it reads on the actual day it falls
# on -- distinct styling is what sets it apart (mobile/
# _calendar_referee_row.html), not a different place on the screen.
# Declined invites are dropped; accepted ones stay visible as a
# confirmed commitment.
if self.managed_people and kind_filter != "training":
signups = (
RefereeSignup.objects.filter(
member=self.me,
member__in=self.managed_people,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
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")
)
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)
@@ -395,29 +396,33 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
"""Accept/decline a referee invite -- from a Calendar row
(mobile/_calendar_referee_row.html) or the same event's own detail page
(event_detail.html's own "Refereeing" card, for the same signup). Routes
through events.services.referees.accept_referee_signup/
decline_referee_signup, so capacity is enforced in the one place the
desktop admin flow already enforces it, and the referee-management
screen sees the result with no separate sync step. 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."""
"""Accept/decline a referee invite, for self.me or any managed person
(a referee-eligible child is exactly as real as a referee-eligible
parent) -- from a Calendar row (mobile/_calendar_referee_row.html) or
the same event's own detail page (event_detail.html's own "Refereeing"
card, for the same signup). Routes through events.services.referees.
accept_referee_signup/decline_referee_signup, so capacity is enforced
in the one place the desktop admin flow already enforces it, and the
referee-management screen sees the result with no separate sync step.
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):
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")
if response == "accept":
try:
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:
notify(request, f"e|{_('Could not sign you up')}|{exc}")
notify(request, f"e|{_('Could not sign up')}|{exc}")
elif response == "decline":
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:
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_categories = selected_members_by_position(lineup) if lineup is not None else []
# Same self.me-only scope as the Calendar row this mirrors
# (mobile/_calendar_referee_row.html) -- a referee is an adult
# acting on their own behalf, never something a parent does for a
# managed child. Declined signups are excluded -- nothing left to do.
referee_signup = None
if self.me is not None:
referee_signup = RefereeSignup.objects.filter(event=event, member=self.me, status__in=[RefereeSignup.Status.INVITED, RefereeSignup.Status.ACCEPTED]).first()
# Same managed_people scope as the Calendar row this mirrors (mobile/
# _calendar_referee_row.html) -- a referee-eligible child is exactly
# as real as a referee-eligible parent. Declined signups are
# excluded -- nothing left to do. Rare in practice (a game usually
# has one eligible referee per family, if any), so this is a plain
# list rather than the "Your answers" card's counts-only aggregate.
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 = []
if self.managed_people:
@@ -513,7 +520,7 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
rsvp_closed=rsvp_closed,
lineup=lineup,
lineup_categories=lineup_categories,
referee_signup=referee_signup,
referee_signups=referee_signups,
your_answers=your_answers,
squad_summary=squad_summary,
**kwargs,