Make the whole page scroll, show referee sign-up on the event page too

The app shell now scrolls as one page (body/document) instead of boxing
content inside an inner overflow-auto <main> under a fixed header/tab bar --
header and tab bar are sticky instead, which reads as a single intuitive
scroll (most noticeable on Calendar's long agenda) and, as a side effect,
means htmx's boosted-navigation scroll-to-top actually resets what the user
sees. The inner "This week"/"Today" sticky sub-headers are no longer sticky
themselves, since they'd otherwise collide with the now-sticky app header.

Event detail also shows the same referee Accept/Decline card the Calendar
row offers, for a signed-in account with a pending or accepted sign-up on
that game -- not just on the Calendar. Both the calendar row's and the
event page's Accept/Decline forms are now htmx-boosted (no hx-boost="false")
since neither is Alpine-toggled, so a response no longer forces a full page
reload.

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:08:17 +02:00
parent 875250366e
commit e349fbbf03
9 changed files with 133 additions and 26 deletions

View File

@@ -10,9 +10,11 @@
on their own behalf, not something a parent does for a child.
Expects ``row`` ({event, referee_signup}) in scope. Accept/Decline are
two small forms, not a single multi-button one, so hx-boost="false" can
sit on each without a formaction/htmx-boost mismatch (see event_detail.
html's own top-of-file comment for that failure mode).
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">
@@ -35,12 +37,12 @@
<span class="pill pill-referee shrink-0">{% trans "Confirmed" %}</span>
{% else %}
<div class="flex shrink-0 gap-1.5">
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}" hx-boost="false">
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}">
{% csrf_token %}
<input type="hidden" name="response" value="accept">
<button type="submit" class="pill pill-referee">{% trans "I'll ref" %}</button>
</form>
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}" hx-boost="false">
<form method="post" action="{% url "mobile:referee_signup_respond" row.referee_signup.pk %}">
{% csrf_token %}
<input type="hidden" name="response" value="decline">
<button type="submit" class="pill pill-neutral">{% trans "Can't" %}</button>

View File

@@ -23,6 +23,14 @@
the OLD shell's <body> class/attributes in place under the NEW shell's
content. See coach/base.html's own comment for the same rule mirrored
the other direction.
The page itself scrolls (body/document), not a boxed-in <main> -- header
and tab bar are sticky (top-0/bottom-0) rather than the earlier fixed-
height-shell-with-an-inner-overflow-auto-region approach, which read as
two separate scrollable areas nested inside each other (most noticeable
on Calendar's long agenda). A side benefit: htmx's boosted navigation
scrolls the window back to top by default, which now actually resets
what the user sees -- with the old inner <main> scroll it didn't.
{% endcomment %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
@@ -62,8 +70,8 @@
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="flex h-screen flex-col overflow-hidden 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">
<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 }}">
<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" %}">
{% if club.logo %}
@@ -95,7 +103,7 @@
{% block header_extra %}{% endblock header_extra %}
</header>
<main class="flex-1 overflow-y-auto">
<main class="flex-1">
<div class="flex flex-col gap-4 px-4 py-4">
{% if messages %}
<div class="flex flex-col gap-2">
@@ -109,7 +117,7 @@
</div>
</main>
<nav class="tab-bar">
<nav class="tab-bar sticky bottom-0 z-20">
<a class="tab-bar-item {% if active_tab == "home" %}tab-bar-item-active{% endif %}" href="{% url "mobile:home" %}">
{% lucide "house" size=21 %}
<span class="tab-bar-label">{% trans "Home" %}</span>

View File

@@ -60,7 +60,7 @@
{% else %}
{% if this_week %}
<div>
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
<div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in this_week %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
@@ -71,7 +71,7 @@
{% if next_week %}
<div>
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
<div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in next_week %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
@@ -82,7 +82,7 @@
{% for month in later_months %}
<div>
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
<div class="bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in month.rows %}
{% if row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}

View File

@@ -58,6 +58,34 @@
{% endif %}
</div>
{% if referee_signup %}
<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 %}
</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 %}
{% if lineup %}
<div class="m-card p-4">
<div class="flex items-center justify-between">
@@ -198,7 +226,7 @@
</div>
{% endif %}
{% if not your_answers and not squad_summary %}
{% if not your_answers and not squad_summary and not lineup and not referee_signup %}
<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

@@ -51,7 +51,7 @@
{% else %}
{% if today %}
<div>
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Today" %}</div>
<div class="bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Today" %}</div>
<div class="flex flex-col gap-2">
{% for row in today %}
{% include "mobile/_notification_row.html" %}
@@ -62,7 +62,7 @@
{% if earlier_this_week %}
<div>
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Earlier this week" %}</div>
<div class="bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Earlier this week" %}</div>
<div class="flex flex-col gap-2">
{% for row in earlier_this_week %}
{% include "mobile/_notification_row.html" %}
@@ -73,7 +73,7 @@
{% if older %}
<div>
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Older" %}</div>
<div class="bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Older" %}</div>
<div class="flex flex-col gap-2">
{% for row in older %}
{% include "mobile/_notification_row.html" %}

