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:
@@ -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;
|
||||
}
|
||||
|
||||
23
club/api.py
23
club/api.py
@@ -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
30
club/services/sponsors.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
129
mobile/tests.py
129
mobile/tests.py
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user