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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 20:50:40 +02:00
parent 9c16141d06
commit 4b5a4a81ec
4 changed files with 92 additions and 43 deletions

View File

@@ -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 @@
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule will show up here." %}</p>
</div>
</div>
{% elif not this_week and not next_week %}
{% elif not this_week and not next_week and not later_months %}
<div class="mx-4">
<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>
<p class="text-sm text-muted">{% trans "No games scheduled." %}</p>
{% elif kind_filter == "training" %}
<p class="text-sm text-muted">{% trans "No practices in the next two weeks." %}</p>
<p class="text-sm text-muted">{% trans "No practices scheduled." %}</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." %}</p>
{% endif %}
</div>
</div>
@@ -76,6 +76,17 @@
</div>
</div>
{% endif %}
{% for month in later_months %}
<div>
<div class="sticky top-0 z-10 bg-paper px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in month.rows %}
{% include "mobile/_calendar_row.html" %}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endblock content %}

View File

@@ -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)

View File

@@ -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):

View File

@@ -4403,6 +4403,9 @@
.self-start {
align-self: flex-start;
}
.self-stretch {
align-self: stretch;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;