Redesign the Coach mode shell: ice-blue header, flat sheet, Squad/Schedule tabs

Reworks the coach shell per feedback that the previous dark-ink header
didn't read as visually distinct from Member mode's own navy header, and
that the rounded "sheet overlaps header" treatment wasn't landing:

- .coach-header is now solid ice-blue (--color-ice, never club-themed --
  Coach mode's own signature colour) with --color-ice-ink foreground text,
  instead of dark ink with white text. Every header_extra block across the
  coach templates (attendance, lineup) is recoloured to match.
- .coach-sheet drops the 20px rounded-top/negative-margin overlap -- flush
  edge-to-edge below the header now, same join the member shell already uses.
- The header's "Head coach" label was hardcoded regardless of the account's
  actual role -- CoachScopeMixin now resolves active_team_role from the
  person's own current-season StaffAssignment.position on the active team,
  so a team manager or physio sees their real title, not someone else's.

Tab bar is now Today / Squad / [+] / Schedule -- no Me tab (the account's
own settings already live in Member mode via the role switcher, no need
for a second one). Squad (CoachSquadView) is a new roster+staff screen for
the active team; Schedule (CoachScheduleView) is a new full upcoming-events
list for the team, each row routed straight to the coach-relevant action
(Attendance for a practice, Line-up for a game) rather than the Member-
shell RSVP page. The "+" is a raised ice-blue circle opening a small popup
with New event/New post/Add player -- three genuinely different actions,
so the button opens a menu rather than committing to one destination.
Today's own inline New event/New post/Add player buttons are gone now that
the tab bar covers the same ground.

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 23:21:27 +02:00
parent 64d0fec547
commit 1d159ca5de
13 changed files with 421 additions and 45 deletions

View File

@@ -447,29 +447,27 @@
} }
/* --- Coach mode shell (C1-C6) --------------------------------------------------- /* --- Coach mode shell (C1-C6) ---------------------------------------------------
Dark-chrome mirror of the member .app-header/.tab-bar pair above. .coach-sheet is Ice-blue header (never club-themed -- same --color-ice token the rest of Coach
the mode's signature: a light body that overlaps the ink header by 20px via a mode already uses) is the mode's own signature, distinct at a glance from the
matching negative margin, so the header appears to sit "behind" a rounded sheet member shell's club-themed navy header. .coach-sheet sits flush below it, edge to
rather than the two stacking edge-to-edge like the member shell's navy header does. */ edge like the member shell's own header/body join -- no rounded overlap. */
.coach-header { .coach-header {
padding: max(env(safe-area-inset-top), 14px) 16px 14px; padding: max(env(safe-area-inset-top), 14px) 16px 14px;
background: var(--color-ink); background: var(--color-ice);
color: #fff; color: var(--color-ice-ink);
flex-shrink: 0; flex-shrink: 0;
} }
.coach-sheet { .coach-sheet {
background: var(--color-paper); background: var(--color-paper);
border-radius: 20px 20px 0 0;
margin-top: -20px;
position: relative;
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
} }
.coach-tab-bar { .coach-tab-bar {
flex-shrink: 0; flex-shrink: 0;
position: relative; /* anchors the "+" action's popup menu */
background: var(--color-ink); background: var(--color-ink);
border-top: 1px solid var(--color-hairline); border-top: 1px solid var(--color-hairline);
padding: 8px 8px max(env(safe-area-inset-bottom), 26px); padding: 8px 8px max(env(safe-area-inset-bottom), 26px);
@@ -490,4 +488,28 @@
.coach-tab-bar-item-active { .coach-tab-bar-item-active {
color: var(--color-ice); color: var(--color-ice);
} }
/* The tab bar's own "+" action -- raised above the bar line in a solid ice
circle so it reads as the one prominent action among otherwise-equal nav
items, same idea as a FAB parked inside a bottom bar. */
.coach-tab-bar-add {
flex: 1;
display: flex;
align-items: flex-start;
justify-content: center;
}
.coach-tab-bar-add-button {
width: 48px;
height: 48px;
margin-top: -20px;
border-radius: 999px;
background: var(--color-ice);
color: var(--color-ice-ink);
display: flex;
align-items: center;
justify-content: center;
border: 4px solid var(--color-ink);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}
} }

View File

