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:
@@ -3,16 +3,16 @@
|
|||||||
|
|
||||||
{% comment %}
|
{% comment %}
|
||||||
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
|
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
|
||||||
width chronological agenda, grouped under "This week"/"Next week" (see
|
width chronological agenda, grouped under "This week"/"Next week", then
|
||||||
CalendarView's own docstring for the browsing-window judgment call --
|
everything further out grouped by calendar month (see CalendarView's own
|
||||||
current + next calendar week, no further paging). No person switcher and
|
docstring -- later_months is a list of {month_start, rows}). No person
|
||||||
no club-wide toggle here -- always every event self.managed_people is
|
switcher and no club-wide toggle here -- always every event
|
||||||
invited to, full stop. A real, working ?kind= filter (All/Games/
|
self.managed_people is invited to, full stop. A real, working ?kind=
|
||||||
Practices) stands in for the design mock's "Games only" pill; its List/
|
filter (All/Games/Practices) stands in for the design mock's "Games only"
|
||||||
Month toggle isn't reproduced -- that's a second view mode, not a filter.
|
pill; its List/Month toggle isn't reproduced -- that's a second view
|
||||||
-mx-4 (on the row groups only, not this filter row) breaks the rows out
|
mode, not a filter. -mx-4 (on the row groups only, not this filter row)
|
||||||
of base.html's shared page padding so they run edge-to-edge, matching
|
breaks the rows out of base.html's shared page padding so they run
|
||||||
the design canvas's own M3 markup.
|
edge-to-edge, matching the design canvas's own M3 markup.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
|
|
||||||
{% block header_extra %}
|
{% 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>
|
<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>
|
||||||
</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="mx-4">
|
||||||
<div class="m-card p-6 text-center">
|
<div class="m-card p-6 text-center">
|
||||||
{% if kind_filter == "game" %}
|
{% 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" %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +76,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -619,7 +619,10 @@ class CalendarViewTests(TestCase):
|
|||||||
return Event.objects.create(**kwargs)
|
return Event.objects.create(**kwargs)
|
||||||
|
|
||||||
def _events_in_context(self, response):
|
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"):
|
def add_child(self, first_name="Noor"):
|
||||||
family = Family.objects.create(name="Bakker")
|
family = Family.objects.create(name="Bakker")
|
||||||
@@ -724,10 +727,8 @@ class CalendarViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "No one to show yet")
|
self.assertContains(response, "No one to show yet")
|
||||||
|
|
||||||
def test_events_outside_the_two_week_window_are_excluded(self):
|
def test_past_events_are_excluded(self):
|
||||||
far_future = self.make_event(title="Far future game", start=timezone.now() + datetime.timedelta(days=30))
|
|
||||||
past = self.make_event(title="Past practice", start=timezone.now() - datetime.timedelta(days=1))
|
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)
|
Attendance.objects.create(event=past, member=self.member)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
@@ -735,12 +736,42 @@ class CalendarViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(self._events_in_context(response), set())
|
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):
|
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
|
# An event 13 days out (a noon start, so today's own time-of-day can't
|
||||||
# week's Sunday", which shrank to as little as 8-9 days whenever today
|
# push it across a date boundary) must always still show up somewhere
|
||||||
# fell late in the week -- an event 13 days out (a noon start, so
|
# on the agenda, whatever day the test happens to run on -- whether
|
||||||
# today's own time-of-day can't push it across a date boundary) must
|
# that's "Next week" or (on weekdays where the calendar week ends
|
||||||
# always still show, whatever day the test happens to run on.
|
# sooner) the first month group.
|
||||||
thirteen_days_out = timezone.make_aware(datetime.datetime.combine(timezone.localdate() + datetime.timedelta(days=13), datetime.time(12, 0)))
|
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)
|
event = self.make_event(title="Two weeks out", start=thirteen_days_out)
|
||||||
Attendance.objects.create(event=event, member=self.member)
|
Attendance.objects.create(event=event, member=self.member)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ routes here yet.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import itertools
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@@ -274,15 +275,12 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
"""M3 -- README's M3 section: a chronological agenda list (not the desktop
|
"""M3 -- README's M3 section: a chronological agenda list (not the desktop
|
||||||
week/month grid events.services.calendar was built for) grouped under
|
week/month grid events.services.calendar was built for) grouped under
|
||||||
"This week"/"Next week". Browsing-window judgment call: the design doc
|
"This week"/"Next week", then everything further out grouped by calendar
|
||||||
doesn't specify month navigation for the mobile screen, so this only ever
|
month (a "later_months" list of {month_start, rows}, each its own sticky
|
||||||
shows *upcoming* events within the next 14 days (no "Later"/past bucket,
|
header) -- an unbounded agenda rather than a fixed lookahead window, since
|
||||||
no ?month= paging) -- a simple, bounded agenda rather than a full season
|
a hard cutoff just hid events a member would reasonably expect to still
|
||||||
browser. The window is always >= 14 days from today, not just "through
|
find here. No ?month= paging beyond that grouping -- there's no season
|
||||||
next calendar week's Sunday" -- pinning it to the calendar week alone
|
browser here, just "everything upcoming, readably grouped".
|
||||||
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).
|
|
||||||
|
|
||||||
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
|
||||||
@@ -317,12 +315,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
_this_week_start, this_week_end = week_bounds(today)
|
_this_week_start, this_week_end = week_bounds(today)
|
||||||
# At least 14 days out from today, not just "through next calendar week's
|
next_week_end = this_week_end + datetime.timedelta(days=7)
|
||||||
# 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))
|
|
||||||
kind_filter = self.request.GET.get("kind")
|
kind_filter = self.request.GET.get("kind")
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
@@ -333,7 +326,6 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
event__club=self.request.club,
|
event__club=self.request.club,
|
||||||
event__cancelled=False,
|
event__cancelled=False,
|
||||||
event__start__gte=now,
|
event__start__gte=now,
|
||||||
event__start__lte=window_end,
|
|
||||||
)
|
)
|
||||||
.select_related("event", "event__location", "event__opponent", "member")
|
.select_related("event", "event__location", "event__opponent", "member")
|
||||||
.prefetch_related("event__teams")
|
.prefetch_related("event__teams")
|
||||||
@@ -350,12 +342,24 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
for attendance in attendances
|
for attendance in attendances
|
||||||
]
|
]
|
||||||
|
|
||||||
this_week, next_week = [], []
|
this_week, next_week, later_rows = [], [], []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week
|
event_date = timezone.localtime(row["event"].start).date()
|
||||||
bucket.append(row)
|
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):
|
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
||||||
|
|||||||
@@ -4403,6 +4403,9 @@
|
|||||||
.self-start {
|
.self-start {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
.self-stretch {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
.truncate {
|
.truncate {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|||||||
Reference in New Issue
Block a user