Coach Schedule: same This week/Next week/month-divider agenda grouping

Extracted the grouping algorithm (previously duplicated between mobile.views.
CalendarView and management.views.EventListView's own "List" mode) into a
shared events.services.calendar.agenda_groups, and wired it into both --
plus mobile.coach_views.CoachScheduleView, the coach app's own upcoming-
events screen, which had no grouping at all before (one flat card). All
three now read identically and can't drift apart.

Also: give each management event-list row's kind badge a fixed width, so
titles start at the same horizontal position regardless of whether the row
says "Game" or "Tournament".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 17:27:56 +02:00
parent a13ab20a0f
commit 6f16737bd9
13 changed files with 211 additions and 88 deletions

View File

@@ -26,6 +26,7 @@ from controlpanel.messages import notify
from events.models import Attendance, Event, EventSeries, Lineup, LineupSelection, Location, Opponent
from events.services import generate_occurrences
from events.services.attendance import member_attendance_counts, record_check_in
from events.services.calendar import agenda_groups
from events.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
from events.tasks import notify_new_event
from management.forms import EventForm, EventSeriesForm, LocationForm, NewsForm, OpponentForm
@@ -1104,15 +1105,19 @@ class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
straight into the coach-relevant action -- Bench attendance for a
practice, the Line-up for a game -- rather than mobile:event_detail (the
Member-shell RSVP page a coach browsing their own team's schedule has no
use for)."""
use for). Grouped into This week/Next week/by-month dividers via events.
services.calendar.agenda_groups -- the same shared grouping mobile.views.
CalendarView (the member app's own Calendar) and management.views.
EventListView's "List" mode use, so all three read the same way."""
template_name = "mobile/coach/schedule.html"
screen_title = _("Schedule")
active_tab = "coach_schedule"
def get_context_data(self, **kwargs):
events = []
this_week, next_week, later_months = [], [], []
if self.active_team is not None:
events = list(Event.objects.filter(teams=self.active_team, cancelled=False, start__gte=timezone.now()).order_by("start"))
this_week, next_week, later_months = agenda_groups(events)
return super().get_context_data(events=events, **kwargs)
return super().get_context_data(this_week=this_week, next_week=next_week, later_months=later_months, **kwargs)

View File

@@ -5,7 +5,7 @@
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
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
docstring -- later_months is a list of {month_start, items}). No person
switcher and no club-wide toggle here -- always every event
self.managed_people is invited to, plus (merged into the same
chronological list, each its own accent colour/look):
@@ -90,7 +90,7 @@
<div>
<div class="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 %}
{% for row in month.items %}
{% if row.blocked_requirements %}{% include "mobile/_calendar_blocked_row.html" %}{% elif row.referee_signup %}{% include "mobile/_calendar_referee_row.html" %}{% else %}{% include "mobile/_calendar_row.html" %}{% endif %}
{% endfor %}
</div>

View File

@@ -0,0 +1,13 @@
{% load i18n %}
<a class="flex items-center gap-3 p-3.5" href="{% if event.kind == "game" %}{% url "mobile:coach_lineup" event.pk %}{% else %}{% url "mobile:coach_attendance" event.pk %}{% endif %}">
<div class="w-9.5 shrink-0 text-center">
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</div>
</div>
<div class="w-[3px] shrink-0 self-stretch rounded-full {% if event.kind == "game" %}bg-club{% elif event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ event.title }}</div>
<div class="truncate text-xs text-muted">{{ event.start|date:"H:i" }}{% if event.location %} &middot; {{ event.location.name }}{% endif %}</div>
</div>
<span class="pill {% if event.kind == "game" %}pill-danger{% elif event.kind == "training" %}pill-info{% else %}pill-neutral{% endif %} shrink-0">{{ event.get_kind_display }}</span>
</a>

View File

