From 4b5a4a81ec492b3fbebe4d967f47d021d862e72b Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Fri, 21 Aug 2026 20:50:40 +0200 Subject: [PATCH] Show everything past next week on Calendar, grouped by month The agenda used to hard-cut at a ~14-day lookahead with no way to see anything past it. Drop the cutoff and bucket whatever falls beyond "Next week" into per-month groups (each its own sticky header), so the screen stays a full upcoming agenda instead of losing events that were still worth seeing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9 --- mobile/templates/mobile/calendar.html | 39 +++++++++++++-------- mobile/tests.py | 49 ++++++++++++++++++++++----- mobile/views.py | 44 +++++++++++++----------- static/css/app.css | 3 ++ 4 files changed, 92 insertions(+), 43 deletions(-) diff --git a/mobile/templates/mobile/calendar.html b/mobile/templates/mobile/calendar.html index 9942e3f..ed9a2c4 100644 --- a/mobile/templates/mobile/calendar.html +++ b/mobile/templates/mobile/calendar.html @@ -3,16 +3,16 @@ {% comment %} M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full- - width chronological agenda, grouped under "This week"/"Next week" (see - CalendarView's own docstring for the browsing-window judgment call -- - current + next calendar week, no further paging). No person switcher and - no club-wide toggle here -- always every event self.managed_people is - invited to, full stop. A real, working ?kind= filter (All/Games/ - Practices) stands in for the design mock's "Games only" pill; its List/ - Month toggle isn't reproduced -- that's a second view mode, not a filter. - -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. + width chronological agenda, grouped under "This week"/"Next week", then + everything further out grouped by calendar month (see CalendarView's own + docstring -- later_months is a list of {month_start, rows}). No person + switcher and no club-wide toggle here -- always every event + self.managed_people is invited to, full stop. A real, working ?kind= + filter (All/Games/Practices) stands in for the design mock's "Games only" + pill; its List/Month toggle isn't reproduced -- that's a second view + mode, not a filter. -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 %} {% block header_extra %} @@ -42,15 +42,15 @@

{% trans "Once you're linked to a member record, their schedule will show up here." %}

- {% elif not this_week and not next_week %} + {% elif not this_week and not next_week and not later_months %}
{% if kind_filter == "game" %} -

{% trans "No games in the next two weeks." %}

+

{% trans "No games scheduled." %}

{% elif kind_filter == "training" %} -

{% trans "No practices in the next two weeks." %}

+

{% trans "No practices scheduled." %}

{% else %} -

{% trans "Nothing scheduled in the next two weeks." %}

+

{% trans "Nothing scheduled." %}

{% endif %}
@@ -76,6 +76,17 @@ {% endif %} + + {% for month in later_months %} +
+
{{ month.month_start|date:"F Y" }}
+
+ {% for row in month.rows %} + {% include "mobile/_calendar_row.html" %} + {% endfor %} +
+
+ {% endfor %} {% endif %} {% endblock content %} diff --git a/mobile/tests.py b/mobile/tests.py index dd7cde3..7f99110 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -619,7 +619,10 @@ class CalendarViewTests(TestCase): return Event.objects.create(**kwargs) def _events_in_context(self, response): - return {row["event"] for row in response.context["this_week"] + response.context["next_week"]} + rows = response.context["this_week"] + response.context["next_week"] + for month in response.context["later_months"]: + rows += month["rows"] + return {row["event"] for row in rows} def add_child(self, first_name="Noor"): family = Family.objects.create(name="Bakker") @@ -724,10 +727,8 @@ class CalendarViewTests(TestCase): self.assertEqual(response.status_code, 200) self.assertContains(response, "No one to show yet") - def test_events_outside_the_two_week_window_are_excluded(self): - far_future = self.make_event(title="Far future game", start=timezone.now() + datetime.timedelta(days=30)) + def test_past_events_are_excluded(self): past = self.make_event(title="Past practice", start=timezone.now() - datetime.timedelta(days=1)) - Attendance.objects.create(event=far_future, member=self.member) Attendance.objects.create(event=past, member=self.member) self.client.force_login(self.user) @@ -735,12 +736,42 @@ class CalendarViewTests(TestCase): self.assertEqual(self._events_in_context(response), set()) + def test_events_beyond_next_week_are_grouped_by_month(self): + far_future = self.make_event(title="Far future game", start=timezone.now() + datetime.timedelta(days=45)) + Attendance.objects.create(event=far_future, member=self.member) + self.client.force_login(self.user) + + response = self._get() + + self.assertIn(far_future, self._events_in_context(response)) + later_months = response.context["later_months"] + self.assertEqual(len(later_months), 1) + month_start = timezone.localtime(far_future.start).date().replace(day=1) + self.assertEqual(later_months[0]["month_start"], month_start) + self.assertEqual([row["event"] for row in later_months[0]["rows"]], [far_future]) + self.assertContains(response, month_start.strftime("%B %Y")) + + def test_further_out_events_are_split_across_separate_month_groups(self): + # >=32 days apart guarantees two different calendar months regardless + # of which day-of-month "today" happens to be (the longest month is + # 31 days), so this can't flake depending on when the suite runs. + this_month = self.make_event(title="This month game", start=timezone.now() + datetime.timedelta(days=45)) + next_month = self.make_event(title="Next month game", start=timezone.now() + datetime.timedelta(days=80)) + Attendance.objects.create(event=this_month, member=self.member) + Attendance.objects.create(event=next_month, member=self.member) + self.client.force_login(self.user) + + response = self._get() + + later_months = response.context["later_months"] + self.assertEqual(len(later_months), 2) + def test_window_covers_at_least_fourteen_days_regardless_of_which_weekday_today_is(self): - # Regression: the window used to be pinned to "through next calendar - # week's Sunday", which shrank to as little as 8-9 days whenever today - # fell late in the week -- an event 13 days out (a noon start, so - # today's own time-of-day can't push it across a date boundary) must - # always still show, whatever day the test happens to run on. + # An event 13 days out (a noon start, so today's own time-of-day can't + # push it across a date boundary) must always still show up somewhere + # on the agenda, whatever day the test happens to run on -- whether + # that's "Next week" or (on weekdays where the calendar week ends + # sooner) the first month group. thirteen_days_out = timezone.make_aware(datetime.datetime.combine(timezone.localdate() + datetime.timedelta(days=13), datetime.time(12, 0))) event = self.make_event(title="Two weeks out", start=thirteen_days_out) Attendance.objects.create(event=event, member=self.member) diff --git a/mobile/views.py b/mobile/views.py index e6a9188..cd3df5b 100644 --- a/mobile/views.py +++ b/mobile/views.py @@ -5,6 +5,7 @@ routes here yet. """ import datetime +import itertools import json from django.contrib.auth.mixins import LoginRequiredMixin @@ -274,15 +275,12 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView): class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): """M3 -- README's M3 section: a chronological agenda list (not the desktop week/month grid events.services.calendar was built for) grouped under - "This week"/"Next week". Browsing-window judgment call: the design doc - doesn't specify month navigation for the mobile screen, so this only ever - shows *upcoming* events within the next 14 days (no "Later"/past bucket, - no ?month= paging) -- a simple, bounded agenda rather than a full season - browser. The window is always >= 14 days from today, not just "through - next calendar week's Sunday" -- pinning it to the calendar week alone - would shrink the effective lookahead to as little as 8-9 days whenever - today falls late in the week, silently dropping events a member would - expect to still see (see get_context_data's window_end_date). + "This week"/"Next week", then everything further out grouped by calendar + month (a "later_months" list of {month_start, rows}, each its own sticky + header) -- an unbounded agenda rather than a fixed lookahead window, since + a hard cutoff just hid events a member would reasonably expect to still + find here. No ?month= paging beyond that grouping -- there's no season + browser here, just "everything upcoming, readably grouped". 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 @@ -317,12 +315,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): now = timezone.now() today = timezone.localdate() _this_week_start, this_week_end = week_bounds(today) - # At least 14 days out from today, not just "through next calendar week's - # Sunday" -- that alone shrinks to as little as 8-9 days when today falls - # late in the week (e.g. today=Friday puts next_week_end only 9 days out), - # silently dropping events a member would reasonably expect to still see. - window_end_date = max(this_week_end + datetime.timedelta(days=7), today + datetime.timedelta(days=13)) - window_end = timezone.make_aware(datetime.datetime.combine(window_end_date, datetime.time.max)) + next_week_end = this_week_end + datetime.timedelta(days=7) kind_filter = self.request.GET.get("kind") rows = [] @@ -333,7 +326,6 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): event__club=self.request.club, event__cancelled=False, event__start__gte=now, - event__start__lte=window_end, ) .select_related("event", "event__location", "event__opponent", "member") .prefetch_related("event__teams") @@ -350,12 +342,24 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView): for attendance in attendances ] - this_week, next_week = [], [] + this_week, next_week, later_rows = [], [], [] for row in rows: - bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week - bucket.append(row) + event_date = timezone.localtime(row["event"].start).date() + if event_date <= this_week_end: + this_week.append(row) + elif event_date <= next_week_end: + next_week.append(row) + else: + later_rows.append(row) - 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) + # ``rows`` is already start-ordered, so a plain groupby (no sorting) is + # enough to split later_rows into one chronological run per month. + later_months = [ + {"month_start": month_start, "rows": list(month_rows)} + for month_start, month_rows in itertools.groupby(later_rows, key=lambda row: timezone.localtime(row["event"].start).date().replace(day=1)) + ] + + return super().get_context_data(this_week=this_week, next_week=next_week, later_months=later_months, kind_filter=kind_filter if kind_filter in self.KIND_FILTERS else "", **kwargs) class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView): diff --git a/static/css/app.css b/static/css/app.css index ba01d5f..c864416 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -4403,6 +4403,9 @@ .self-start { align-self: flex-start; } + .self-stretch { + align-self: stretch; + } .truncate { overflow: hidden; text-overflow: ellipsis;