@@ -8,6 +8,7 @@ several people the way Member mode's person switcher does.
from club.services.access import current_season, teams_managed_by, teams_staffed_by from club.services.access import current_season, teams_managed_by, teams_staffed_by
from members.models import Member from members.models import Member
from members.views import ClubScopedPublicMixin from members.views import ClubScopedPublicMixin
from teams.models import StaffAssignment
#: Session key remembering which team was last active, so navigating between #: Session key remembering which team was last active, so navigating between
#: coach screens (or leaving and coming back) doesn't reset the picker to #: coach screens (or leaving and coming back) doesn't reset the picker to
@@ -33,6 +34,11 @@ class CoachScopeMixin(ClubScopedPublicMixin):
disabled -- a coach on staff but without a management position (e.g. a disabled -- a coach on staff but without a management position (e.g. a
physio) can see Coach mode but shouldn't see edit affordances they don't physio) can see Coach mode but shouldn't see edit affordances they don't
have the authority to use. have the authority to use.
``active_team_role`` is ``self.me``'s own StaffAssignment.position on
``active_team`` this season (e.g. "Team manager", "Physio") -- the
header shows this instead of a hardcoded "Head coach", since not every
staffed team is one the account holder actually coaches.
""" """
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@@ -41,8 +47,15 @@ class CoachScopeMixin(ClubScopedPublicMixin):
self.managed_teams = list(teams_managed_by(request.user, request.club)) if request.user.is_authenticated else [] self.managed_teams = list(teams_managed_by(request.user, request.club)) if request.user.is_authenticated else []
self.active_team = self._resolve_active_team(request) self.active_team = self._resolve_active_team(request)
self.can_manage_active_team = self.active_team is not None and self.active_team in self.managed_teams self.can_manage_active_team = self.active_team is not None and self.active_team in self.managed_teams
self.active_team_role = self._resolve_active_team_role(request)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def _resolve_active_team_role(self, request):
if self.me is None or self.active_team is None:
return None
assignment = StaffAssignment.objects.filter(member=self.me, team=self.active_team, season=current_season(request.club)).select_related("position").first()
return assignment.position if assignment is not None else None
def _resolve_active_team(self, request): def _resolve_active_team(self, request):
requested_id = request.GET.get("team") requested_id = request.GET.get("team")
for team in self.staffed_teams: for team in self.staffed_teams:
@@ -66,6 +79,7 @@ class CoachScopeMixin(ClubScopedPublicMixin):
staffed_teams=self.staffed_teams, staffed_teams=self.staffed_teams,
active_team=self.active_team, active_team=self.active_team,
can_manage_active_team=self.can_manage_active_team, can_manage_active_team=self.can_manage_active_team,
active_team_role=self.active_team_role,
season=current_season(self.request.club), season=current_season(self.request.club),
**kwargs, **kwargs,
) )

View File

@@ -26,7 +26,7 @@ from management.forms import EventForm, NewsForm
from members.models import Member from members.models import Member
from news.models import News from news.models import News
from news.services import notify_editors_of_pending_review from news.services import notify_editors_of_pending_review
from teams.models import Team, TeamMembership from teams.models import StaffAssignment, Team, TeamMembership
from teams.services import eligible_roster_members from teams.services import eligible_roster_members
from .coach_mixins import CoachScopeMixin from .coach_mixins import CoachScopeMixin
@@ -535,3 +535,46 @@ class CoachLineupPublishView(CoachScopeMixin, LoginRequiredMixin, View):
publish_lineup(lineup) publish_lineup(lineup)
notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}") notify(request, f"s|{_('Line-up published')}|{_('Selected players have been notified.')}")
return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk})) return HttpResponseRedirect(reverse("mobile:coach_lineup", kwargs={"event_id": event.pk}))
class CoachSquadView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Squad" -- the active team's roster and staff for the
current season, view-only beyond the "Add player" entry point (which
reuses CoachAddPlayerView/C6). No per-row edit here (jersey number,
position, captaincy) -- that stays a desktop-only action for now via
management.forms.TeamMembershipForm; this screen is about seeing the
squad, not managing individual rows from a phone.
"""
template_name = "mobile/coach/squad.html"
screen_title = _("Squad")
active_tab = "coach_squad"
def get_context_data(self, **kwargs):
season = current_season(self.request.club)
roster, staff = [], []
if self.active_team is not None and season is not None:
roster = list(TeamMembership.objects.filter(team=self.active_team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
staff = list(StaffAssignment.objects.filter(team=self.active_team, season=season).select_related("member", "position").order_by("position__ordering", "member__last_name"))
return super().get_context_data(roster=roster, staff=staff, **kwargs)
class CoachScheduleView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""Bottom-tab "Schedule" -- every upcoming event for the active team, full
stop (not Today's own "just the next session" scope). Each row jumps
straight into the coach-relevant action -- Bench attendance for a
practice, the Line-up for a game -- rather than mobile:event_detail (the
Member-shell RSVP page a coach browsing their own team's schedule has no
use for)."""
template_name = "mobile/coach/schedule.html"
screen_title = _("Schedule")
active_tab = "coach_schedule"
def get_context_data(self, **kwargs):
events = []
if self.active_team is not None:
events = list(Event.objects.filter(teams=self.active_team, cancelled=False, start__gte=timezone.now()).order_by("start"))
return super().get_context_data(events=events, **kwargs)

