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

@@ -16,6 +16,7 @@ Three grids, one per granularity:
import calendar as _calendar import calendar as _calendar
import datetime import datetime
import itertools
from django.utils import timezone from django.utils import timezone
@@ -55,6 +56,58 @@ def add_months(anchor: datetime.date, months: int) -> datetime.date:
return datetime.date(year, month, 1) return datetime.date(year, month, 1)
def agenda_groups(items, *, start_of=lambda item: item.start, show_past=False, today=None):
"""Group already start-sorted ``items`` the way every agenda-style
schedule in this app presents itself: "This week"/"Next week", then
everything further out under its own month divider -- shared by
mobile.views.CalendarView (the member app's own Calendar), management.
views.EventListView (the desktop's "List" view), and mobile.coach_views.
CoachScheduleView (the coach app's "Schedule"), so the three stay
behaviourally identical rather than three hand-rolled copies of the same
grouping drifting apart over time.
``start_of`` extracts an item's start datetime -- the default assumes a
bare ``Event``-like object; pass a lambda for anything else (e.g.
CalendarView's own rows, each a dict wrapping one).
``show_past`` is for a caller already querying in descending order (most
recent first) -- "This week"/"Next week" only make sense for what's
ahead, so past mode skips that split and groups straight into months
instead, newest month first, oldest last.
Returns ``(this_week, next_week, later_months)`` -- ``later_months`` is
``[{"month_start": date, "items": [...]}, ...]``; ``this_week``/
``next_week`` are always ``[]`` in show_past mode, with everything
carried in ``later_months`` instead.
"""
if not items:
return [], [], []
def _month_start(item):
return timezone.localtime(start_of(item)).date().replace(day=1)
if show_past:
months = [{"month_start": month_start, "items": list(month_items)} for month_start, month_items in itertools.groupby(items, key=_month_start)]
return [], [], months
today = today or timezone.localdate()
_this_week_start, this_week_end = week_bounds(today)
next_week_end = this_week_end + datetime.timedelta(days=7)
this_week, next_week, later = [], [], []
for item in items:
item_date = timezone.localtime(start_of(item)).date()
if item_date <= this_week_end:
this_week.append(item)
elif item_date <= next_week_end:
next_week.append(item)
else:
later.append(item)
later_months = [{"month_start": month_start, "items": list(month_items)} for month_start, month_items in itertools.groupby(later, key=_month_start)]
return this_week, next_week, later_months
def _local_span(event) -> tuple[datetime.datetime, datetime.datetime]: def _local_span(event) -> tuple[datetime.datetime, datetime.datetime]:
"""An event's start/end in local time, end defaulting to +1h when unset """An event's start/end in local time, end defaulting to +1h when unset
(mirrors the "assumed duration" read-time fallback events.models.Event (mirrors the "assumed duration" read-time fallback events.models.Event

View File

@@ -1,6 +1,7 @@
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from io import StringIO from io import StringIO
from types import SimpleNamespace
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.cache import cache from django.core.cache import cache
@@ -35,7 +36,7 @@ from .services import (
team_no_shows, team_no_shows,
) )
from .services.attendance import member_attendance_counts from .services.attendance import member_attendance_counts
from .services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid from .services.calendar import add_months, agenda_groups, month_bounds, month_grid, season_grid, week_bounds, week_grid
from .services.lineup import cancel_scheduled_publish, notify_dropout, publish_lineup, schedule_lineup_publish, selected_members_by_position, toggle_selection from .services.lineup import cancel_scheduled_publish, notify_dropout, publish_lineup, schedule_lineup_publish, selected_members_by_position, toggle_selection
from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent from .services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, parse_fixtures, suggested_location, suggested_opponent
from .services.referees import ( from .services.referees import (
@@ -1807,6 +1808,53 @@ class CalendarGridTests(EventsTestBase):
def test_add_months_rolls_over_the_year(self): def test_add_months_rolls_over_the_year(self):
self.assertEqual(add_months(date(2026, 11, 15), 2), date(2027, 1, 1)) self.assertEqual(add_months(date(2026, 11, 15), 2), date(2027, 1, 1))
def _item(self, day):
return SimpleNamespace(start=self.at(day, 12))
def test_agenda_groups_splits_into_this_week_next_week_and_later(self):
# 2026-08-19 is a Wednesday -- this week runs Mon 17..Sun 23, next
# week Mon 24..Sun 30, matching test_week_bounds_returns_monday_to_
# sunday above.
this_week_item = self._item(date(2026, 8, 21))
next_week_item = self._item(date(2026, 8, 27))
later_item = self._item(date(2026, 9, 15))
items = [this_week_item, next_week_item, later_item]
this_week, next_week, later_months = agenda_groups(items, today=date(2026, 8, 19))
self.assertEqual(this_week, [this_week_item])
self.assertEqual(next_week, [next_week_item])
self.assertEqual(len(later_months), 1)
self.assertEqual(later_months[0]["month_start"], date(2026, 9, 1))
self.assertEqual(later_months[0]["items"], [later_item])
def test_agenda_groups_later_items_split_across_separate_months(self):
september_item = self._item(date(2026, 9, 15))
october_item = self._item(date(2026, 10, 15))
_this_week, _next_week, later_months = agenda_groups([september_item, october_item], today=date(2026, 8, 19))
self.assertEqual([month["month_start"] for month in later_months], [date(2026, 9, 1), date(2026, 10, 1)])
def test_agenda_groups_show_past_skips_week_labels(self):
past_item = self._item(date(2026, 8, 1))
this_week, next_week, months = agenda_groups([past_item], show_past=True)
self.assertEqual(this_week, [])
self.assertEqual(next_week, [])
self.assertEqual(months, [{"month_start": date(2026, 8, 1), "items": [past_item]}])
def test_agenda_groups_empty_list(self):
self.assertEqual(agenda_groups([]), ([], [], []))
def test_agenda_groups_start_of_accessor_for_non_event_items(self):
row = {"event": self._item(date(2026, 8, 21))}
this_week, _next_week, _later = agenda_groups([row], start_of=lambda row: row["event"].start, today=date(2026, 8, 19))
self.assertEqual(this_week, [row])
def test_week_grid_always_covers_the_full_day(self): def test_week_grid_always_covers_the_full_day(self):
monday = date(2026, 8, 17) monday = date(2026, 8, 17)
event = self.make_event(start=self.at(monday, 10), end=self.at(monday, 11, 30)) event = self.make_event(start=self.at(monday, 10), end=self.at(monday, 11, 30))

View File

@@ -1,6 +1,6 @@
{% load i18n lucide ui %} {% load i18n lucide ui %}
<div class="flex items-center gap-3 px-4 py-3 {% if not forloop.last %}border-b border-line{% endif %}"> <div class="flex items-center gap-3 px-4 py-3 {% if not forloop.last %}border-b border-line{% endif %}">
<span class="badge badge-sm shrink-0 {% if event.kind == "game" %}badge-error{% elif event.kind == "training" %}badge-info{% elif event.kind == "tournament" %}badge-warning{% elif event.kind == "meeting" %}badge-neutral{% elif event.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}"> <span class="badge badge-sm w-24 shrink-0 justify-center {% if event.kind == "game" %}badge-error{% elif event.kind == "training" %}badge-info{% elif event.kind == "tournament" %}badge-warning{% elif event.kind == "meeting" %}badge-neutral{% elif event.kind == "social" %}border-violet/30 bg-violet/10 text-violet{% else %}badge-outline{% endif %}">
{{ event.get_kind_display }} {{ event.get_kind_display }}
</span> </span>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">

View File

@@ -86,7 +86,7 @@
{% for month in list_months %} {% for month in list_months %}
<div class="card overflow-hidden"> <div class="card overflow-hidden">
<div class="border-b border-line bg-subhead px-4 py-2 font-display text-xs font-extrabold tracking-[.08em] text-ink uppercase">{{ month.month_start|date:"F Y" }}</div> <div class="border-b border-line bg-subhead px-4 py-2 font-display text-xs font-extrabold tracking-[.08em] text-ink uppercase">{{ month.month_start|date:"F Y" }}</div>
{% for event in month.events %}{% include "management/_event_list_row.html" %}{% endfor %} {% for event in month.items %}{% include "management/_event_list_row.html" %}{% endfor %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -5625,7 +5625,7 @@ class EventManagementTests(ManagementTestBase):
self.assertEqual([event.title for event in response.context["list_next_week"]], ["Next week event"]) self.assertEqual([event.title for event in response.context["list_next_week"]], ["Next week event"])
later_months = response.context["list_months"] later_months = response.context["list_months"]
self.assertEqual(len(later_months), 1) self.assertEqual(len(later_months), 1)
self.assertEqual([event.title for event in later_months[0]["events"]], ["Later event"]) self.assertEqual([event.title for event in later_months[0]["items"]], ["Later event"])
self.assertContains(response, "This week") self.assertContains(response, "This week")
self.assertContains(response, "Next week") self.assertContains(response, "Next week")
self.assertContains(response, later_event.start.strftime("%B %Y")) self.assertContains(response, later_event.start.strftime("%B %Y"))
@@ -5640,7 +5640,7 @@ class EventManagementTests(ManagementTestBase):
self.assertEqual(response.context["list_this_week"], []) self.assertEqual(response.context["list_this_week"], [])
self.assertEqual(response.context["list_next_week"], []) self.assertEqual(response.context["list_next_week"], [])
self.assertEqual(len(response.context["list_months"]), 1) self.assertEqual(len(response.context["list_months"]), 1)
self.assertEqual([event.title for event in response.context["list_months"][0]["events"]], ["Past event"]) self.assertEqual([event.title for event in response.context["list_months"][0]["items"]], ["Past event"])
self.assertNotContains(response, "This week") self.assertNotContains(response, "This week")
def test_the_dashboard_only_shows_upcoming_events_for_managed_teams(self): def test_the_dashboard_only_shows_upcoming_events_for_managed_teams(self):

View File

@@ -1,4 +1,3 @@
import itertools
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
@@ -38,7 +37,7 @@ from controlpanel.mixins import RedirectOnInvalidMixin
from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members from controlpanel.services.statistics import club_attention, club_charts, club_statistics, unrostered_members
from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup from events.models import Attendance, Event, EventReferee, EventSeries, Location, Opponent, RefereeSignup
from events.services.attendance import member_attendance_counts, member_attendance_sparkline, player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows from events.services.attendance import member_attendance_counts, member_attendance_sparkline, player_attendance_rankings, players_who_missed_recent_practices, team_attendance_rate, team_no_shows
from events.services.calendar import add_months, month_bounds, month_grid, season_grid, week_bounds, week_grid from events.services.calendar import add_months, agenda_groups, month_bounds, month_grid, season_grid, week_bounds, week_grid
from events.services.competitions import CompetitionFetchError, fetch_game_info from events.services.competitions import CompetitionFetchError, fetch_game_info
from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html from events.services.rbihf_import import RBIHFImportError, apply_plan, build_plan, extract_team_id, fetch_html
from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series from events.services.recurrence import cancel_occurrence, detach_occurrence, generate_occurrences, propagate_series
@@ -2473,36 +2472,15 @@ class EventListView(ClubStaffRequiredMixin, ListView):
return grid, calendar_nav return grid, calendar_nav
def _list_groups(self, events, show_past): def _list_groups(self, events, show_past):
"""The same This week/Next week/by-month agenda grouping mobile.views. """The shared This week/Next week/by-month agenda grouping (events.
CalendarView uses (see that view's own docstring for the algorithm) -- services.calendar.agenda_groups, also behind mobile.views.CalendarView
applied to whichever page of `events` is actually being shown, so and mobile.coach_views.CoachScheduleView) -- applied to whichever page
pagination and grouping don't fight each other. Past mode (show_past=1, of `events` is actually being shown, so pagination and grouping don't
already descending) skips the this/next-week special-casing -- those fight each other. Past mode (show_past=1, already descending) skips
labels only make sense for what's ahead -- and just groups straight the this/next-week special-casing -- those labels only make sense for
into months, most recent first.""" what's ahead -- and just groups straight into months, most recent
if not events: first."""
return [], [], [] return agenda_groups(events, show_past=show_past)
if show_past:
months = [{"month_start": month_start, "events": list(month_events)} for month_start, month_events in itertools.groupby(events, key=lambda event: timezone.localtime(event.start).date().replace(day=1))]
return [], [], months
today = timezone.localdate()
_this_week_start, this_week_end = week_bounds(today)
next_week_end = this_week_end + timedelta(days=7)
this_week, next_week, later = [], [], []
for event in events:
event_date = timezone.localtime(event.start).date()
if event_date <= this_week_end:
this_week.append(event)
elif event_date <= next_week_end:
next_week.append(event)
else:
later.append(event)
later_months = [{"month_start": month_start, "events": list(month_events)} for month_start, month_events in itertools.groupby(later, key=lambda event: timezone.localtime(event.start).date().replace(day=1))]
return this_week, next_week, later_months
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
club, user = self.request.club, self.request.user club, user = self.request.club, self.request.user

View File

@@ -26,6 +26,7 @@ from controlpanel.messages import notify
from events.models import Attendance, Event, EventSeries, Lineup, LineupSelection, Location, Opponent from events.models import Attendance, Event, EventSeries, Lineup, LineupSelection, Location, Opponent
from events.services import generate_occurrences from events.services import generate_occurrences
from events.services.attendance import member_attendance_counts, record_check_in 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.services.lineup import UNAVAILABLE_STATUSES, cancel_scheduled_publish, publish_lineup, schedule_lineup_publish, toggle_selection
from events.tasks import notify_new_event from events.tasks import notify_new_event
from management.forms import EventForm, EventSeriesForm, LocationForm, NewsForm, OpponentForm 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 straight into the coach-relevant action -- Bench attendance for a
practice, the Line-up for a game -- rather than mobile:event_detail (the 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 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" template_name = "mobile/coach/schedule.html"
screen_title = _("Schedule") screen_title = _("Schedule")
active_tab = "coach_schedule" active_tab = "coach_schedule"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
events = [] this_week, next_week, later_months = [], [], []
if self.active_team is not None: 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")) 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- M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
width chronological agenda, grouped under "This week"/"Next week", then width chronological agenda, grouped under "This week"/"Next week", then
everything further out grouped by calendar month (see CalendarView's own 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 switcher and no club-wide toggle here -- always every event
self.managed_people is invited to, plus (merged into the same self.managed_people is invited to, plus (merged into the same
chronological list, each its own accent colour/look): chronological list, each its own accent colour/look):
@@ -90,7 +90,7 @@
<div> <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="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"> <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 %} {% 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 %} {% endfor %}
</div> </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 %} {% comment %}
Bottom-tab "Schedule" -- every upcoming event for the active team (not 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 just the next one, that's Today's job), grouped into This week/Next
coach-relevant action for its kind rather than mobile:event_detail (the week/by-month dividers -- see CoachScheduleView's own docstring for the
Member-shell RSVP page) -- see CoachScheduleView's own docstring. 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 %} {% endcomment %}
{% block content %} {% block content %}
@@ -13,26 +16,46 @@
<div class="m-card p-6 text-center"> <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> <p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
</div> </div>
{% elif not events %} {% elif not this_week and not next_week and not later_months %}
<div class="m-card p-6 text-center"> <div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing scheduled." %}</p> <p class="text-sm text-muted">{% trans "Nothing scheduled." %}</p>
</div> </div>
{% else %} {% else %}
<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"> <div class="m-card flex flex-col overflow-hidden">
{% for event in events %} {% for event in this_week %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %} {% 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 %}"> {% include "mobile/coach/_schedule_row.html" %}
<div class="w-9.5 shrink-0 text-center"> {% endfor %}
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div> </div>
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</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>
</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>
<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> </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>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% 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 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.models import Attendance, Competition, Event, EventReferee, EventSeries, Lineup, LineupSelection, Location, Opponent, RefereeSignup
from events.services.attendance import record_check_in from events.services.attendance import record_check_in
from events.services.calendar import week_bounds
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News from news.models import News
from notifications.models import Notification from notifications.models import Notification
@@ -850,7 +851,7 @@ class CalendarViewTests(TestCase):
def _events_in_context(self, response): def _events_in_context(self, response):
rows = response.context["this_week"] + response.context["next_week"] rows = response.context["this_week"] + response.context["next_week"]
for month in response.context["later_months"]: for month in response.context["later_months"]:
rows += month["rows"] rows += month["items"]
return {row["event"] for row in rows} return {row["event"] for row in rows}
def add_child(self, first_name="Noor"): def add_child(self, first_name="Noor"):
@@ -977,7 +978,7 @@ class CalendarViewTests(TestCase):
self.assertEqual(len(later_months), 1) self.assertEqual(len(later_months), 1)
month_start = timezone.localtime(far_future.start).date().replace(day=1) month_start = timezone.localtime(far_future.start).date().replace(day=1)
self.assertEqual(later_months[0]["month_start"], month_start) 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")) self.assertContains(response, month_start.strftime("%B %Y"))
def test_further_out_events_are_split_across_separate_month_groups(self): def test_further_out_events_are_split_across_separate_month_groups(self):
@@ -3127,7 +3128,9 @@ class CoachScheduleViewTests(TestCase):
response = self._get() 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): 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") 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.assertEqual(response.status_code, 200)
self.assertContains(response, "Not staffing a team yet") 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"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachAttendanceViewTests(TestCase): class CoachAttendanceViewTests(TestCase):

View File

@@ -4,8 +4,6 @@ phase -- see design_handoff_rosterchief_platform/README.md -- and has no
routes here yet. routes here yet.
""" """
import datetime
import itertools
import json import json
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
@@ -29,7 +27,7 @@ from club.services.sponsors import active_sponsors
from controlpanel.messages import notify from controlpanel.messages import notify
from events.models import Attendance, Event, Lineup, RefereeSignup from events.models import Attendance, Event, Lineup, RefereeSignup
from events.services.attendance import blocked_upcoming_events_for_member 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.lineup import notify_dropout, selected_members_by_position
from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup from events.services.referees import RefereeAssignmentError, accept_referee_signup, decline_referee_signup
from members.models import FamilyMembership, Member 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 """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", then everything further out grouped by calendar "This week"/"Next week", then everything further out grouped by calendar
month (a "later_months" list of {month_start, rows}, each its own sticky month via events.services.calendar.agenda_groups (also behind management.
header) -- an unbounded agenda rather than a fixed lookahead window, since views.EventListView's own "List" mode and mobile.coach_views.
a hard cutoff just hid events a member would reasonably expect to still CoachScheduleView, so all three stay behaviourally identical) -- an
find here. No ?month= paging beyond that grouping -- there's no season 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". browser here, just "everything upcoming, readably grouped".
Always scoped to every one of ``self.managed_people`` -- unlike Home, 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): def get_context_data(self, **kwargs):
now = timezone.now() now = timezone.now()
today = timezone.localdate() 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") kind_filter = self.request.GET.get("kind")
rows = [] rows = []
@@ -386,22 +384,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
rows.sort(key=lambda row: row["event"].start) rows.sort(key=lambda row: row["event"].start)
this_week, next_week, later_rows = [], [], [] this_week, next_week, later_months = agenda_groups(rows, start_of=lambda row: row["event"].start, today=today)
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))
]
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) 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)

File diff suppressed because one or more lines are too long