View File

@@ -1097,9 +1097,9 @@ class RefereeSignupRespondViewTests(TestCase):
cls.game = Event.objects.create(club=cls.club, title="Home game", kind=Event.EventKind.GAME, location=cls.home_ground, start=timezone.now() + datetime.timedelta(days=1))
cls.game.teams.add(cls.team)
def _post(self, response_value):
def _post(self, response_value, **extra):
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
return self.client.post(reverse("mobile:referee_signup_respond", kwargs={"signup_id": signup.pk}), {"response": response_value}, HTTP_HOST="ajax-united.rosterchief.app")
return self.client.post(reverse("mobile:referee_signup_respond", kwargs={"signup_id": signup.pk}), {"response": response_value, **extra}, HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._post("accept")
@@ -1137,6 +1137,13 @@ class RefereeSignupRespondViewTests(TestCase):
signup = RefereeSignup.objects.get(event=self.game, member=self.member)
self.assertEqual(signup.status, RefereeSignup.Status.DECLINED)
def test_next_event_detail_redirects_back_to_the_event_instead_of_calendar(self):
self.client.force_login(self.user)
response = self._post("accept", next="event_detail")
self.assertRedirects(response, reverse("mobile:event_detail", kwargs={"pk": self.game.pk}), fetch_redirect_response=False)
def test_cannot_respond_to_someone_elses_signup(self):
stranger_user = User.objects.create_user(email="stranger@example.com", password="pw-secret-123")
Member.objects.create(first_name="Not", last_name="You", email="stranger@example.com", user=stranger_user)
@@ -1299,6 +1306,42 @@ class EventDetailScreenTests(TestCase):
self.assertNotContains(response, 'name="status" value="dropout"')
def test_no_referee_card_when_not_invited(self):
self.client.force_login(self.user)
response = self._get()
self.assertIsNone(response.context["referee_signup"])
self.assertNotContains(response, "Refereeing")
def test_pending_referee_invite_shows_accept_decline(self):
RefereeSignup.objects.create(event=self.event, member=self.member, status=RefereeSignup.Status.INVITED)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Refereeing")
self.assertContains(response, "I'll ref")
self.assertNotContains(response, "Confirmed")
def test_accepted_referee_signup_shows_a_confirmed_pill_with_no_actions(self):
RefereeSignup.objects.create(event=self.event, member=self.member, status=RefereeSignup.Status.ACCEPTED)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Confirmed")
self.assertNotContains(response, "I'll ref")
def test_declined_referee_signup_is_not_shown(self):
RefereeSignup.objects.create(event=self.event, member=self.member, status=RefereeSignup.Status.DECLINED)
self.client.force_login(self.user)
response = self._get()
self.assertIsNone(response.context["referee_signup"])
self.assertNotContains(response, "Refereeing")
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

@@ -395,12 +395,15 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
"""Accept/decline a referee invite from a Calendar row
(mobile/_calendar_referee_row.html) -- 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."""
"""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."""
def post(self, request, *args, **kwargs):
signup = get_object_or_404(RefereeSignup, pk=kwargs["signup_id"], member=self.me, event__club=request.club)
@@ -418,6 +421,8 @@ class RefereeSignupRespondView(PersonScopeMixin, LoginRequiredMixin, View):
else:
return HttpResponseBadRequest(_("Unknown response."))
if request.POST.get("next") == "event_detail":
return HttpResponseRedirect(reverse("mobile:event_detail", kwargs={"pk": signup.event_id}))
return HttpResponseRedirect(reverse("mobile:calendar"))
@@ -456,6 +461,14 @@ 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()
your_answers = []
if self.managed_people:
managed_ids = [person.pk for person in self.managed_people]
@@ -494,7 +507,17 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"no_reply_pct": round(100 * counts["no_reply_count"] / total),
}
return super().get_context_data(screen_title=event.title, event=event, rsvp_closed=rsvp_closed, lineup=lineup, lineup_categories=lineup_categories, your_answers=your_answers, squad_summary=squad_summary, **kwargs)
return super().get_context_data(
screen_title=event.title,
event=event,
rsvp_closed=rsvp_closed,
lineup=lineup,
lineup_categories=lineup_categories,
referee_signup=referee_signup,
your_answers=your_answers,
squad_summary=squad_summary,
**kwargs,
)
def post(self, request, *args, **kwargs):
status = request.POST.get("status")