From 00bd46dfebc4296e4a082f74be7767a9221219a5 Mon Sep 17 00:00:00 2001 From: Ben Klang Date: Sun, 21 Jun 2026 15:45:09 -0400 Subject: [PATCH 1/4] Add gig attendance-taking feature This feature allows band admins to track who actually showed up at a gig. This can be used to know whether the band is waiting for more people to arrive, and can be used to help determine gig payouts later. --- gig/helpers.py | 31 +++ ...ken_at_gig_attendance_taken_by_and_more.py | 41 ++++ gig/models.py | 11 ++ gig/tests.py | 92 +++++++++ gig/urls.py | 2 + gig/views.py | 20 ++ templates/gig/attendance_check.html | 1 + templates/gig/attendance_row.html | 16 ++ templates/gig/gig_attendance.html | 180 ++++++++++++++++++ templates/gig/gig_detail.html | 8 + 10 files changed, 402 insertions(+) create mode 100644 gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py create mode 100644 templates/gig/attendance_check.html create mode 100644 templates/gig/attendance_row.html create mode 100644 templates/gig/gig_attendance.html diff --git a/gig/helpers.py b/gig/helpers.py index 1208aa86..6dfab841 100644 --- a/gig/helpers.py +++ b/gig/helpers.py @@ -58,6 +58,19 @@ def decorated(request, pk, *args, **kw): return func(request, p, *args, **kw) return decorated +def plan_band_admin_required(func): + """ Like plan_editor_required, but attendance can only be taken by a band admin + (or superuser) -- not by the member themselves. """ + def decorated(request, pk, *args, **kw): + # Imported lazily: a module-load import creates a circular dependency + # (member.helpers -> band.helpers -> gig.helpers). + from member.helpers import has_band_admin + p = get_object_or_404(Plan, pk=pk) + if not has_band_admin(request.user, p.assoc.band): + return HttpResponseForbidden() + return func(request, p, *args, **kw) + return decorated + @login_required @plan_editor_required @@ -89,6 +102,24 @@ def update_plan_section(request, plan, val): plan.save() return HttpResponse(status=204) +@login_required +@plan_band_admin_required +def toggle_attendance(request, plan): + """ Toggle whether a member attended the gig. Band-admin only; persists per tap + via htmx and returns the refreshed roster row fragment. """ + plan.attended = not plan.attended + plan.save() + + # Record who most recently changed attendance for this gig, and when. + gig = plan.gig + gig.attendance_taken_by = request.user + gig.attendance_taken_at = now() + gig.save() + + # Return only the checkmark icon: the row element stays put so its (JS-managed) + # visibility/filter state isn't disturbed by the swap. + return render(request, 'gig/attendance_check.html', {'plan': plan}) + # @login_required def update_plan_default_section(assoc): diff --git a/gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py b/gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py new file mode 100644 index 00000000..0d487b91 --- /dev/null +++ b/gig/migrations/0035_gig_attendance_taken_at_gig_attendance_taken_by_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.30 on 2026-06-21 18:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('gig', '0034_alter_gig_safe_date_alter_gig_safe_enddate_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='gig', + name='attendance_taken_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='gig', + name='attendance_taken_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='historicalgig', + name='attendance_taken_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='historicalgig', + name='attendance_taken_by', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='plan', + name='attended', + field=models.BooleanField(default=False), + ), + ] diff --git a/gig/models.py b/gig/models.py index 68034e2e..8e1f316b 100644 --- a/gig/models.py +++ b/gig/models.py @@ -73,6 +73,11 @@ class Plan(models.Model): status = models.IntegerField(choices=PlanStatusChoices.choices, default=PlanStatusChoices.NO_PLAN) status_changed = models.BooleanField(default=False) + # whether the member actually showed up at the gig. Set by a band admin via the + # attendance-taking view. False is treated as "absent / not yet marked"; whether + # attendance was ever taken at all is recorded on the Gig (attendance_taken_at). + attended = models.BooleanField(default=False) + @property def status_string(self): return PlanStatusChoices(self.status).label @@ -207,6 +212,12 @@ class Gig(AbstractEvent): # Flag whether band members can change their plans plans_locked = models.BooleanField(default=False) + # Attendance tracking: who recorded attendance for this gig, and when. These stay + # null until a band admin marks the first member present in the attendance view. + attendance_taken_by = models.ForeignKey('member.Member', blank=True, null=True, + related_name='+', on_delete=models.SET_NULL) + attendance_taken_at = models.DateTimeField(null=True, blank=True) + # for use in calfeeds cal_feed_id = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) diff --git a/gig/tests.py b/gig/tests.py index f81bd17c..46a25297 100644 --- a/gig/tests.py +++ b/gig/tests.py @@ -1614,3 +1614,95 @@ def test_api_throttle(self): self.assertEqual(response.status_code, 429) self.assertTrue(count <= THROTTLE_PER_SECOND) + +class AttendanceTest(GigTestBase): + """ Tests for the band-admin attendance-taking view and toggle endpoint. """ + + def _set_status(self, plan, status): + plan.status = status + plan.save() + return plan + + # --- attendance page (view) permissions --- + + def test_attendance_view_admin(self): + g, _, _ = self.assoc_joe_and_create_gig() + self.client.force_login(self.band_admin) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + self.assertEqual(resp.status_code, 200) + + def test_attendance_view_superuser(self): + g, _, _ = self.assoc_joe_and_create_gig() + self.client.force_login(self.super) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + self.assertEqual(resp.status_code, 200) + + def test_attendance_view_non_admin_member_forbidden(self): + # joe is a confirmed member but not a band admin + g, _, _ = self.assoc_joe_and_create_gig() + self.client.force_login(self.joeuser) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + self.assertEqual(resp.status_code, 403) + + def test_attendance_view_anonymous_redirected(self): + g, _, _ = self.assoc_joe_and_create_gig() + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + # LoginRequiredMixin redirects anonymous users to login + self.assertEqual(resp.status_code, 302) + + def test_attendance_default_roster_marks_attending(self): + """ The roster rows carry the 'attending' flag for DEFINITELY/PROBABLY plans + so the client-side default filter can show just those. """ + g, a, p = self.assoc_joe_and_create_gig() + self._set_status(p, PlanStatusChoices.DEFINITELY) + self.client.force_login(self.band_admin) + resp = self.client.get(reverse("gig-attendance", args=[g.id])) + content = resp.content.decode("utf-8") + self.assertIn(f'id="attendance-row-{p.id}"', content) + self.assertIn('data-attending="1"', content) + + # --- toggle endpoint --- + + def test_toggle_attendance_admin(self): + g, a, p = self.assoc_joe_and_create_gig() + self.assertFalse(p.attended) + self.client.force_login(self.band_admin) + resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + self.assertEqual(resp.status_code, 200) + p.refresh_from_db() + self.assertTrue(p.attended) + # toggling again flips it back + resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + self.assertEqual(resp.status_code, 200) + p.refresh_from_db() + self.assertFalse(p.attended) + + def test_toggle_attendance_stamps_gig(self): + g, a, p = self.assoc_joe_and_create_gig() + self.assertIsNone(g.attendance_taken_at) + self.assertIsNone(g.attendance_taken_by) + self.client.force_login(self.band_admin) + self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + g.refresh_from_db() + self.assertIsNotNone(g.attendance_taken_at) + self.assertEqual(g.attendance_taken_by, self.band_admin) + first_stamp = g.attendance_taken_at + # a later toggle by a different admin updates the stamp to the last editor + Assoc.objects.create( + member=self.super, band=self.band, is_admin=True, + status=AssocStatusChoices.CONFIRMED) + with freeze_time(first_stamp + timedelta(minutes=5)): + self.client.force_login(self.super) + self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + g.refresh_from_db() + self.assertEqual(g.attendance_taken_by, self.super) + self.assertGreater(g.attendance_taken_at, first_stamp) + + def test_toggle_attendance_non_admin_forbidden(self): + g, a, p = self.assoc_joe_and_create_gig() + self.client.force_login(self.joeuser) + resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) + self.assertEqual(resp.status_code, 403) + p.refresh_from_db() + self.assertFalse(p.attended) + diff --git a/gig/urls.py b/gig/urls.py index 8377b409..bdb3ad75 100644 --- a/gig/urls.py +++ b/gig/urls.py @@ -36,8 +36,10 @@ path('/printallplans', views.PrintPlansView.as_view(), {'all':True}, name='gig-print-all-plans'), path('/printconfirmedplans', views.PrintPlansView.as_view(), {'all':False}, name='gig-print-confirmed-plans'), path('/printsetlist', views.PrintSetlistView.as_view(), name='gig-print-setlist'), + path('/attendance', views.AttendanceView.as_view(), name='gig-attendance'), path('plan//update/', helpers.update_plan, name='plan-update'), + path('plan//attendance', helpers.toggle_attendance, name='plan-attendance-toggle'), path('plan//feedback/', helpers.update_plan_feedback, name='plan-update-feedback'), path('plan//comment', helpers.update_plan_comment, name='plan-update-comment'), path('plan//section/', helpers.update_plan_section, name='plan-update-section'), diff --git a/gig/views.py b/gig/views.py index f36e9465..d0302e4a 100644 --- a/gig/views.py +++ b/gig/views.py @@ -86,6 +86,26 @@ def get_context_data(self, **kwargs): return context +class AttendanceView(LoginRequiredMixin, UserPassesTestMixin, generic.DetailView): + """ Mobile-friendly view for band admins to take attendance at a gig. + + The full roster is rendered once; searching, the attending/all toggle, sorting + and the running count are all handled client-side so the page works well on a + phone with a flaky connection. Marking a member present persists per-tap via htmx. + """ + model = Gig + template_name = 'gig/gig_attendance.html' + + def test_func(self): + gig = get_object_or_404(Gig, id=self.kwargs['pk']) + return has_band_admin(self.request.user, gig.band) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['gig_ordered_member_plans'] = self.object.member_plans.all().order_by('assoc__member__display_name') + return context + + class CreateView(LoginRequiredMixin, UserPassesTestMixin, generic.CreateView): model = Gig form_class = GigForm diff --git a/templates/gig/attendance_check.html b/templates/gig/attendance_check.html new file mode 100644 index 00000000..0a72fadc --- /dev/null +++ b/templates/gig/attendance_check.html @@ -0,0 +1 @@ +{% if plan.attended %}{% else %}{% endif %} diff --git a/templates/gig/attendance_row.html b/templates/gig/attendance_row.html new file mode 100644 index 00000000..b2e5d539 --- /dev/null +++ b/templates/gig/attendance_row.html @@ -0,0 +1,16 @@ +{% load i18n %} + diff --git a/templates/gig/gig_attendance.html b/templates/gig/gig_attendance.html new file mode 100644 index 00000000..b13ce308 --- /dev/null +++ b/templates/gig/gig_attendance.html @@ -0,0 +1,180 @@ +{% extends "base/go3base.html" %} +{% load i18n static %} + +{% block title %}{% trans "Attendance" %} · {{ gig.title }}{% endblock title %} + +{% block headcontent %} + +{% endblock headcontent %} + +{% block content %} +
+
+ + {% trans "Back to gig" %} + +
{% trans "Attendance" %}
+
{{ gig.title }}{% if gig.date %} · {{ gig.date|date:"DATE_FORMAT" }}{% endif %}
+ + + +
+ +
+ 0 {% trans "present" %} / + 0 {% trans "shown" %} +
+
+
+ +
+ {% for plan in gig_ordered_member_plans %} + {% include 'gig/attendance_row.html' with plan=plan %} + {% empty %} +
{% trans "No members to show." %}
+ {% endfor %} +
+
+{% endblock content %} + +{% block localscripts %} + +{% endblock localscripts %} diff --git a/templates/gig/gig_detail.html b/templates/gig/gig_detail.html index d7a3c2a8..2b5267ad 100644 --- a/templates/gig/gig_detail.html +++ b/templates/gig/gig_detail.html @@ -254,6 +254,14 @@ {% include "gig/watching.html" %} {% endif %} + {% if the_user_is_band_admin %} + + + + {% trans "Take Attendance" %} + + + {% endif %} {% if gig.plans_locked %} From e05568e5f3b42482019f1cd4d0fded7d88eff9a7 Mon Sep 17 00:00:00 2001 From: Ben Klang Date: Sun, 21 Jun 2026 16:00:33 -0400 Subject: [PATCH 2/4] Group members by section --- gig/views.py | 5 ++- templates/gig/attendance_row.html | 1 - templates/gig/gig_attendance.html | 64 +++++++++++++++++++++++++++---- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/gig/views.py b/gig/views.py index d0302e4a..774c61ce 100644 --- a/gig/views.py +++ b/gig/views.py @@ -102,7 +102,10 @@ def test_func(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['gig_ordered_member_plans'] = self.object.member_plans.all().order_by('assoc__member__display_name') + # Order by section then name so the template can regroup by section, just like + # the gig detail / print plan list pages. The order MUST group by section. + context['gig_ordered_member_plans'] = self.object.member_plans.all().order_by( + 'section', Lower('assoc__member__display_name')) return context diff --git a/templates/gig/attendance_row.html b/templates/gig/attendance_row.html index b2e5d539..092cbd5b 100644 --- a/templates/gig/attendance_row.html +++ b/templates/gig/attendance_row.html @@ -10,7 +10,6 @@ {% include 'gig/attendance_check.html' %} {{ plan.assoc.member.display_name }} - {% if plan.section %}{{ plan.section.name }}{% endif %} {% include 'gig/plan_icon.html' with plan_value=plan.status %} diff --git a/templates/gig/gig_attendance.html b/templates/gig/gig_attendance.html index b13ce308..d0e642a4 100644 --- a/templates/gig/gig_attendance.html +++ b/templates/gig/gig_attendance.html @@ -1,5 +1,5 @@ {% extends "base/go3base.html" %} -{% load i18n static %} +{% load i18n static gig_extras %} {% block title %}{% trans "Attendance" %} · {{ gig.title }}{% endblock title %} @@ -50,6 +50,26 @@ background-color: #17a2b8; border-color: #17a2b8; } + /* Section grouping headers, styled to read like the .gomlabel section labels + used on the gig detail page. They stick just beneath the (already sticky) + attendance header so the current section stays visible while scrolling a + long section; its `top` is set in JS to the attendance header's height. */ + .att-section-header { + position: sticky; + z-index: 1005; + background-color: #f1f3f5; + border-bottom: 1px solid #dee2e6; + font-weight: 600; + color: #6c757d; + text-transform: uppercase; + font-size: 0.8rem; + letter-spacing: 0.03em; + padding: 0.5rem 1rem; + margin-top: 0.5rem; + } + .att-section-group.att-hidden { + display: none !important; + } {% endblock headcontent %} @@ -76,12 +96,28 @@
{% trans "Attendance" %}
-
- {% for plan in gig_ordered_member_plans %} - {% include 'gig/attendance_row.html' with plan=plan %} +
+ {% regroup gig_ordered_member_plans by section as plans_by_section %} + {% with multi_section=gig.band.sections.all|length %} + {% for section in gig.band.sections.all %} + {% with section_plans=plans_by_section|lookup_plans:section %} + {% if section_plans %} +
+ {% if multi_section > 1 %} +
{{ section.display_name }}
+ {% endif %} +
+ {% for plan in section_plans %} + {% include 'gig/attendance_row.html' with plan=plan %} + {% endfor %} +
+
+ {% endif %} + {% endwith %} {% empty %}
{% trans "No members to show." %}
{% endfor %} + {% endwith %}
{% endblock content %} @@ -98,11 +134,16 @@
{% trans "Attendance" %}
var navbar = document.querySelector('.navbar.sticky-top'); // Pin the attendance header flush beneath the (sticky) site navbar so it can't - // scroll up over it. Re-measure on resize since the navbar height can change. + // scroll up over it, and pin each section header just beneath the attendance + // header. Re-measure on resize since heights can change with layout/width. function positionHeader() { - if (header && navbar) { - header.style.top = navbar.offsetHeight + 'px'; + var navH = navbar ? navbar.offsetHeight : 0; + if (header) { + header.style.top = navH + 'px'; } + var sectionTop = navH + (header ? header.offsetHeight : 0); + Array.prototype.slice.call(document.querySelectorAll('.att-section-header')) + .forEach(function (h) { h.style.top = sectionTop + 'px'; }); } positionHeader(); window.addEventListener('resize', positionHeader); @@ -136,6 +177,15 @@
{% trans "Attendance" %}
}); presentCount.textContent = present; visibleCount.textContent = visible; + + // Hide a section header when the filter has hidden all of its rows. + Array.prototype.slice.call(list.querySelectorAll('.att-section-group')) + .forEach(function (group) { + var anyVisible = Array.prototype.slice + .call(group.querySelectorAll('.att-row')) + .some(function (row) { return !row.classList.contains('att-hidden'); }); + group.classList.toggle('att-hidden', !anyVisible); + }); } search.addEventListener('input', applyFilter); From b89bcd1166273e4efb606e7b5cf78019076505ee Mon Sep 17 00:00:00 2001 From: Ben Klang Date: Sun, 21 Jun 2026 16:12:23 -0400 Subject: [PATCH 3/4] Underscore unused variable assignment --- gig/tests.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gig/tests.py b/gig/tests.py index 46a25297..7b9380c2 100644 --- a/gig/tests.py +++ b/gig/tests.py @@ -1653,7 +1653,7 @@ def test_attendance_view_anonymous_redirected(self): def test_attendance_default_roster_marks_attending(self): """ The roster rows carry the 'attending' flag for DEFINITELY/PROBABLY plans so the client-side default filter can show just those. """ - g, a, p = self.assoc_joe_and_create_gig() + g, _, p = self.assoc_joe_and_create_gig() self._set_status(p, PlanStatusChoices.DEFINITELY) self.client.force_login(self.band_admin) resp = self.client.get(reverse("gig-attendance", args=[g.id])) @@ -1664,7 +1664,7 @@ def test_attendance_default_roster_marks_attending(self): # --- toggle endpoint --- def test_toggle_attendance_admin(self): - g, a, p = self.assoc_joe_and_create_gig() + _, _, p = self.assoc_joe_and_create_gig() self.assertFalse(p.attended) self.client.force_login(self.band_admin) resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) @@ -1678,7 +1678,7 @@ def test_toggle_attendance_admin(self): self.assertFalse(p.attended) def test_toggle_attendance_stamps_gig(self): - g, a, p = self.assoc_joe_and_create_gig() + g, _, p = self.assoc_joe_and_create_gig() self.assertIsNone(g.attendance_taken_at) self.assertIsNone(g.attendance_taken_by) self.client.force_login(self.band_admin) @@ -1699,7 +1699,7 @@ def test_toggle_attendance_stamps_gig(self): self.assertGreater(g.attendance_taken_at, first_stamp) def test_toggle_attendance_non_admin_forbidden(self): - g, a, p = self.assoc_joe_and_create_gig() + _, _, p = self.assoc_joe_and_create_gig() self.client.force_login(self.joeuser) resp = self.client.post(reverse("plan-attendance-toggle", args=[p.id])) self.assertEqual(resp.status_code, 403) From 28ad350d2a47e719ae066017bb734011e9a2f0be Mon Sep 17 00:00:00 2001 From: Ben Klang Date: Sun, 21 Jun 2026 18:56:56 -0400 Subject: [PATCH 4/4] Tighter UI based on user feedback See more people at once on the screen --- templates/gig/gig_attendance.html | 56 ++++++++++++++++--------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/templates/gig/gig_attendance.html b/templates/gig/gig_attendance.html index d0e642a4..9bf5e14b 100644 --- a/templates/gig/gig_attendance.html +++ b/templates/gig/gig_attendance.html @@ -15,17 +15,21 @@ padding-bottom: 0.5rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06); } - /* big, finger-friendly rows */ + /* Compact but still tappable rows — kept dense so more members fit on screen. */ .att-row { - padding: 0.9rem 1rem; + padding: 0.45rem 0.75rem; border-left: 0; border-right: 0; cursor: pointer; } + /* Tighten the gap between the check icon and the name (overrides .mr-3). */ + .att-check { + margin-right: 0.6rem !important; + } .att-check i { - font-size: 1.5rem; + font-size: 1.25rem; color: #adb5bd; - width: 1.6rem; + width: 1.3rem; text-align: center; } .att-present { @@ -35,18 +39,23 @@ color: #28a745; } .att-name { - font-size: 1.05rem; + font-size: 0.95rem; + line-height: 1.2; } /* Defined after Bootstrap so it beats .d-flex's `display: flex !important`. */ .att-hidden { display: none !important; } - /* Large, finger-friendly toggle for the attending/all filter. */ - #att-show-all { - min-height: 44px; + /* Compact toggle switch for the attending/all filter. Slightly enlarged + over the Bootstrap default so it stays tappable on a phone. */ + .att-switch { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + } + .att-switch .custom-control-label { + cursor: pointer; } - #att-show-all.active { - color: #fff; + .att-switch .custom-control-input:checked ~ .custom-control-label::before { background-color: #17a2b8; border-color: #17a2b8; } @@ -62,10 +71,9 @@ font-weight: 600; color: #6c757d; text-transform: uppercase; - font-size: 0.8rem; + font-size: 0.75rem; letter-spacing: 0.03em; - padding: 0.5rem 1rem; - margin-top: 0.5rem; + padding: 0.3rem 0.75rem; } .att-section-group.att-hidden { display: none !important; @@ -79,16 +87,17 @@
{% trans "Back to gig" %} -
{% trans "Attendance" %}
-
{{ gig.title }}{% if gig.date %} · {{ gig.date|date:"DATE_FORMAT" }}{% endif %}
+
{{ gig.title }}
+
{% trans "Attendance" %}{% if gig.date %} · {{ gig.date|date:"DATE_FORMAT" }}{% endif %}
- +
+ + +
0 {% trans "present" %} / 0 {% trans "shown" %} @@ -153,7 +162,7 @@
{% trans "Attendance" %}
} function showingAll() { - return showAll.getAttribute('aria-pressed') === 'true'; + return showAll.checked; } // Search + attending/all filter + running counts, all client-side. @@ -191,14 +200,7 @@
{% trans "Attendance" %}
search.addEventListener('input', applyFilter); // Toggle between "attending only" (default) and "all members". - showAll.addEventListener('click', function () { - var next = !showingAll(); - showAll.setAttribute('aria-pressed', next ? 'true' : 'false'); - showAll.classList.toggle('active', next); - var icon = showAll.querySelector('i'); - if (icon) { icon.className = next ? 'fas fa-check-square' : 'far fa-square'; } - applyFilter(); - }); + showAll.addEventListener('change', applyFilter); // Optimistic toggle: flip the visual state immediately on tap; htmx then posts // and swaps in the authoritative row markup. Reverted below if the request fails.