Bigger unread badge with a real count, and a Clear all action for notifications

The header bell's unread indicator was an 8px dot -- now a proper count
badge (capped at "9+"), matching the size/legibility of similar badges
elsewhere in the app. Notifications also gains "Clear all" next to
"Mark all read" -- a hard delete of the list (the same clear-all gesture
a phone's own notification centre uses), not another read-state flip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 17:41:14 +02:00
parent 385e2f3c38
commit 5bfb6ce76e
6 changed files with 49 additions and 8 deletions

View File

@@ -67,7 +67,11 @@
<div class="flex-1"></div>
<a class="relative flex h-11 w-11 shrink-0 items-center justify-center" href="{% url "mobile:notifications" %}" aria-label="{% trans "Notifications" %}">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
{% if unread_notification_count %}<span class="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-club" style="border: 2px solid var(--color-navy)"></span>{% endif %}
{% if unread_notification_count %}
<span class="absolute -top-1 -right-1 flex h-[19px] min-w-[19px] items-center justify-center rounded-full bg-club px-1 font-mono text-[11px] font-bold text-white" style="border: 2px solid var(--color-navy)">
{% if unread_notification_count > 9 %}9+{% else %}{{ unread_notification_count }}{% endif %}
</span>
{% endif %}
</a>
</div>
{% block header_extra %}{% endblock header_extra %}

View File

@@ -25,12 +25,23 @@
<button type="button" class="btn btn-dark shrink-0" @click="window.rosterchiefPush.subscribe(); requested = true">{% trans "Enable" %}</button>
</div>
{% if unread_notification_count %}
<form method="post" action="{% url "mobile:notifications" %}">
{% csrf_token %}
<input type="hidden" name="action" value="mark_all_read">
<button class="font-display text-xs font-extrabold tracking-wide text-club uppercase" type="submit">{% trans "Mark all read" %}</button>
</form>
{% if today or earlier_this_week or older %}
<div class="flex items-center justify-between">
{% if unread_notification_count %}
<form method="post" action="{% url "mobile:notifications" %}">
{% csrf_token %}
<input type="hidden" name="action" value="mark_all_read">
<button class="font-display text-xs font-extrabold tracking-wide text-club uppercase" type="submit">{% trans "Mark all read" %}</button>
</form>
{% else %}
<span></span>
{% endif %}
<form method="post" action="{% url "mobile:notifications" %}">
{% csrf_token %}
<input type="hidden" name="action" value="clear_all">
<button class="font-display text-xs font-extrabold tracking-wide text-muted uppercase" type="submit">{% trans "Clear all" %}</button>
</form>
</div>
{% endif %}
{% if not today and not earlier_this_week and not older %}

View File

@@ -910,6 +910,19 @@ class NotificationsViewTests(TestCase):
follow_up = self._get()
self.assertEqual(follow_up.context["unread_notification_count"], 0)
def test_clear_all_deletes_every_notification_for_every_managed_person(self):
Notification.objects.create(club=self.club, member=self.member, title="First", body="Body.")
Notification.objects.create(club=self.club, member=self.child, title="Second", body="Body.")
stranger = Member.objects.create(first_name="Someone", last_name="Else")
untouched = Notification.objects.create(club=self.club, member=stranger, title="Not yours", body="Body.")
self.client.force_login(self.user)
response = self._post({"action": "clear_all"})
self.assertRedirects(response, reverse("mobile:notifications"), fetch_redirect_response=False)
self.assertEqual(Notification.objects.filter(member__in=[self.member, self.child]).count(), 0)
self.assertTrue(Notification.objects.filter(pk=untouched.pk).exists())
def test_mark_read_marks_a_single_notification_and_redirects_back(self):
notification = Notification.objects.create(club=self.club, member=self.member, title="First", body="Body.")
self.client.force_login(self.user)

View File

@@ -755,6 +755,13 @@ class NotificationsView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
Notification.objects.filter(club=request.club, member__in=self.managed_people, read_at__isnull=True).update(read_at=timezone.now())
return HttpResponseRedirect(reverse("mobile:notifications"))
if action == "clear_all":
# A hard delete, not another read-state flip -- "Clear" empties the
# list itself, same as the clear-all gesture in a phone's own
# notification centre, rather than just marking everything read.
Notification.objects.filter(club=request.club, member__in=self.managed_people).delete()
return HttpResponseRedirect(reverse("mobile:notifications"))
if action == "mark_read":
notification = Notification.objects.filter(pk=request.POST.get("notification_id"), club=request.club, member__in=self.managed_people).first()
if notification is None:

View File

@@ -3944,6 +3944,9 @@
.h-\[7px\] {
height: 7px;
}
.h-\[19px\] {
height: 19px;
}
.h-\[22px\] {
height: 22px;
}
@@ -4169,6 +4172,9 @@
.min-w-40 {
min-width: calc(var(--spacing) * 40);
}
.min-w-\[19px\] {
min-width: 19px;
}
.min-w-\[220px\] {
min-width: 220px;
}

File diff suppressed because one or more lines are too long