Fix RSVP buttons broken by hx-boost; require a real Out reason; add optional Maybe reason

The In/Out hero buttons (and Out's reason confirm) stopped working: Alpine
owns the toggle between a row's two sibling forms (buttons vs. the reason
prompt), and hx-boost="true" on <body> had htmx *also* intercepting the
same submit -- both ended up fighting over it. Fixed by marking every
write-action <form> across the mobile app hx-boost="false" (link
navigation, where the smooth-navigation feature actually matters, is
untouched). The one exception worth calling out: coach/lineup.html's form
uses three submit buttons sharing one <form> via formaction overrides --
htmx's boost reads the form's own action rather than the submitter's
formaction override, so a boosted click there would always have posted to
the wrong endpoint regardless of the Alpine conflict.

Also:
- A reason for Out is now mandatory, not just captured -- empty and
  punctuation-only "answers" (a bare ".", "-", "??") are rejected
  server-side (the authoritative check) with textarea required/minlength
  as a client-side nudge on top.
- Maybe can now carry an optional reason too, visible to the same audience
  as Out's (this member/family, and Coach mode's bench attendance) -- one
  shared reason form in event_detail.html's per-person row, its hidden
  status input following whichever of Maybe/Out was tapped.
- The 3-way In/Maybe/Out row (and the 2-way hero In/Out) now use min-w-0 on
  every button so flex-1 actually splits the row evenly -- a longer
  label's own intrinsic width was winning it a bigger share otherwise.
- HomeView's "Needs your answer" list now excludes events whose
  registration deadline has already passed -- replying is no longer
  possible there (same rule EventDetailView.post already enforces), so it
  doesn't belong in a "still needs a reply" list. hero_attendance is
  unaffected -- it always shows the true next event, falling back to a
  read-only pill once its own deadline closes.

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 14:59:23 +02:00
parent 614c35861b
commit 366239e60b
14 changed files with 160 additions and 49 deletions

View File

@@ -289,6 +289,24 @@ class HomeViewTests(TestCase):
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {awaiting, maybe})
def test_needs_your_answer_excludes_events_with_a_closed_registration_deadline(self):
# A distinct, already-answered earlier event so it becomes the hero --
# otherwise the closed-deadline event below would become the hero
# itself (still shown there, just read-only) rather than reaching
# needs_answer's own exclusion at all.
hero_event = self.make_event(title="Soonest", start=self.future)
Attendance.objects.create(event=hero_event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
closed = self.make_event(title="Deadline passed", start=self.future + datetime.timedelta(days=2), deadline=timezone.now() - datetime.timedelta(hours=1))
open_deadline = self.make_event(title="Deadline still open", start=self.future + datetime.timedelta(days=3), deadline=timezone.now() + datetime.timedelta(hours=1))
Attendance.objects.create(event=closed, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
Attendance.objects.create(event=open_deadline, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.client.force_login(self.user)
response = self._get("home")
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {open_deadline})
def test_needs_your_answer_is_capped_at_five_with_a_remaining_count(self):
# A distinct, already-answered earlier event so it becomes the hero and
# none of the seven "Practice N" events below get excluded as the hero.
@@ -524,7 +542,7 @@ class EventDetailRsvpTests(TestCase):
other_event = Event.objects.create(club=self.club, title="Away game", start=timezone.now() + datetime.timedelta(days=8))
self.client.force_login(self.user)
self._post(other_event, {"status": "absent"})
self._post(other_event, {"status": "absent", "note": "Sick"})
self.assertEqual(Attendance.objects.get(event=other_event, member=self.member).status, Attendance.AttendanceStatus.ABSENT)
@@ -565,6 +583,25 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
def test_posting_maybe_without_a_reason_is_allowed(self):
# Unlike Out, a reason is optional for Maybe -- no 400 without one.
self.client.force_login(self.user)
response = self._post(self.event, {"status": "maybe"})
self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False)
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.note, "")
def test_posting_maybe_with_a_reason_stores_the_note(self):
self.client.force_login(self.user)
self._post(self.event, {"status": "maybe", "note": "Might have to leave early"})
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.MAYBE)
self.assertEqual(self.attendance.note, "Might have to leave early")
def test_posting_absent_with_a_reason_stores_the_note(self):
self.client.force_login(self.user)
@@ -602,6 +639,31 @@ class EventDetailRsvpTests(TestCase):
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.note, "")
def test_absent_without_a_reason_is_rejected(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "absent"})
self.assertEqual(response.status_code, 400)
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
def test_absent_with_a_whitespace_only_reason_is_rejected(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "absent", "note": " \n "})
self.assertEqual(response.status_code, 400)
def test_absent_with_a_punctuation_only_reason_is_rejected(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "absent", "note": "..."})
self.assertEqual(response.status_code, 400)
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
def test_rejects_an_unknown_status_value(self):
self.client.force_login(self.user)
@@ -2158,6 +2220,16 @@ class CoachAttendanceViewTests(TestCase):
self.assertContains(response, "Anna Player")
self.assertContains(response, "9")
def test_shows_a_maybe_reason_alongside_an_absent_one(self):
self.attendance.status = Attendance.AttendanceStatus.MAYBE
self.attendance.note = "Might be a few minutes late"
self.attendance.save()
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Might be a few minutes late")
def test_save_records_check_ins_via_record_check_in(self):
self.client.force_login(self.user)