Add a working event-kind filter to Calendar; merge per-screen headers into the shared navy bar

Calendar gains a real "All/Games/Practices" filter (?kind=), a working
version of the design mock's own "Games only" pill -- its List/Month
toggle isn't reproduced, that's a whole second view mode, not a filter.

base.html gains a header_extra block so a screen can add its own title
into the *same* navy app-header instead of stacking a second,
differently-coloured block underneath it. Me's avatar/name/subtitle row
moved there from its own bg-ink block (a visible colour seam that didn't
match the design canvas's own M5 markup, which keeps it all in one navy
area), and Calendar now shows a "Calendar" title the same way.

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 16:14:32 +02:00
parent 40bee9af48
commit 65801a7843
6 changed files with 111 additions and 20 deletions

View File

@@ -70,6 +70,7 @@
{% 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.5 right-1.5 h-2 w-2 rounded-full bg-club" style="border: 2px solid var(--color-navy)"></span>{% endif %}
</a> </a>
</div> </div>
{% block header_extra %}{% endblock header_extra %}
</header> </header>
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">

View File

@@ -7,13 +7,33 @@
CalendarView's own docstring for the browsing-window judgment call -- CalendarView's own docstring for the browsing-window judgment call --
current + next calendar week, no further paging). No person switcher and current + next calendar week, no further paging). No person switcher and
no club-wide toggle here -- always every event self.managed_people is no club-wide toggle here -- always every event self.managed_people is
invited to, full stop. -mx-4 breaks the rows out of base.html's shared invited to, full stop. A real, working ?kind= filter (All/Games/
page padding so they run edge-to-edge, matching the design canvas's own Practices) stands in for the design mock's "Games only" pill; its List/
M3 markup (the desktop List/Month/"Games only" controls in that mock Month toggle isn't reproduced -- that's a second view mode, not a filter.
aren't reproduced here -- no real functionality behind them yet). -mx-4 (on the row groups only, not this filter row) breaks the rows out
of base.html's shared page padding so they run edge-to-edge, matching
the design canvas's own M3 markup.
{% endcomment %} {% endcomment %}
{% block header_extra %}
<div class="mt-3">
<span class="font-display text-2xl font-extrabold text-white uppercase">{% trans "Calendar" %}</span>
</div>
{% endblock header_extra %}
{% block content %} {% block content %}
<div class="flex gap-2">
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not kind_filter %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="{% url "mobile:calendar" %}">
{% trans "All" %}
</a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if kind_filter == "game" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?kind=game">
{% trans "Games" %}
</a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if kind_filter == "training" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?kind=training">
{% trans "Practices" %}
</a>
</div>
<div class="-mx-4"> <div class="-mx-4">
{% if not managed_people %} {% if not managed_people %}
<div class="mx-4"> <div class="mx-4">
@@ -25,7 +45,13 @@
{% elif not this_week and not next_week %} {% elif not this_week and not next_week %}
<div class="mx-4"> <div class="mx-4">
<div class="m-card p-6 text-center"> <div class="m-card p-6 text-center">
{% if kind_filter == "game" %}
<p class="text-sm text-muted">{% trans "No games in the next two weeks." %}</p>
{% elif kind_filter == "training" %}
<p class="text-sm text-muted">{% trans "No practices in the next two weeks." %}</p>
{% else %}
<p class="text-sm text-muted">{% trans "Nothing scheduled in the next two weeks." %}</p> <p class="text-sm text-muted">{% trans "Nothing scheduled in the next two weeks." %}</p>
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}

View File

@@ -9,19 +9,17 @@
"Payments & dues" have no screen to lead to and are omitted, same for the "Payments & dues" have no screen to lead to and are omitted, same for the
mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode
screens exist yet, so it's never rendered, not even as a dead/inert link). screens exist yet, so it's never rendered, not even as a dead/inert link).
The avatar/name/subtitle row lives in header_extra -- merged into the
shared navy app-header (base.html) rather than a separately-coloured
block of its own, matching the design canvas's own M5 markup.
{% endcomment %} {% endcomment %}
{% block content %} {% block header_extra %}
{% if not managed_people %} {% if managed_people %}
<div class="m-card p-6 text-center"> <div class="mt-3 flex items-center gap-3">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, your profile will show up here." %}</p>
</div>
{% else %}
<div class="-mx-4 -mt-4 flex items-center gap-3 bg-ink px-4 pt-4 pb-5 text-white">
{% include "mobile/_avatar.html" with person=me size_class="h-14 w-14" text_class="text-xl" bg_class="bg-club" %} {% include "mobile/_avatar.html" with person=me size_class="h-14 w-14" text_class="text-xl" bg_class="bg-club" %}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="font-display text-xl leading-none font-extrabold uppercase">{{ me.get_full_name }}</div> <div class="font-display text-xl leading-none font-extrabold text-white uppercase">{{ me.get_full_name }}</div>
{% if member_since or team_manager_label %} {% if member_since or team_manager_label %}
<div class="mt-1.5 text-[13px] text-on-dark-dim"> <div class="mt-1.5 text-[13px] text-on-dark-dim">
{% if member_since %}{% blocktrans with year=member_since|date:"Y" %}Member since {{ year }}{% endblocktrans %}{% endif %} {% if member_since %}{% blocktrans with year=member_since|date:"Y" %}Member since {{ year }}{% endblocktrans %}{% endif %}
@@ -31,7 +29,16 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% endblock header_extra %}
{% block content %}
{% if not managed_people %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</p>
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, your profile will show up here." %}</p>
</div>
{% else %}
<div> <div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "People I manage" %}</div> <div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "People I manage" %}</div>
<div class="m-card overflow-hidden"> <div class="m-card overflow-hidden">

View File