View File

@@ -13,10 +13,10 @@
{% block header_extra %} {% block header_extra %}
<div class="mt-3"> <div class="mt-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Attendance" %}</span> <span class="font-display text-xl leading-none font-extrabold text-ice-ink uppercase">{% trans "Attendance" %}</span>
<span class="font-mono text-sm text-on-dark">{{ checked_in_count }}/{{ total_count }}</span> <span class="font-mono text-sm text-ice-ink/70">{{ checked_in_count }}/{{ total_count }}</span>
</div> </div>
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} &middot; {{ event.start|date:"D d M H:i" }}</div> <div class="mt-1 text-xs text-ice-ink/65">{{ event.title }} &middot; {{ event.start|date:"D d M H:i" }}</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-steel"> <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-steel">
<div class="h-full bg-ice" style="width: {% widthratio checked_in_count total_count|default:1 100 %}%"></div> <div class="h-full bg-ice" style="width: {% widthratio checked_in_count total_count|default:1 100 %}%"></div>
</div> </div>

View File

@@ -4,19 +4,21 @@
App shell for Coach mode (C1-C6) -- design_handoff_rosterchief_platform/README.md's App shell for Coach mode (C1-C6) -- design_handoff_rosterchief_platform/README.md's
"Coach mode (mobile, dark chrome)" section. Standalone from mobile/templates/mobile/ "Coach mode (mobile, dark chrome)" section. Standalone from mobile/templates/mobile/
base.html (Member mode's shell) rather than a shared parent: the two have almost no base.html (Member mode's shell) rather than a shared parent: the two have almost no
markup in common beyond the outer <html>/<body> skeleton -- dark ink header instead markup in common beyond the outer <html>/<body> skeleton -- an ice-blue header
of navy, a light .coach-sheet body that overlaps the header by 20px (the mode's own (never club-themed, the mode's own signature colour, see assets/mobile.css's own
signature, see assets/mobile.css's own comment), and a dark .coach-tab-bar instead of comment) instead of the member shell's club-themed navy, and a dark .coach-tab-bar
the white one. instead of the white one.
Reuses the SAME stylesheet (static/css/mobile.css) and the same mobile:manifest/ Reuses the SAME stylesheet (static/css/mobile.css) and the same mobile:manifest/
mobile:icon/mobile:service_worker PWA plumbing as Member mode -- one app, one mobile:icon/mobile:service_worker PWA plumbing as Member mode -- one app, one
manifest, two modes, not two separate PWAs. manifest, two modes, not two separate PWAs.
The tab bar deliberately stays minimal (Today + Me) even with all six coach screens Tab bar: Today / Squad / Schedule, plus a raised "+" action in the middle that opens
built -- C2/C3/C4/C5/C6 are reached from Today's own action buttons and "needs you" a small popup menu (New event / New post / Add player) rather than committing to one
list, not separate tab items; Me reuses the *existing* member mobile:me page rather destination -- coach mode has three genuinely different "add" actions and picking a
than a separate coach-Me screen (see CoachTodayView's own docstring). single one for the button would just hide the other two. No Me tab here -- the
account's own settings live in Member mode's mobile:me, reached via the role
switcher above, not duplicated as a second Me screen.
hx-boost="true" on <body>: every same-shell link/form becomes an AJAX navigation hx-boost="true" on <body>: every same-shell link/form becomes an AJAX navigation
instead of a full browser reload -- see mobile/templates/mobile/base.html's own instead of a full browser reload -- see mobile/templates/mobile/base.html's own
@@ -29,7 +31,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0b1220"> <meta name="theme-color" content="#14b8e8">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -66,15 +68,15 @@
<span class="app-crest app-crest-fallback">{{ club.initials }}</span> <span class="app-crest app-crest-fallback">{{ club.initials }}</span>
{% endif %} {% endif %}
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block truncate font-display text-[19px] leading-none font-extrabold text-white uppercase">{{ active_team.name|default:club.name }}</span> <span class="block truncate font-display text-[19px] leading-none font-extrabold text-ice-ink uppercase">{{ active_team.name|default:club.name }}</span>
{% if me %}<span class="block font-mono text-xs text-on-dark">{% trans "Head coach" %} &middot; {{ me.first_name }}</span>{% endif %} {% if me and active_team_role %}<span class="block font-mono text-xs text-ice-ink/70">{{ active_team_role }} &middot; {{ me.first_name }}</span>{% endif %}
</span> </span>
</div> </div>
{% if staffed_teams|length > 1 %} {% if staffed_teams|length > 1 %}
<div class="mt-3 flex gap-2 overflow-x-auto scrollbar-hide"> <div class="mt-3 flex gap-2 overflow-x-auto scrollbar-hide">
{% for team in staffed_teams %} {% for team in staffed_teams %}
<a class="shrink-0 rounded-full px-3 py-1.5 font-display text-xs font-extrabold tracking-wide uppercase {% if team == active_team %}bg-ice text-ice-ink{% else %}bg-steel text-on-dark{% endif %}" href="?team={{ team.pk }}"> <a class="shrink-0 rounded-full px-3 py-1.5 font-display text-xs font-extrabold tracking-wide uppercase {% if team == active_team %}bg-ink text-ice{% else %}bg-white/40 text-ice-ink{% endif %}" href="?team={{ team.pk }}">
{{ team.short_name }} {{ team.short_name }}
</a> </a>
{% endfor %} {% endfor %}
@@ -108,14 +110,35 @@
{% lucide "house" size=21 %} {% lucide "house" size=21 %}
<span class="tab-bar-label">{% trans "Today" %}</span> <span class="tab-bar-label">{% trans "Today" %}</span>
</a> </a>
{% comment %} <a class="coach-tab-bar-item {% if active_tab == "coach_squad" %}coach-tab-bar-item-active{% endif %}" href="{% url "mobile:coach_squad" %}">
hx-boost="false" -- mobile:me is a Member-shell view (bg-paper, not {% lucide "users" size=21 %}
this page's bg-ink); see base.html's own comment for why a boosted <span class="tab-bar-label">{% trans "Squad" %}</span>
cross-shell swap would leave the wrong <body> class in place. </a>
{% endcomment %}
<a class="coach-tab-bar-item" href="{% url "mobile:me" %}" hx-boost="false"> {% if can_manage_active_team %}
{% lucide "user" size=21 %} <div class="coach-tab-bar-add" x-data="{ open: false }">
<span class="tab-bar-label">{% trans "Me" %}</span> <button class="coach-tab-bar-add-button" type="button" @click="open = !open" aria-label="{% trans "Add" %}">
{% lucide "plus" size=24 %}
</button>
<div class="absolute inset-x-3 bottom-full z-20 mb-3 flex flex-col gap-1 rounded-xl border border-line bg-white p-2 shadow-lg" x-show="open" x-cloak @click.outside="open = false">
<a class="flex items-center gap-3 rounded-lg p-3 text-sm font-semibold text-ink" href="{% url "mobile:coach_create_event" %}">
{% lucide "calendar-plus" size=18 class="text-club" %} {% trans "New event" %}
</a>
<a class="flex items-center gap-3 rounded-lg p-3 text-sm font-semibold text-ink" href="{% url "mobile:coach_create_news" %}">
{% lucide "newspaper" size=18 class="text-club" %} {% trans "New post" %}
</a>
<a class="flex items-center gap-3 rounded-lg p-3 text-sm font-semibold text-ink" href="{% url "mobile:coach_add_player" %}">
{% lucide "user-plus" size=18 class="text-club" %} {% trans "Add player" %}
</a>
</div>
</div>
{% else %}
<div class="coach-tab-bar-add"></div>
{% endif %}
<a class="coach-tab-bar-item {% if active_tab == "coach_schedule" %}coach-tab-bar-item-active{% endif %}" href="{% url "mobile:coach_schedule" %}">
{% lucide "calendar" size=21 %}
<span class="tab-bar-label">{% trans "Schedule" %}</span>
</a> </a>
</nav> </nav>

View File

@@ -15,10 +15,10 @@
{% block header_extra %} {% block header_extra %}
<div class="mt-3 flex items-center justify-between"> <div class="mt-3 flex items-center justify-between">
<span class="font-display text-xl leading-none font-extrabold text-white uppercase">{% trans "Line-up" %}</span> <span class="font-display text-xl leading-none font-extrabold text-ice-ink uppercase">{% trans "Line-up" %}</span>
{% if lineup.published_at %}<span class="pill pill-info">{% trans "Published" %}</span>{% endif %} {% if lineup.published_at %}<span class="pill pill-info">{% trans "Published" %}</span>{% endif %}
</div> </div>
<div class="mt-1 text-xs text-on-dark-dim">{{ event.title }} &middot; {{ event.start|date:"D d M H:i" }}</div> <div class="mt-1 text-xs text-ice-ink/65">{{ event.title }} &middot; {{ event.start|date:"D d M H:i" }}</div>
{% endblock header_extra %} {% endblock header_extra %}
{% block content %} {% block content %}

View File

@@ -0,0 +1,39 @@
{% extends "mobile/coach/base.html" %}
{% load i18n %}
{% comment %}
Bottom-tab "Schedule" -- every upcoming event for the active team (not
just the next one, that's Today's job). Each row links straight into the
coach-relevant action for its kind rather than mobile:event_detail (the
Member-shell RSVP page) -- see CoachScheduleView's own docstring.
{% endcomment %}
{% block content %}
{% if not active_team %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
</div>
{% elif not events %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing scheduled." %}</p>
</div>
{% else %}
<div class="m-card flex flex-col overflow-hidden">
{% for event in events %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<a class="flex items-center gap-3 p-3.5" href="{% if event.kind == "game" %}{% url "mobile:coach_lineup" event.pk %}{% else %}{% url "mobile:coach_attendance" event.pk %}{% endif %}">
<div class="w-9.5 shrink-0 text-center">
<div class="font-mono text-[10px] tracking-wide text-muted uppercase">{{ event.start|date:"D" }}</div>
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ event.start|date:"d" }}</div>
</div>
<div class="w-[3px] shrink-0 self-stretch rounded-full {% if event.kind == "game" %}bg-club{% elif event.kind == "training" %}bg-info{% else %}bg-warn{% endif %}"></div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ event.title }}</div>
<div class="truncate text-xs text-muted">{{ event.start|date:"H:i" }}{% if event.location %} &middot; {{ event.location.name }}{% endif %}</div>
</div>
<span class="pill {% if event.kind == "game" %}pill-danger{% elif event.kind == "training" %}pill-info{% else %}pill-neutral{% endif %} shrink-0">{{ event.get_kind_display }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,62 @@
{% extends "mobile/coach/base.html" %}
{% load i18n lucide %}
{% comment %}
Bottom-tab "Squad" -- roster + staff for the active team, current season.
See CoachSquadView's own docstring for why there's no per-row edit here
(jersey number/position/captaincy stays a desktop-only action for now).
{% endcomment %}
{% block content %}
{% if not active_team %}
<div class="m-card p-6 text-center">
<p class="font-display text-lg font-extrabold text-ink uppercase">{% trans "Not staffing a team yet" %}</p>
</div>
{% else %}
<div>
<div class="mb-2 flex items-center justify-between">
<span class="font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Roster" %}</span>
{% if can_manage_active_team %}
<a class="flex items-center gap-1 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_add_player" %}">
{% lucide "user-plus" size=14 %} {% trans "Add" %}
</a>
{% endif %}
</div>
<div class="m-card overflow-hidden">
{% for membership in roster %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<div class="flex items-center gap-3 p-3.5">
<div class="w-8 shrink-0 text-center font-display text-lg font-extrabold text-ink tabular-nums">{{ membership.jersey_number|default:"—" }}</div>
<div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">
{{ membership.member.get_full_name }}
{% if membership.is_captain %}<span class="ml-1 font-display text-xs font-extrabold text-club-dark">C</span>{% endif %}
{% if membership.is_alternate_captain %}<span class="ml-1 font-display text-xs font-extrabold text-club-dark">A</span>{% endif %}
</div>
<div class="text-xs text-muted">{{ membership.position|default:"—" }}</div>
</div>
</div>
{% empty %}
<div class="p-6 text-center text-sm text-muted">{% trans "No one on the roster for this season yet." %}</div>
{% endfor %}
</div>
</div>
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Staff" %}</div>
<div class="m-card overflow-hidden">
{% for assignment in staff %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<div class="flex items-center gap-3 p-3.5">
<div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">{{ assignment.member.get_full_name }}</div>
<div class="text-xs text-muted">{{ assignment.position }}</div>
</div>
</div>
{% empty %}
<div class="p-6 text-center text-sm text-muted">{% trans "No staff assigned for this season yet." %}</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock content %}

View File

@@ -6,7 +6,9 @@
stat tiles, a tonight's-session card (only rendered when one exists), stat tiles, a tonight's-session card (only rendered when one exists),
a "needs you" list, and an "Also yours" card for the coach's own a "needs you" list, and an "Also yours" card for the coach's own
member-side RSVP. See CoachTodayView's own docstring for what's scoped member-side RSVP. See CoachTodayView's own docstring for what's scoped
down from the mock and why. down from the mock and why. The mock's own New event/New post/Add
player actions live on the shell's own "+" tab-bar button now (coach/
base.html) rather than repeated here as inline buttons.
{% endcomment %} {% endcomment %}
{% block content %} {% block content %}
@@ -16,14 +18,6 @@
<p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p> <p class="mt-1 text-sm text-muted">{% trans "Once you're assigned to a team's staff, its schedule and roster will show up here." %}</p>
</div> </div>
{% else %} {% else %}
{% if can_manage_active_team %}
<div class="flex gap-2">
<a class="btn btn-dark flex-1" href="{% url "mobile:coach_create_event" %}">{% trans "New event" %}</a>
<a class="btn btn-secondary flex-1" href="{% url "mobile:coach_create_news" %}">{% trans "New post" %}</a>
<a class="btn btn-secondary flex-1" href="{% url "mobile:coach_add_player" %}">{% trans "Add player" %}</a>
</div>
{% endif %}
<div class="grid grid-cols-3 gap-2.5"> <div class="grid grid-cols-3 gap-2.5">
<div class="m-card p-3 text-center"> <div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div> <div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div>

View File

@@ -1891,6 +1891,174 @@ class CoachTodayViewTests(TestCase):
self.assertNotContains(response, "Line-up not published") self.assertNotContains(response, "Line-up not published")
def test_header_shows_the_persons_actual_role_not_a_hardcoded_label(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Head coach")
self.assertEqual(response.context["active_team_role"], self.position)
def test_header_role_reflects_a_non_coaching_staff_position(self):
physio_position = Position.objects.create(club=self.club, name="Team manager", short_name="TM", staff_position=True, management_position=True)
physio_user = User.objects.create_user(email="tm@example.com", password="pw-secret-123")
physio_member = Member.objects.create(first_name="Tom", last_name="Manager", user=physio_user)
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
self.client.force_login(physio_user)
response = self._get()
self.assertContains(response, "Team manager")
self.assertNotContains(response, "Head coach")
def test_tab_bar_has_no_me_tab(self):
self.client.force_login(self.user)
response = self._get()
self.assertNotContains(response, reverse("mobile:me"))
def test_tab_bar_links_to_squad_and_schedule(self):
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:coach_squad"))
self.assertContains(response, reverse("mobile:coach_schedule"))
def test_add_menu_hidden_for_non_managing_staff(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
self.client.force_login(physio_user)
response = self._get()
self.assertNotContains(response, reverse("mobile:coach_create_event"))
self.assertNotContains(response, reverse("mobile:coach_create_news"))
self.assertNotContains(response, reverse("mobile:coach_add_player"))
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachSquadViewTests(TestCase):
"""Bottom-tab "Squad" -- roster + staff for the active team."""
@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="coach@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
def _get(self):
return self.client.get(reverse("mobile:coach_squad"), HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_lists_roster_and_staff(self):
player_position = Position.objects.create(club=self.club, name="Forward", short_name="FW", staff_position=False)
player = Member.objects.create(first_name="Anna", last_name="Player")
TeamMembership.objects.create(team=self.team, member=player, season=self.season, position=player_position, jersey_number=9)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Anna Player")
self.assertContains(response, "Forward")
self.assertContains(response, "Sam Coach")
self.assertContains(response, "Head coach")
def test_add_link_hidden_for_non_managing_staff(self):
physio_position = Position.objects.create(club=self.club, name="Physio", short_name="PHY", staff_position=True, management_position=False)
physio_user = User.objects.create_user(email="physio@example.com", password="pw-secret-123")
physio_member = Member.objects.create(first_name="Pat", last_name="Physio", user=physio_user)
StaffAssignment.objects.create(team=self.team, member=physio_member, season=self.season, position=physio_position)
self.client.force_login(physio_user)
response = self._get()
self.assertNotContains(response, reverse("mobile:coach_add_player"))
def test_no_staff_assignment_shows_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user)
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Not staffing a team yet")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachScheduleViewTests(TestCase):
"""Bottom-tab "Schedule" -- every upcoming event for the active team,
each row routed to the coach-relevant action for its kind."""
@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="coach@example.com", password="pw-secret-123")
cls.member = Member.objects.create(first_name="Sam", last_name="Coach", email="coach@example.com", user=cls.user)
cls.team = Team.objects.create(club=cls.club, name="U16", short_name="U16")
cls.position = Position.objects.create(club=cls.club, name="Head coach", short_name="HC", staff_position=True, management_position=True)
StaffAssignment.objects.create(team=cls.team, member=cls.member, season=cls.season, position=cls.position)
def _get(self):
return self.client.get(reverse("mobile:coach_schedule"), HTTP_HOST="ajax-united.rosterchief.app")
def test_requires_login(self):
response = self._get()
self.assertEqual(response.status_code, 302)
def test_game_row_links_to_lineup(self):
game = Event.objects.create(club=self.club, title="Big game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=2))
game.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:coach_lineup", kwargs={"event_id": game.pk}))
def test_training_row_links_to_attendance(self):
practice = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=2))
practice.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, reverse("mobile:coach_attendance", kwargs={"event_id": practice.pk}))
def test_past_and_cancelled_events_are_excluded(self):
past = Event.objects.create(club=self.club, title="Old practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(days=2))
past.teams.add(self.team)
cancelled = Event.objects.create(club=self.club, title="Cancelled game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=2), cancelled=True)
cancelled.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["events"], [])
def test_no_staff_assignment_shows_a_graceful_empty_state(self):
bare_user = User.objects.create_user(email="new@example.com", password="pw-secret-123")
self.client.force_login(bare_user)
response = self._get()
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Not staffing a team yet")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"]) @override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class CoachAttendanceViewTests(TestCase): class CoachAttendanceViewTests(TestCase):

