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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
mobile/templates/mobile/coach/_schedule_row.html
Normal file
13
mobile/templates/mobile/coach/_schedule_row.html
Normal 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 %} · {{ 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>
|
||||
@@ -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 %} · {{ 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 %}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user