@@ -596,6 +596,13 @@ class CalendarViewTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
def test_header_shows_a_calendar_title_merged_into_the_shared_header(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, '<span class="font-display text-2xl font-extrabold text-white uppercase">Calendar</span>')
def test_shows_only_events_the_managed_people_are_invited_to(self): def test_shows_only_events_the_managed_people_are_invited_to(self):
invited = self.make_event(title="Lars's practice") invited = self.make_event(title="Lars's practice")
not_invited = self.make_event(title="Someone else's practice") not_invited = self.make_event(title="Someone else's practice")
@@ -637,6 +644,39 @@ class CalendarViewTests(TestCase):
self.assertNotContains(response, 'href="?as=') self.assertNotContains(response, 'href="?as=')
def test_games_filter_shows_only_games(self):
game = self.make_event(title="vs Leuven", kind=Event.EventKind.GAME)
practice = self.make_event(title="Ice 3", kind=Event.EventKind.TRAINING)
Attendance.objects.create(event=game, member=self.member)
Attendance.objects.create(event=practice, member=self.member)
self.client.force_login(self.user)
response = self._get(kind="game")
self.assertEqual(self._events_in_context(response), {game})
self.assertEqual(response.context["kind_filter"], "game")
def test_practices_filter_shows_only_practices(self):
game = self.make_event(title="vs Leuven", kind=Event.EventKind.GAME)
practice = self.make_event(title="Ice 3", kind=Event.EventKind.TRAINING)
Attendance.objects.create(event=game, member=self.member)
Attendance.objects.create(event=practice, member=self.member)
self.client.force_login(self.user)
response = self._get(kind="training")
self.assertEqual(self._events_in_context(response), {practice})
def test_unrecognized_kind_falls_back_to_all(self):
event = self.make_event(title="Social night", kind=Event.EventKind.SOCIAL)
Attendance.objects.create(event=event, member=self.member)
self.client.force_login(self.user)
response = self._get(kind="bogus")
self.assertEqual(self._events_in_context(response), {event})
self.assertEqual(response.context["kind_filter"], "")
def test_no_managed_people_shows_a_graceful_empty_state(self): def test_no_managed_people_shows_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123") bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user) self.client.force_login(bare_user)
@@ -1092,6 +1132,13 @@ class MeViewTests(TestCase):
self.assertContains(response, "Lars Bakker") self.assertContains(response, "Lars Bakker")
self.assertContains(response, "(me)") self.assertContains(response, "(me)")
def test_header_is_merged_into_the_shared_navy_header_not_a_separate_block(self):
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "bg-ink")
def test_every_managed_child_appears_in_the_list(self): def test_every_managed_child_appears_in_the_list(self):
self.client.force_login(self.user) self.client.force_login(self.user)

View File

@@ -282,16 +282,22 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
Always scoped to every one of ``self.managed_people`` -- unlike Home, Always scoped to every one of ``self.managed_people`` -- unlike Home,
this screen has no person switcher and no "every club event" toggle: it's this screen has no person switcher and no "every club event" toggle: it's
just "what is my family invited to", full stop. (The design mock's own just "what is my family invited to", full stop. A ``?kind=`` filter (All/
"All members"/list-vs-month/games-only controls aren't built -- they'd Games/Practices, the two dominant event kinds) narrows that down -- a
need real functionality behind them, not just markup; flagged rather than real, working version of the design mock's own "Games only" pill, not
faked.) reproducing its List/Month toggle (that needs a whole second view mode,
not a filter) or its per-person "All members" (removed on purpose, see
above).
""" """
template_name = "mobile/calendar.html" template_name = "mobile/calendar.html"
screen_title = _("Calendar") screen_title = _("Calendar")
active_tab = "calendar" active_tab = "calendar"
#: ?kind= values this screen actually understands, mapped to the Event.EventKind
#: they filter on -- anything else (including no param at all) means "All".
KIND_FILTERS = {"game": Event.EventKind.GAME, "training": Event.EventKind.TRAINING}
#: Pill styling for a per-person RSVP status (assets/mobile.css's .pill-*). #: Pill styling for a per-person RSVP status (assets/mobile.css's .pill-*).
STATUS_PILL_CLASSES = { STATUS_PILL_CLASSES = {
Attendance.AttendanceStatus.PRESENT: "pill-ok", Attendance.AttendanceStatus.PRESENT: "pill-ok",
@@ -308,6 +314,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
_this_week_start, this_week_end = week_bounds(timezone.localdate()) _this_week_start, this_week_end = week_bounds(timezone.localdate())
next_week_end = this_week_end + datetime.timedelta(days=7) next_week_end = this_week_end + datetime.timedelta(days=7)
window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max)) window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max))
kind_filter = self.request.GET.get("kind")
rows = [] rows = []
if self.managed_people: if self.managed_people:
@@ -323,6 +330,9 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
.prefetch_related("event__teams") .prefetch_related("event__teams")
.order_by("event__start") .order_by("event__start")
) )
if kind_filter in self.KIND_FILTERS:
attendances = attendances.filter(event__kind=self.KIND_FILTERS[kind_filter])
# Only worth naming whose row it is once there's more than one managed # Only worth naming whose row it is once there's more than one managed
# person to tell apart -- a lone member's own agenda doesn't need it. # person to tell apart -- a lone member's own agenda doesn't need it.
show_member = len(self.managed_people) > 1 show_member = len(self.managed_people) > 1
@@ -336,7 +346,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week
bucket.append(row) bucket.append(row)
return super().get_context_data(this_week=this_week, next_week=next_week, **kwargs) return super().get_context_data(this_week=this_week, next_week=next_week, kind_filter=kind_filter if kind_filter in self.KIND_FILTERS else "", **kwargs)
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):

File diff suppressed because one or more lines are too long