View File

@@ -24,6 +24,8 @@ urlpatterns = [
path("notifications/", views.NotificationsView.as_view(), name="notifications"), path("notifications/", views.NotificationsView.as_view(), name="notifications"),
# Coach mode (C1-C6). # Coach mode (C1-C6).
path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"), path("coach/", coach_views.CoachTodayView.as_view(), name="coach_today"),
path("coach/squad/", coach_views.CoachSquadView.as_view(), name="coach_squad"),
path("coach/schedule/", coach_views.CoachScheduleView.as_view(), name="coach_schedule"),
path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"), path("coach/attendance/<uuid:event_id>/", coach_views.CoachAttendanceView.as_view(), name="coach_attendance"),
path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"), path("coach/events/new/", coach_views.CoachCreateEventView.as_view(), name="coach_create_event"),
path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"), path("coach/news/new/", coach_views.CoachCreateNewsView.as_view(), name="coach_create_news"),

View File

@@ -2696,6 +2696,9 @@
.inset-0 { .inset-0 {
inset: 0; inset: 0;
} }
.inset-x-3 {
inset-inline: calc(var(--spacing) * 3);
}
.dropdown-right { .dropdown-right {
@layer daisyui.l1.l2 { @layer daisyui.l1.l2 {
--anchor-h: right; --anchor-h: right;
@@ -4618,6 +4621,12 @@
background-color: color-mix(in oklab, var(--color-white) 15%, transparent); background-color: color-mix(in oklab, var(--color-white) 15%, transparent);
} }
} }
.bg-white\/40 {
background-color: color-mix(in srgb, #fff 40%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-white) 40%, transparent);
}
}
.btn-link { .btn-link {
@layer daisyui { @layer daisyui {
text-decoration-line: underline; text-decoration-line: underline;

File diff suppressed because one or more lines are too long