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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user