Build M5 Me & my people for the mobile app

An account-level hub: a header for the signed-in account's own record
(member-since year, a "Team manager {team}" label for someone managing
1-2 teams, otherwise "Staff"), a "People I manage" list linking each
person into M6's edit screen, and a settings card linking into M6 (for
the account's own record) and M7 (Notifications).

The mockup's "Household & contacts"/"Payments & dues" rows and its
"Coach mode" promo have nowhere to lead in this build -- no dedicated
screen for the former, no Coach mode screens at all for the latter (same
precedent base.html's own role switcher already established) -- so
they're omitted rather than built as dead links. "Licence OK" has no
backing field either; each row's meta line uses real current-season
roster data (team + jersey number) instead.

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 14:04:04 +02:00
parent 3559c936f7
commit 19e1acb93a
5 changed files with 239 additions and 4 deletions

View File

@@ -0,0 +1,74 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me &
my people". See MeView's own docstring (mobile/views.py) for the judgment
calls: no license/eligibility field backing "licence OK", so each row's
meta line is real roster data instead; "Household & contacts" and
"Payments & dues" have no screen to lead to and are omitted, same for the
mockup's "Coach mode" promo (base.html's own precedent -- no Coach mode
screens exist yet, so it's never rendered, not even as a dead/inert link).
{% endcomment %}
{% block content %}
{% if 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, your profile will show up here." %}</p>
</div>
{% else %}
<div class="-mx-4 -mt-4 flex items-center gap-3 bg-ink px-4 pt-4 pb-5 text-white">
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-club font-display text-xl font-extrabold text-white">{{ me.first_name|slice:":1" }}{{ me.last_name|slice:":1" }}</span>
<div class="min-w-0 flex-1">
<div class="font-display text-xl leading-none font-extrabold uppercase">{{ me.get_full_name }}</div>
{% if member_since or team_manager_label %}
<div class="mt-1.5 text-[13px] text-on-dark-dim">
{% if member_since %}{% blocktrans with year=member_since|date:"Y" %}Member since {{ year }}{% endblocktrans %}{% endif %}
{% if member_since and team_manager_label %}&middot;{% endif %}
{{ team_manager_label }}
</div>
{% endif %}
</div>
</div>
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "People I manage" %}</div>
<div class="m-card overflow-hidden">
{% for row in people_rows %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:edit_profile" row.person.pk %}">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-steel font-display text-sm font-extrabold text-white">{{ row.person.first_name|slice:":1" }}{{ row.person.last_name|slice:":1" }}</span>
<div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">
{{ row.person.get_full_name }}
{% if row.person == me %}<span class="text-xs text-muted">{% trans "(me)" %}</span>{% endif %}
</div>
{% if row.membership %}
<div class="text-xs text-muted">
{{ row.membership.team.short_name }}
{% if row.membership.jersey_number %}&middot; #{{ row.membership.jersey_number }}{% endif %}
</div>
{% endif %}
</div>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</a>
{% endfor %}
</div>
</div>
<div class="m-card overflow-hidden">
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:edit_profile" me.pk %}">
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Personal details" %}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</a>
<div class="h-px bg-rule"></div>
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:notifications" %}">
<span class="flex-1 text-[15px] font-semibold text-ink">{% trans "Notifications" %}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</a>
</div>
<p class="pb-2 text-center text-xs text-dim">{% blocktrans with club_name=club.name %}RosterChief &middot; {{ club_name }} &middot; v1.0{% endblocktrans %}</p>
{% endif %}
{% endblock content %}

View File

@@ -11,7 +11,7 @@ from events.models import Attendance, Event
from members.models import Family, FamilyMembership, Member
from news.models import News
from notifications.models import Notification
from teams.models import Position, Team, TeamMembership
from teams.models import Position, StaffAssignment, Team, TeamMembership
from .models import PushSubscription
from .services.icons import render_fallback_icon
@@ -756,3 +756,97 @@ class NewsDetailScreenTests(TestCase):
response = self._get(news_item)
self.assertEqual(response.status_code, 302)
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class MeViewTests(TestCase):
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
& my people". See MeView's own docstring for the judgment calls: no
licence/eligibility field backing "licence OK" (real roster data used
instead), "Household & contacts"/"Payments & dues"/"Coach mode" all
omitted since none has anywhere to lead in this build.
"""
@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.family = Family.objects.create(name="Bakker")
FamilyMembership.objects.create(family=cls.family, member=cls.member, role=FamilyMembership.FamilyRole.PARENT)
cls.child = Member.objects.create(first_name="Noor", last_name="Bakker")
FamilyMembership.objects.create(family=cls.family, member=cls.child, role=FamilyMembership.FamilyRole.CHILD)
ClubMembership.objects.create(club=cls.club, member=cls.child, season=cls.season)
def _get(self):
return self.client.get(reverse("mobile:me"), HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_own_record_shows_the_me_suffix(self):
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Lars Bakker")
self.assertContains(response, "(me)")
def test_every_managed_child_appears_in_the_list(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Noor Bakker")
def test_a_persons_row_links_to_their_edit_profile_url(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:edit_profile", kwargs={"member_id": self.member.pk}))
self.assertContains(response, reverse("mobile:edit_profile", kwargs={"member_id": self.child.pk}))
def test_a_managed_persons_current_season_team_and_number_show_as_the_meta_line(self):
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
TeamMembership.objects.create(team=team, member=self.child, season=self.season, jersey_number=9)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "U16")
self.assertContains(response, "#9")
def test_coach_mode_promo_is_never_rendered(self):
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, "Coach mode")
def test_team_manager_label_shows_for_a_current_season_management_staff_assignment(self):
team = Team.objects.create(club=self.club, name="U16", short_name="U16")
position = Position.objects.create(club=self.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=team, member=self.member, season=self.season, position=position)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Team manager U16")
def test_empty_account_gets_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")
self.assertNotContains(response, "Coach mode")

View File

@@ -25,7 +25,7 @@ from django.views import View
from django.views.generic import TemplateView
from club.models import ClubMembership
from club.services.access import current_season
from club.services.access import current_season, has_management_access, teams_managed_by
from club.services.fees import remaining_balance
from events.models import Attendance, Event
from events.services.calendar import week_bounds
@@ -415,10 +415,74 @@ class NewsDetailView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
)
class MeView(_PlaceholderScreen):
class MeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M5 -- design_handoff_rosterchief_platform/README.md's M5 section, "Me
& my people". A header for ``self.me`` (member-since year plus a staff
label, see below), a "People I manage" card (one row per managed_people,
each with their current-season team + jersey number when they're on a
roster), and a settings-ish card linking into M6 (edit_profile) for
``self.me`` and into M7 (notifications).
The mockup's "Household & contacts" and "Payments & dues" rows, and its
"Coach mode" promo card, have nowhere to lead in this build (no dedicated
screen, no Coach mode screens at all yet -- see base.html's own comment)
and are deliberately omitted rather than built as dead or inert links.
There's no license/eligibility field on Member or ClubMembership to power
the mockup's "licence OK" text, so each managed person's meta line is
real roster data instead: current-season team + jersey number when
they're on one, nothing extra otherwise.
"""
template_name = "mobile/me.html"
screen_title = _("Me")
active_tab = "me"
def get_context_data(self, **kwargs):
club = self.request.club
season = current_season(club)
member_since = None
team_manager_label = None
if self.me is not None and season is not None:
membership = ClubMembership.objects.filter(club=club, member=self.me, season=season).first()
if membership is not None:
member_since = membership.created
# PersonScopeMixin.get_context_data computes this same check for the
# ``has_staff_access`` context var, but only as a context value, not
# an attribute on self -- recomputed here since it's needed before
# that runs.
if self.me is not None and has_management_access(self.request.user, club):
# teams_managed_by returns *every* club team for an ADMIN (see its
# own docstring) -- fine for authority checks, but a wall of team
# names makes a poor header subtitle, so it only gets spelled out
# for someone managing a small, specific handful; anyone else
# (including a full club admin) just reads as "Staff".
managed_teams = list(teams_managed_by(self.request.user, club))
if 1 <= len(managed_teams) <= 2:
team_manager_label = _("Team manager %(teams)s") % {"teams": ", ".join(sorted(team.short_name for team in managed_teams))}
else:
team_manager_label = _("Staff")
people_rows = []
if self.managed_people:
memberships_by_member = {}
if season is not None:
memberships_by_member = {
membership.member_id: membership
for membership in TeamMembership.objects.filter(member__in=self.managed_people, season=season).select_related("team")
}
people_rows = [{"person": person, "membership": memberships_by_member.get(person.pk)} for person in self.managed_people]
return super().get_context_data(
member_since=member_since,
team_manager_label=team_manager_label,
people_rows=people_rows,
**kwargs,
)
class EditProfileView(_PlaceholderScreen):
screen_title = _("Edit info")