Build M3 Calendar for the mobile app
A bounded chronological agenda (this week / next week, no month paging -- the mobile screen doesn't need the desktop week/month grid events.services.calendar was built for) scoped to the person switcher's current selection, plus an "All members" toggle for the whole club's schedule. Each row links into the still-placeholder-GET event-detail screen that M2 builds out next. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
26
mobile/templates/mobile/_calendar_row.html
Normal file
26
mobile/templates/mobile/_calendar_row.html
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% comment %}
|
||||||
|
One M3 agenda row -- included from calendar.html once per bucket. Expects
|
||||||
|
``row`` ({event, pill_class, pill_label}) in scope. A 4px club-red left
|
||||||
|
border marks games; other kinds get a 3px colour bar (info for training,
|
||||||
|
warn for everything else) next to the date, per the design doc's M3 row
|
||||||
|
description.
|
||||||
|
{% endcomment %}
|
||||||
|
<a class="flex items-center gap-3 bg-white p-3.5 {% if row.event.kind == "game" %}border-l-4 border-club{% endif %}" href="{% url "mobile:event_detail" row.event.pk %}">
|
||||||
|
<div class="w-9.5 shrink-0 text-center">
|
||||||
|
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ row.event.start|date:"D" }}</div>
|
||||||
|
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ row.event.start|date:"d" }}</div>
|
||||||
|
</div>
|
||||||
|
{% if row.event.kind != "game" %}
|
||||||
|
<div class="h-9.5 w-[3px] shrink-0 rounded-full {% if row.event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-semibold text-ink">{{ row.event.title }}</div>
|
||||||
|
<div class="truncate text-xs text-muted">
|
||||||
|
{{ row.event.start|date:"H:i" }}
|
||||||
|
{% for team in row.event.teams.all %}· {{ team.name }}{% endfor %}
|
||||||
|
{% if row.event.location %}· {{ row.event.location.name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill {{ row.pill_class }} shrink-0">{{ row.pill_label }}</span>
|
||||||
|
</a>
|
||||||
57
mobile/templates/mobile/calendar.html
Normal file
57
mobile/templates/mobile/calendar.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "mobile/base.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a
|
||||||
|
chronological agenda, grouped under "This week"/"Next week", scoped to
|
||||||
|
scope_person's own invites by default or, with the "All members" toggle
|
||||||
|
below, every club event. See CalendarView's own docstring for the
|
||||||
|
browsing-window judgment call (current + next calendar week, no further
|
||||||
|
paging).
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if not scope_all %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}"
|
||||||
|
href="?{% if request.GET.as %}as={{ request.GET.as }}{% endif %}">
|
||||||
|
{% trans "My schedule" %}
|
||||||
|
</a>
|
||||||
|
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if scope_all %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}"
|
||||||
|
href="?scope=all{% if request.GET.as %}&as={{ request.GET.as }}{% endif %}">
|
||||||
|
{% trans "All members" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not scope_all and not scope_person %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "No one to show yet" %}</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>
|
||||||
|
{% elif not this_week and not next_week %}
|
||||||
|
<div class="m-card p-6 text-center">
|
||||||
|
<p class="text-sm text-muted">{% trans "Nothing scheduled in the next two weeks." %}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if this_week %}
|
||||||
|
<div>
|
||||||
|
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "This week" %}</div>
|
||||||
|
<div class="flex flex-col gap-px overflow-hidden rounded-box bg-line">
|
||||||
|
{% for row in this_week %}
|
||||||
|
{% include "mobile/_calendar_row.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if next_week %}
|
||||||
|
<div>
|
||||||
|
<div class="sticky top-0 z-10 bg-paper py-1 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
|
||||||
|
<div class="flex flex-col gap-px overflow-hidden rounded-box bg-line">
|
||||||
|
{% for row in next_week %}
|
||||||
|
{% include "mobile/_calendar_row.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
@@ -321,3 +321,98 @@ class EventDetailRsvpTests(TestCase):
|
|||||||
response = self._post(other_event, {"status": "present"})
|
response = self._post(other_event, {"status": "present"})
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
|
||||||
|
class CalendarViewTests(TestCase):
|
||||||
|
"""M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a
|
||||||
|
"This week"/"Next week" agenda, scoped to scope_person's own invites by
|
||||||
|
default or, with ?scope=all, every club event."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
cls.club = make_club()
|
||||||
|
today = timezone.localdate()
|
||||||
|
cls.season = Season.objects.create(club=cls.club, start_date=today - datetime.timedelta(days=30), end_date=today + datetime.timedelta(days=300))
|
||||||
|
cls.user = User.objects.create_user(email="parent@example.com", password="pw-secret-123")
|
||||||
|
cls.member = Member.objects.create(first_name="Lars", last_name="Bakker", email="parent@example.com", user=cls.user)
|
||||||
|
ClubMembership.objects.create(club=cls.club, member=cls.member, season=cls.season)
|
||||||
|
cls.soon = timezone.now() + datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
def _get(self, **params):
|
||||||
|
url = reverse("mobile:calendar")
|
||||||
|
if params:
|
||||||
|
url += "?" + "&".join(f"{key}={value}" for key, value in params.items())
|
||||||
|
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
|
||||||
|
|
||||||
|
def make_event(self, **kwargs):
|
||||||
|
kwargs.setdefault("club", self.club)
|
||||||
|
kwargs.setdefault("title", "Training")
|
||||||
|
kwargs.setdefault("start", self.soon)
|
||||||
|
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"]}
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_per_person_scope_only_shows_that_persons_invited_events(self):
|
||||||
|
invited = self.make_event(title="Lars's practice")
|
||||||
|
not_invited = self.make_event(title="Someone else's practice")
|
||||||
|
Attendance.objects.create(event=invited, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(self._events_in_context(response), {invited})
|
||||||
|
self.assertNotContains(response, not_invited.title)
|
||||||
|
|
||||||
|
def test_all_scope_shows_every_club_event_regardless_of_invitation(self):
|
||||||
|
invited = self.make_event(title="Lars's practice")
|
||||||
|
not_invited = self.make_event(title="Whole squad practice")
|
||||||
|
Attendance.objects.create(event=invited, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get(scope="all")
|
||||||
|
|
||||||
|
self.assertEqual(self._events_in_context(response), {invited, not_invited})
|
||||||
|
|
||||||
|
def test_all_scope_excludes_cancelled_events(self):
|
||||||
|
cancelled = self.make_event(title="Cancelled practice", cancelled=True)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get(scope="all")
|
||||||
|
|
||||||
|
self.assertNotIn(cancelled, self._events_in_context(response))
|
||||||
|
|
||||||
|
def test_per_person_scope_excludes_cancelled_events(self):
|
||||||
|
cancelled = self.make_event(title="Cancelled practice", cancelled=True)
|
||||||
|
Attendance.objects.create(event=cancelled, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertNotIn(cancelled, self._events_in_context(response))
|
||||||
|
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(self._events_in_context(response), set())
|
||||||
|
|
||||||
|
def test_empty_scope_person_shows_a_graceful_empty_state(self):
|
||||||
|
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
|
||||||
|
self.client.force_login(bare_user)
|
||||||
|
|
||||||
|
response = self._get()
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "No one to show yet")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ inside the real app shell (base.html), at its final URL name, so the shell
|
|||||||
before every screen is built out one at a time.
|
before every screen is built out one at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
@@ -26,6 +27,7 @@ from club.models import ClubMembership
|
|||||||
from club.services.access import current_season
|
from club.services.access import current_season
|
||||||
from club.services.fees import remaining_balance
|
from club.services.fees import remaining_balance
|
||||||
from events.models import Attendance, Event
|
from events.models import Attendance, Event
|
||||||
|
from events.services.calendar import week_bounds
|
||||||
from members.models import Member
|
from members.models import Member
|
||||||
from members.views import ClubScopedPublicMixin
|
from members.views import ClubScopedPublicMixin
|
||||||
from news.models import News
|
from news.models import News
|
||||||
@@ -213,10 +215,74 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarView(_PlaceholderScreen):
|
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 across the current and next calendar week (no
|
||||||
|
"Later"/past bucket, no ?month= paging) -- a simple, bounded agenda rather
|
||||||
|
than a full season browser.
|
||||||
|
|
||||||
|
``?scope=all`` is the design doc's extra "All members" scope on top of
|
||||||
|
the normal per-person chip switcher (mobile/mixins.py's scope_person):
|
||||||
|
every club event instead of just scope_person's own invites, since
|
||||||
|
there's no single person's Attendance row to key off.
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_name = "mobile/calendar.html"
|
||||||
screen_title = _("Calendar")
|
screen_title = _("Calendar")
|
||||||
active_tab = "calendar"
|
active_tab = "calendar"
|
||||||
|
|
||||||
|
#: Pill styling for a per-person RSVP status (assets/mobile.css's .pill-*).
|
||||||
|
STATUS_PILL_CLASSES = {
|
||||||
|
Attendance.AttendanceStatus.PRESENT: "pill-ok",
|
||||||
|
Attendance.AttendanceStatus.SELECTED: "pill-ok",
|
||||||
|
Attendance.AttendanceStatus.ABSENT: "pill-danger",
|
||||||
|
Attendance.AttendanceStatus.NOT_SELECTED: "pill-neutral",
|
||||||
|
Attendance.AttendanceStatus.EXCUSED: "pill-neutral",
|
||||||
|
Attendance.AttendanceStatus.MAYBE: "pill-warn",
|
||||||
|
Attendance.AttendanceStatus.NO_RESPONSE: "pill-warn",
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
scope_all = self.request.GET.get("scope") == "all"
|
||||||
|
now = timezone.now()
|
||||||
|
_this_week_start, this_week_end = week_bounds(timezone.localdate())
|
||||||
|
next_week_end = this_week_end + datetime.timedelta(days=7)
|
||||||
|
window_end = timezone.make_aware(datetime.datetime.combine(next_week_end, datetime.time.max))
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
if scope_all:
|
||||||
|
events = (
|
||||||
|
Event.objects.filter(club=self.request.club, cancelled=False, start__gte=now, start__lte=window_end)
|
||||||
|
.select_related("location", "opponent")
|
||||||
|
.prefetch_related("teams")
|
||||||
|
.order_by("start")
|
||||||
|
)
|
||||||
|
rows = [{"event": event, "pill_class": "pill-info", "pill_label": event.get_kind_display()} for event in events]
|
||||||
|
elif self.scope_person is not None:
|
||||||
|
attendances = (
|
||||||
|
Attendance.objects.filter(
|
||||||
|
member=self.scope_person,
|
||||||
|
event__club=self.request.club,
|
||||||
|
event__cancelled=False,
|
||||||
|
event__start__gte=now,
|
||||||
|
event__start__lte=window_end,
|
||||||
|
)
|
||||||
|
.select_related("event", "event__location", "event__opponent")
|
||||||
|
.prefetch_related("event__teams")
|
||||||
|
.order_by("event__start")
|
||||||
|
)
|
||||||
|
rows = [{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display()} for attendance in attendances]
|
||||||
|
|
||||||
|
this_week, next_week = [], []
|
||||||
|
for row in rows:
|
||||||
|
bucket = this_week if timezone.localtime(row["event"].start).date() <= this_week_end else next_week
|
||||||
|
bucket.append(row)
|
||||||
|
|
||||||
|
return super().get_context_data(scope_all=scope_all, this_week=this_week, next_week=next_week, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class EventDetailView(_PlaceholderScreen):
|
class EventDetailView(_PlaceholderScreen):
|
||||||
"""GET is still the M2 placeholder (a later screen owns the full detail
|
"""GET is still the M2 placeholder (a later screen owns the full detail
|
||||||
|
|||||||
@@ -2751,6 +2751,9 @@
|
|||||||
.-top-1 {
|
.-top-1 {
|
||||||
top: calc(var(--spacing) * -1);
|
top: calc(var(--spacing) * -1);
|
||||||
}
|
}
|
||||||
|
.top-0 {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
.top-1 {
|
.top-1 {
|
||||||
top: var(--spacing);
|
top: var(--spacing);
|
||||||
}
|
}
|
||||||
@@ -3881,6 +3884,9 @@
|
|||||||
.h-9 {
|
.h-9 {
|
||||||
height: calc(var(--spacing) * 9);
|
height: calc(var(--spacing) * 9);
|
||||||
}
|
}
|
||||||
|
.h-9\.5 {
|
||||||
|
height: calc(var(--spacing) * 9.5);
|
||||||
|
}
|
||||||
.h-10 {
|
.h-10 {
|
||||||
height: calc(var(--spacing) * 10);
|
height: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
@@ -4046,6 +4052,9 @@
|
|||||||
.w-80 {
|
.w-80 {
|
||||||
width: calc(var(--spacing) * 80);
|
width: calc(var(--spacing) * 80);
|
||||||
}
|
}
|
||||||
|
.w-\[3px\] {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
.w-\[7px\] {
|
.w-\[7px\] {
|
||||||
width: 7px;
|
width: 7px;
|
||||||
}
|
}
|
||||||
@@ -4310,6 +4319,9 @@
|
|||||||
.gap-10 {
|
.gap-10 {
|
||||||
gap: calc(var(--spacing) * 10);
|
gap: calc(var(--spacing) * 10);
|
||||||
}
|
}
|
||||||
|
.gap-px {
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
.space-y-2 {
|
.space-y-2 {
|
||||||
:where(& > :not(:last-child)) {
|
:where(& > :not(:last-child)) {
|
||||||
--tw-space-y-reverse: 0;
|
--tw-space-y-reverse: 0;
|
||||||
@@ -4519,6 +4531,9 @@
|
|||||||
.bg-base-200 {
|
.bg-base-200 {
|
||||||
background-color: var(--color-base-200);
|
background-color: var(--color-base-200);
|
||||||
}
|
}
|
||||||
|
.bg-info {
|
||||||
|
background-color: var(--color-info);
|
||||||
|
}
|
||||||
.bg-secondary {
|
.bg-secondary {
|
||||||
background-color: var(--color-secondary);
|
background-color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user