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

@@ -221,7 +221,16 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
deadline = hero_attendance.event.deadline
rsvp_closed = deadline is not None and deadline < now
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
# Deadline already passed -> replying is no longer possible (see
# EventDetailView.post's own deadline check), so it doesn't belong
# in a "still needs a reply" list -- unlike hero_attendance above,
# which always shows the true next event regardless of RSVP state
# and falls back to a read-only pill once its own deadline closes.
needs_answer_qs = (
upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE])
.filter(Q(event__deadline__isnull=True) | Q(event__deadline__gte=now))
.order_by("event__start")
)
if hero_attendance is not None:
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
needs_answer_total = needs_answer_qs.count()
@@ -444,14 +453,30 @@ class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
if event.deadline is not None and event.deadline < timezone.now():
return HttpResponseBadRequest(_("Replies are closed for this event."))
# A reason is only ever meaningful attached to "Out" -- clearing it the
# moment someone flips back to In/Maybe avoids a stale "sick" note
# hanging around under an answer it no longer explains. Private by
# A reason is only ever meaningful attached to Out/Maybe -- clearing it
# the moment someone flips to In avoids a stale "sick" note hanging
# around under an answer it no longer explains. Private by
# construction, not by a visibility flag: nothing renders another
# member's own note anywhere -- only this member/family's own screens
# (event_detail's "Your answers") and Coach mode's bench attendance
# (mobile/templates/mobile/coach/attendance.html) ever read it.
note = request.POST.get("note", "").strip() if status == Attendance.AttendanceStatus.ABSENT else ""
note = ""
if status == Attendance.AttendanceStatus.ABSENT:
note = request.POST.get("note", "").strip()
# Rejects blank and punctuation-only "answers" (a bare ".", "-",
# "??") -- mandatory for Out specifically, unlike Maybe below.
# Backend-scoped, not the pretty inline-error UX this codebase
# gives ModelForm submissions elsewhere -- matches this view's
# own existing style (see "Unknown RSVP status"/"Replies are
# closed" above, both plain 400s a normal user should never
# actually see, since the template only ever offers Out through
# the reason form to begin with).
if not any(char.isalnum() for char in note):
return HttpResponseBadRequest(_("Please enter a reason."))
elif status == Attendance.AttendanceStatus.MAYBE:
# Optional here -- Maybe doesn't owe anyone an explanation the way
# a firm no does, but the same field carries it if given one.
note = request.POST.get("note", "").strip()
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status, "note": note})
if request.POST.get("next") == "event_detail":