@@ -3,9 +3,12 @@
{% comment %}
Bottom-tab "Schedule" -- every upcoming event for the active team (not
just the next one, that's Today's job). Each row links straight into the
coach-relevant action for its kind rather than mobile:event_detail (the
Member-shell RSVP page) -- see CoachScheduleView's own docstring.
just the next one, that's Today's job), grouped into This week/Next
week/by-month dividers -- see CoachScheduleView's own docstring for the
shared agenda_groups grouping behind this (also mobile.views.CalendarView
and management.views.EventListView's "List" mode). Each row links
straight into the coach-relevant action for its kind rather than
mobile:event_detail (the Member-shell RSVP page).
{% endcomment %}
{% block content %}
@@ -13,26 +16,46 @@
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
</div>
{% elif not events %}
{% elif not this_week and not next_week and not later_months %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing scheduled." %}</p>
</div>
{% else %}
<div class="m-card flex flex-col overflow-hidden">
{% for event in events %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<a class="flex items-center gap-3 p-3.5" href="{% if event.kind == "game" %}{% url "mobile:coach_lineup" event.pk %}{% else %}{% url "mobile:coach_attendance" event.pk %}{% endif %}">
<div class="w-9.5 shrink-0 text-center">
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</div>
<div class="flex flex-col gap-4">
{% if this_week %}
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
<div class="m-card flex flex-col overflow-hidden">
{% for event in this_week %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
{% include "mobile/coach/_schedule_row.html" %}
{% endfor %}
</div>
<div class="w-[3px] shrink-0 self-stretch rounded-full {% if event.kind == "game" %}bg-club{% elif event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ event.title }}</div>
<div class="truncate text-xs text-muted">{{ event.start|date:"H:i" }}{% if event.location %} &middot; {{ event.location.name }}{% endif %}</div>
</div>
{% endif %}
{% if next_week %}
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
<div class="m-card flex flex-col overflow-hidden">
{% for event in next_week %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
{% include "mobile/coach/_schedule_row.html" %}
{% endfor %}
</div>
<span class="pill {% if event.kind == "game" %}pill-danger{% elif event.kind == "training" %}pill-info{% else %}pill-neutral{% endif %} shrink-0">{{ event.get_kind_display }}</span>
</a>
</div>
{% endif %}
{% for month in later_months %}
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{{ month.month_start|date:"F Y" }}</div>
<div class="m-card flex flex-col overflow-hidden">
{% for event in month.items %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
{% include "mobile/coach/_schedule_row.html" %}
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -12,6 +12,7 @@ from icalendar import Calendar as ICalCalendar
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
from events.services.attendance import record_check_in
from events.services.calendar import week_bounds
from members.models import Family, FamilyMembership, Member
from news.models import News
from notifications.models import Notification
@@ -850,7 +851,7 @@ class CalendarViewTests(TestCase):
def _events_in_context(self, response):
rows = response.context["this_week"] + response.context["next_week"]
for month in response.context["later_months"]:
rows += month["rows"]
rows += month["items"]
return {row["event"] for row in rows}
def add_child(self, first_name="Noor"):
@@ -977,7 +978,7 @@ class CalendarViewTests(TestCase):
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.assertEqual([row["event"] for row in later_months[0]["items"]], [far_future])
self.assertContains(response, month_start.strftime("%B %Y"))
def test_further_out_events_are_split_across_separate_month_groups(self):
@@ -3127,7 +3128,9 @@ class CoachScheduleViewTests(TestCase):
response = self._get()
self.assertEqual(response.context["events"], [])
self.assertEqual(response.context["this_week"], [])
self.assertEqual(response.context["next_week"], [])
self.assertEqual(response.context["later_months"], [])
def test_no_staff_assignment_shows_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
@@ -3138,6 +3141,23 @@ class CoachScheduleViewTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Not staffing a team yet")
def test_events_are_grouped_into_this_week_next_week_and_month_dividers(self):
_this_week_start, this_week_end = week_bounds(timezone.localdate())
this_week_event = Event.objects.create(club=self.club, title="This week practice", kind=Event.EventKind.TRAINING, start=timezone.make_aware(datetime.datetime.combine(this_week_end, datetime.time(18, 0))))
this_week_event.teams.add(self.team)
far_future = Event.objects.create(club=self.club, title="Far future practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=60))
far_future.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual([event.title for event in response.context["this_week"]], ["This week practice"])
later_months = response.context["later_months"]
self.assertEqual(len(later_months), 1)
self.assertEqual([event.title for event in later_months[0]["items"]], ["Far future practice"])
self.assertContains(response, "This week")
self.assertContains(response, far_future.start.strftime("%B %Y"))
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachAttendanceViewTests(TestCase):

View File

@@ -4,8 +4,6 @@ phase -- see design_handoff_rosterchief_platform/README.md -- and has no
routes here yet.
"""
import datetime
import itertools
import json
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -29,7 +27,7 @@ from club.services.sponsors import active_sponsors
from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup, RefereeSignup
from events.services.attendance import blocked_upcoming_events_for_member
from events.services.calendar import week_bounds
from events.services.calendar import agenda_groups, week_bounds
from events.services.lineup import notify_dropout, selected_members_by_position
from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup
from members.models import FamilyMembership, Member
@@ -284,10 +282,12 @@ 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", 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
month via events.services.calendar.agenda_groups (also behind management.
views.EventListView's own "List" mode and mobile.coach_views.
CoachScheduleView, so all three stay behaviourally identical) -- 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,
@@ -322,8 +322,6 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
now = timezone.now()
today = timezone.localdate()
_this_week_start, this_week_end = week_bounds(today)
next_week_end = this_week_end + datetime.timedelta(days=7)
kind_filter = self.request.GET.get("kind")
rows = []
@@ -386,22 +384,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
rows.sort(key=lambda row: row["event"].start)
this_week, next_week, later_rows = [], [], []
for row in rows:
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)
# ``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))
]
this_week, next_week, later_months = agenda_groups(rows, start_of=lambda row: row["event"].start, today=today)
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)