Home/Calendar polish: person switcher placement, full-width Calendar, sponsors

- Home: the person switcher (chip row) now lives in the white content area
  instead of the navy header, matching the design canvas's own M1 markup,
  and renders smaller. Home is now the only screen that shows it at all.
- Calendar: dropped the person switcher and the "My schedule"/"All members"
  toggle entirely -- it always shows every event self.managed_people is
  invited to, full stop. Rows now run edge-to-edge (-mx-4) instead of
  living in a rounded, inset card, matching the M3 design canvas's own
  full-bleed layout. (The design mock's List/Month/"Games only" controls
  aren't reproduced -- no real functionality behind them yet.)
- Home's "Needs your answer" card caps at 5 items with a "+N more in
  Calendar" link, so it can't crowd the dues/news cards below it off the
  first screenful.
- Home gains a sponsors strip at the very bottom (horizontally scrolling,
  scrollbar hidden) -- active sponsors only, reshuffled on every request.
  club.services.sponsors.active_sponsors factors the "what counts as
  active" query out of club/api.py's public sponsors endpoint so both it
  and Home share one definition.
- Also removed mobile/views.py's now-fully-dead _PlaceholderScreen and its
  template -- every M1-M7 screen has had a real implementation for a while
  and nothing subclassed it anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-21 15:00:33 +02:00
parent 4b2b84793f
commit 96fc58e453
12 changed files with 263 additions and 198 deletions

View File

@@ -380,16 +380,16 @@
border: 1px solid #dde1e7;
}
/* --- Person switcher chips (M1, M2, M3 -- "a person switcher on anything per-member") --- */
/* --- Person switcher chips (Home's own -- see mobile/templates/mobile/home.html) --- */
.person-chip {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
background: #fff;
border: 1.5px solid var(--color-line);
border-radius: 999px;
padding: 5px 12px 5px 5px;
padding: 4px 10px 4px 4px;
color: var(--color-muted);
flex-shrink: 0;
}
@@ -400,8 +400,8 @@
}
.person-chip-avatar {
width: 28px;
height: 28px;
width: 22px;
height: 22px;
border-radius: 999px;
background: var(--color-stroke);
color: #fff;
@@ -410,7 +410,7 @@
justify-content: center;
font-family: var(--font-display);
font-weight: 800;
font-size: 0.875rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
@@ -421,7 +421,7 @@
.person-chip-label {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.9375rem;
font-size: 0.8125rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -2,17 +2,14 @@
mounted.
"""
import random
import uuid
from datetime import date
from django.db.models import Q
from django.utils import timezone
from ninja import Router, Schema
from api.errors import require_club
from .models import Sponsor
from .services.sponsors import active_sponsors
router = Router(tags=["sponsors"])
@@ -43,20 +40,8 @@ def _to_sponsor_out(sponsor, request) -> SponsorOut:
@router.get("/", response=list[SponsorOut], summary="Active sponsors")
def list_sponsors(request, randomize: bool = False):
"""Sponsors currently "live": start_date has passed and either there's no
end_date (runs indefinitely once started) or it hasn't passed yet. Both
bounds are inclusive of today.
`randomize=true` shuffles the result (e.g. for a sponsor strip that
shouldn't always lead with the same one) -- shuffled in Python after a
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
counts are far too small to need and which SQLite/Postgres don't even
express the same way."""
"""See club.services.sponsors.active_sponsors for what "active" means and
why `randomize=true` shuffles in Python rather than in SQL."""
club = require_club(request)
today = timezone.localdate()
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
if randomize:
random.shuffle(sponsors)
sponsors = active_sponsors(club, randomize=randomize)
return [_to_sponsor_out(sponsor, request) for sponsor in sponsors]

30
club/services/sponsors.py Normal file
View File

@@ -0,0 +1,30 @@
"""Which sponsors are currently "live" -- shared by the public API
(club/api.py, the club's own external website) and the mobile member app's
Home screen (mobile/views.py), so both read the same definition of "active"
rather than each re-deriving it.
"""
import random
from django.db.models import Q
from django.utils import timezone
from ..models import Sponsor
def active_sponsors(club, *, randomize=False):
"""Sponsors currently live for ``club``: ``start_date`` has passed and
either there's no ``end_date`` (runs indefinitely once started) or it
hasn't passed yet. Both bounds are inclusive of today.
``randomize=True`` shuffles the result (e.g. for a sponsor strip that
shouldn't always lead with the same one) -- shuffled in Python after a
stable-ordered fetch rather than an ORDER BY RANDOM(), which sponsor
counts are far too small to need and which SQLite/Postgres don't even
express the same way.
"""
today = timezone.localdate()
sponsors = list(Sponsor.objects.filter(club=club, start_date__lte=today).filter(Q(end_date__isnull=True) | Q(end_date__gte=today)).order_by("name"))
if randomize:
random.shuffle(sponsors)
return sponsors

View File

@@ -6,7 +6,7 @@
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 %}">
<a class="flex items-center gap-3 bg-white px-4 py-3 {% 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>

View File

@@ -1,9 +0,0 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% block content %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-bold text-ink uppercase">{{ screen_title }}</p>
<p class="mt-1 text-sm text-muted">{% trans "This screen is coming soon." %}</p>
</div>
{% endblock content %}

View File

@@ -70,23 +70,6 @@
{% if unread_notification_count %}<span class="absolute top-1.5 right-1.5 h-2 w-2 rounded-full bg-club" style="border: 2px solid var(--color-navy)"></span>{% endif %}
</a>
</div>
{% if managed_people|length > 1 %}
<div class="scrollbar-hide mt-3 flex gap-2 overflow-x-auto pb-0.5">
<a class="person-chip {% if scope_everyone %}person-chip-active{% endif %}" href="?as=all">
<span class="person-chip-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><circle cx="17" cy="9" r="2.6"/><path d="M2.5 20c0-3.3 2.5-5.6 5.5-5.6s5.5 2.3 5.5 5.6"/><path d="M14 15c2.6.3 4.5 2.3 4.5 5"/></svg>
</span>
<span class="person-chip-label">{% trans "All" %}</span>
</a>
{% for person in managed_people %}
<a class="person-chip {% if not scope_everyone and person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
<span class="person-chip-avatar">{{ person.first_name|slice:":1" }}</span>
<span class="person-chip-label">{% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %}</span>
</a>
{% endfor %}
</div>
{% endif %}
</header>
<main class="flex-1 overflow-y-auto">

View File

@@ -2,56 +2,54 @@
{% 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).
M3 -- design_handoff_rosterchief_platform/README.md's M3 section: a full-
width chronological agenda, grouped under "This week"/"Next week" (see
CalendarView's own docstring for the browsing-window judgment call --
current + next calendar week, no further paging). No person switcher and
no club-wide toggle here -- always every event self.managed_people is
invited to, full stop. -mx-4 breaks the rows out of base.html's shared
page padding so they run edge-to-edge, matching the design canvas's own
M3 markup (the desktop List/Month/"Games only" controls in that mock
aren't reproduced here -- no real functionality behind them yet).
{% 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 class="-mx-4">
{% if not managed_people %}
<div class="mx-4">
<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>
</div>
{% elif not this_week and not next_week %}
<div class="mx-4">
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing scheduled in the next two weeks." %}</p>
</div>
</div>
{% else %}
{% if this_week %}
<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">{% trans "This week" %}</div>
<div class="flex flex-col gap-px 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 px-4 py-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Next week" %}</div>
<div class="flex flex-col gap-px bg-line">
{% for row in next_week %}
{% include "mobile/_calendar_row.html" %}
{% endfor %}
</div>
</div>
{% endif %}
{% endif %}
</div>
{% if not scope_all and not managed_people %}
<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 %}

View File

@@ -2,16 +2,37 @@
{% load i18n %}
{% comment %}
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. Four
independently-optional cards, scoped to people_in_scope (mobile/mixins.py)
-- everyone the account manages once there's more than one (the header's
"All" chip, on by default), or just the one person picked from the chip
row. A hero for the soonest upcoming event with a quick In/Out RSVP, a
"needs your answer" list, a season-dues card per person who owes money,
and a news teaser.
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. The
person switcher (chip row) sits in the white content area here, not the
navy header -- matching the design canvas's own M1 markup, and Home is
now the only screen that renders it at all (mobile/mixins.py's scope
resolution still runs for every screen, but only Home's UI exposes it).
Four independently-optional cards below it, scoped to people_in_scope --
everyone the account manages once there's more than one ("All", on by
default), or just the one person picked from the chip row. A hero for
the soonest upcoming event with a quick In/Out RSVP, a "needs your
answer" list, a season-dues card per person who owes money, and a news
teaser.
{% endcomment %}
{% block content %}
{% if managed_people|length > 1 %}
<div class="scrollbar-hide -mt-1 flex gap-2 overflow-x-auto pb-0.5">
<a class="person-chip {% if scope_everyone %}person-chip-active{% endif %}" href="?as=all">
<span class="person-chip-avatar">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><circle cx="17" cy="9" r="2.6"/><path d="M2.5 20c0-3.3 2.5-5.6 5.5-5.6s5.5 2.3 5.5 5.6"/><path d="M14 15c2.6.3 4.5 2.3 4.5 5"/></svg>
</span>
<span class="person-chip-label">{% trans "All" %}</span>
</a>
{% for person in managed_people %}
<a class="person-chip {% if not scope_everyone and person == scope_person %}person-chip-active{% endif %}" href="?as={{ person.pk }}">
<span class="person-chip-avatar">{{ person.first_name|slice:":1" }}</span>
<span class="person-chip-label">{% if person == me %}{% trans "Me" %}{% else %}{{ person.first_name }}{% endif %}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if managed_people %}
{% if hero_attendance %}
<div class="m-card-dark overflow-hidden">
@@ -56,7 +77,7 @@
<div class="m-card p-4">
<div class="flex items-center justify-between">
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Needs your answer" %}</span>
<span class="font-display text-lg font-extrabold text-club">{{ needs_answer|length }}</span>
<span class="font-display text-lg font-extrabold text-club">{{ needs_answer|length|add:needs_answer_remaining }}</span>
</div>
<div class="mt-3 flex flex-col gap-2.5">
{% for attendance in needs_answer %}
@@ -74,6 +95,11 @@
</a>
{% endfor %}
</div>
{% if needs_answer_remaining %}
<a class="mt-3 block text-center text-xs font-semibold text-club" href="{% url "mobile:calendar" %}">
{% blocktrans count counter=needs_answer_remaining %}+{{ counter }} more in Calendar{% plural %}+{{ counter }} more in Calendar{% endblocktrans %}
</a>
{% endif %}
</div>
{% endif %}
@@ -128,4 +154,23 @@
<p class="mt-1 text-sm text-muted">{% trans "Once you're linked to a member record, their schedule and updates will show up here." %}</p>
</div>
{% endif %}
{% if sponsors %}
<div>
<div class="mb-2.5 font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Sponsors" %}</div>
<div class="scrollbar-hide flex gap-3 overflow-x-auto pb-0.5">
{% for sponsor in sponsors %}
{% if sponsor.url %}
<a class="m-card flex h-14 w-28 shrink-0 items-center justify-center p-2" href="{{ sponsor.url }}" target="_blank" rel="noopener">
{% if sponsor.logo %}<img class="max-h-full max-w-full object-contain" src="{{ sponsor.logo.url }}" alt="{{ sponsor.name }}">{% else %}<span class="text-xs font-semibold text-muted">{{ sponsor.name }}</span>{% endif %}
</a>
{% else %}
<div class="m-card flex h-14 w-28 shrink-0 items-center justify-center p-2">
{% if sponsor.logo %}<img class="max-h-full max-w-full object-contain" src="{{ sponsor.logo.url }}" alt="{{ sponsor.name }}">{% else %}<span class="text-xs font-semibold text-muted">{{ sponsor.name }}</span>{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
{% endblock content %}

View File

@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone, translation
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season
from club.models import Club, ClubMembership, DuesInvoice, MemberRequirementStatus, OnboardingRequirement, Season, Sponsor
from events.models import Attendance, Event
from members.models import Family, FamilyMembership, Member
from news.models import News
@@ -103,6 +103,17 @@ class MobileShellTests(TestCase):
self.assertEqual(response.context["scope_person"], child)
def test_bottom_tab_bar_highlights_the_active_screen(self):
self.client.force_login(self.user)
home_response = self._get("home")
self.assertContains(home_response, 'class="tab-bar-item tab-bar-item-active"', count=1)
self.assertEqual(home_response.context["active_tab"], "home")
me_response = self.client.get(reverse("mobile:me"), HTTP_HOST="ajax-united.rosterchief.app")
self.assertContains(me_response, 'class="tab-bar-item tab-bar-item-active"', count=1)
self.assertEqual(me_response.context["active_tab"], "me")
def test_all_chip_only_appears_once_theres_more_than_one_managed_person(self):
self.client.force_login(self.user)
@@ -240,6 +251,22 @@ class HomeViewTests(TestCase):
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {awaiting, maybe})
def test_needs_your_answer_is_capped_at_five_with_a_remaining_count(self):
# A distinct, already-answered earlier event so it becomes the hero and
# none of the seven "Practice N" events below get excluded as the hero.
hero_event = self.make_event(title="Soonest", start=self.future)
Attendance.objects.create(event=hero_event, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
for day in range(1, 8):
event = self.make_event(title=f"Practice {day}", start=self.future + datetime.timedelta(days=day))
Attendance.objects.create(event=event, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(len(response.context["needs_answer"]), 5)
self.assertEqual(response.context["needs_answer_remaining"], 2)
self.assertContains(response, "+2 more in Calendar")
def test_needs_your_answer_excludes_the_hero_event_even_if_unanswered(self):
soon = self.make_event(title="Soonest", start=self.future)
Attendance.objects.create(event=soon, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
@@ -290,6 +317,39 @@ class HomeViewTests(TestCase):
self.assertIsNone(response.context["scope_person"])
self.assertContains(response, "No one to show yet")
def test_sponsors_shows_only_active_ones(self):
today = timezone.localdate()
active = Sponsor.objects.create(club=self.club, name="Active Co", start_date=today - datetime.timedelta(days=10))
Sponsor.objects.create(club=self.club, name="Future Co", start_date=today + datetime.timedelta(days=10))
Sponsor.objects.create(club=self.club, name="Past Co", start_date=today - datetime.timedelta(days=100), end_date=today - datetime.timedelta(days=1))
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(list(response.context["sponsors"]), [active])
self.assertContains(response, "Active Co")
self.assertNotContains(response, "Future Co")
self.assertNotContains(response, "Past Co")
def test_sponsors_are_absent_when_none_are_defined(self):
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(list(response.context["sponsors"]), [])
self.assertNotContains(response, "Sponsors")
def test_sponsors_still_show_for_a_brand_new_account_with_no_managed_people(self):
today = timezone.localdate()
Sponsor.objects.create(club=self.club, name="Active Co", start_date=today - datetime.timedelta(days=10))
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user)
response = self._get("home")
self.assertContains(response, "No one to show yet")
self.assertContains(response, "Active Co")
def add_child(self, first_name="Noor"):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
@@ -467,8 +527,8 @@ class EventDetailRsvpTests(TestCase):
@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."""
"This week"/"Next week" agenda. No person switcher and no club-wide
toggle -- always every event self.managed_people is invited to."""
@classmethod
def setUpTestData(cls):
@@ -495,12 +555,20 @@ class CalendarViewTests(TestCase):
def _events_in_context(self, response):
return {row["event"] for row in response.context["this_week"] + response.context["next_week"]}
def add_child(self, first_name="Noor"):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name=first_name, last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
return child
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):
def test_shows_only_events_the_managed_people_are_invited_to(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)
@@ -511,25 +579,7 @@ class CalendarViewTests(TestCase):
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):
def test_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)
@@ -538,15 +588,7 @@ class CalendarViewTests(TestCase):
self.assertNotIn(cancelled, self._events_in_context(response))
def add_child(self, first_name="Noor"):
family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=family, member=self.member, role=FamilyMembership.FamilyRole.PARENT)
child = Member.objects.create(first_name=first_name, last_name="Bakker")
FamilyMembership.objects.create(family=family, member=child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=self.club, member=child, season=self.season)
return child
def test_my_schedule_aggregates_across_every_managed_person_once_all_is_the_default(self):
def test_aggregates_across_every_managed_person(self):
child = self.add_child()
mine = self.make_event(title="Lars's practice")
theirs = self.make_event(title="Noor's game")
@@ -559,19 +601,15 @@ class CalendarViewTests(TestCase):
self.assertEqual(self._events_in_context(response), {mine, theirs})
self.assertContains(response, "Noor")
def test_selecting_one_person_narrows_my_schedule_back_to_just_them(self):
child = self.add_child()
mine = self.make_event(title="Lars's practice")
theirs = self.make_event(title="Noor's game")
Attendance.objects.create(event=mine, member=self.member)
Attendance.objects.create(event=theirs, member=child)
def test_no_person_switcher_is_rendered(self):
self.add_child()
self.client.force_login(self.user)
response = self._get(**{"as": self.member.pk})
response = self._get()
self.assertEqual(self._events_in_context(response), {mine})
self.assertNotContains(response, 'href="?as=')
def test_my_schedule_with_no_managed_people_shows_the_empty_state_not_a_500(self):
def test_no_managed_people_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)
@@ -591,15 +629,6 @@ class CalendarViewTests(TestCase):
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")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EventDetailScreenTests(TestCase):

View File

@@ -2,11 +2,6 @@
icon, push subscribe) they all sit on top of. Coach mode (C1-C6) is a later
phase -- see design_handoff_rosterchief_platform/README.md -- and has no
routes here yet.
The M1-M7 views below are placeholders: each renders a "coming soon" card
inside the real app shell (base.html), at its final URL name, so the shell
(header, role switcher, tab bar, person switcher) can be verified end-to-end
before every screen is built out one at a time.
"""
import datetime
@@ -28,6 +23,7 @@ from club.models import ClubMembership
from club.services.access import current_season, has_management_access, teams_managed_by
from club.services.fees import remaining_balance
from club.services.onboarding import checklist_for
from club.services.sponsors import active_sponsors
from controlpanel.messages import notify
from events.models import Attendance, Event
from events.services.calendar import week_bounds
@@ -124,19 +120,6 @@ class PushSubscribeView(LoginRequiredMixin, ClubScopedPublicMixin, View):
return JsonResponse({"status": "ok"})
class _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""Stand-in for an M-screen not built yet. Each subclass below is replaced
entirely -- view and template -- when its screen is built; only the URL
name/path in mobile/urls.py needs to stay put."""
template_name = "mobile/_placeholder.html"
screen_title = ""
active_tab = ""
def get_context_data(self, **kwargs):
return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs)
class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
hero card for the soonest upcoming event across everyone currently in
@@ -157,6 +140,10 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
screen_title = _("Home")
active_tab = "home"
#: Keeps the card from crowding the dues/news cards below it off the first
#: screenful -- Calendar is the place to see everything still awaiting a reply.
NEEDS_ANSWER_LIMIT = 5
def get_context_data(self, **kwargs):
people = self.people_in_scope
now = timezone.now()
@@ -164,6 +151,7 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
hero_attendance = None
rsvp_closed = False
needs_answer = []
needs_answer_total = 0
dues_rows = []
news_item = None
@@ -183,7 +171,8 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
if hero_attendance is not None:
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
needs_answer = list(needs_answer_qs)
needs_answer_total = needs_answer_qs.count()
needs_answer = list(needs_answer_qs[: self.NEEDS_ANSWER_LIMIT])
season = current_season(self.request.club)
if season is not None:
@@ -213,9 +202,15 @@ class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
hero_attendance=hero_attendance,
rsvp_closed=rsvp_closed,
needs_answer=needs_answer,
needs_answer_remaining=max(needs_answer_total - len(needs_answer), 0),
dues_rows=dues_rows,
news_item=news_item,
news_team=news_item.teams.first() if news_item is not None else None,
# Club-wide, not person-specific -- shown regardless of managed_people,
# unlike every other card on this screen. Reshuffled on every request
# (see club.services.sponsors.active_sponsors) rather than once per
# session, same as the public-website sponsor strip it shares logic with.
sponsors=active_sponsors(self.request.club, randomize=True),
**kwargs,
)
@@ -229,10 +224,12 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"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.
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
just "what is my family invited to", full stop. (The design mock's own
"All members"/list-vs-month/games-only controls aren't built -- they'd
need real functionality behind them, not just markup; flagged rather than
faked.)
"""
template_name = "mobile/calendar.html"
@@ -251,25 +248,16 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
}
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.people_in_scope:
if self.managed_people:
attendances = (
Attendance.objects.filter(
member__in=self.people_in_scope,
member__in=self.managed_people,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
@@ -279,10 +267,11 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
.prefetch_related("event__teams")
.order_by("event__start")
)
# Only worth naming whose row it is once "everyone" is aggregating more
# than one person -- a single scoped person's own agenda doesn't need it.
# Only worth naming whose row it is once there's more than one managed
# person to tell apart -- a lone member's own agenda doesn't need it.
show_member = len(self.managed_people) > 1
rows = [
{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display(), "member": attendance.member if self.scope_everyone else None}
{"event": attendance.event, "pill_class": self.STATUS_PILL_CLASSES.get(attendance.status, "pill-neutral"), "pill_label": attendance.get_status_display(), "member": attendance.member if show_member else None}
for attendance in attendances
]
@@ -291,7 +280,7 @@ class CalendarView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
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)
return super().get_context_data(this_week=this_week, next_week=next_week, **kwargs)
class EventDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):

View File

@@ -3342,6 +3342,9 @@
.-mx-4 {
margin-inline: calc(var(--spacing) * -4);
}
.mx-4 {
margin-inline: calc(var(--spacing) * 4);
}
.mx-auto {
margin-inline: auto;
}
@@ -3444,6 +3447,9 @@
.-mt-0\.5 {
margin-top: calc(var(--spacing) * -0.5);
}
.-mt-1 {
margin-top: calc(var(--spacing) * -1);
}
.-mt-4 {
margin-top: calc(var(--spacing) * -4);
}
@@ -3998,6 +4004,9 @@
.max-h-96 {
max-height: calc(var(--spacing) * 96);
}
.max-h-full {
max-height: 100%;
}
.max-h-none {
max-height: none;
}
@@ -4055,6 +4064,9 @@
.w-24 {
width: calc(var(--spacing) * 24);
}
.w-28 {
width: calc(var(--spacing) * 28);
}
.w-40 {
width: calc(var(--spacing) * 40);
}
@@ -4133,6 +4145,9 @@
.max-w-\[1440px\] {
max-width: 1440px;
}
.max-w-full {
max-width: 100%;
}
.max-w-md {
max-width: var(--container-md);
}

File diff suppressed because one or more lines are too long