Add Coach mode: dark-chrome shell, mode switcher, C1 Today, C2 Bench attendance

First slice of the coach-mode build (design_handoff_rosterchief_platform/
README.md's "Coach mode" section, C1-C6). Ships the foundation together with
the two screens the design doc calls out as the reason coaches install
anything at all, rather than landing a Today screen with a dead "Check
attendance" button:

- mobile/coach_mixins.py's CoachScopeMixin -- the dark-mode mirror of
  PersonScopeMixin, scoped by active team (via club.services.access'
  teams_staffed_by/teams_managed_by) instead of managed people.
- A standalone dark ink/ice shell (mobile/templates/mobile/coach/base.html)
  with the mode's signature 20px-radius overlapping sheet, reusing the
  --color-ice/--color-ink tokens that already existed in assets/mobile.css
  but were unconsumed until now.
- The Coach/Member role switcher is re-added to the member shell, gated on
  a real staff assignment (has_coach_access), replacing the "deliberately
  not rendered yet" placeholder.
- C1 Today: stat tiles, a tonight's-session card, a silent-players "needs
  you" row, and an "Also yours" card reusing HomeView's own hero-RSVP
  pattern scoped to the coach's own member record.
- C2 Bench attendance: writes through events.services.attendance.
  record_check_in, which existed for exactly this and had no caller yet.
  Read-only for staff on a team without a management position.

Line-up (C3), create event (C4), post news (C5), and add-to-roster (C6)
follow in later stages -- see the coach-mode plan.

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 22:21:25 +02:00
parent 9fbcc89646
commit 6a9a6204ed
12 changed files with 873 additions and 6 deletions

View File

@@ -8,9 +8,10 @@
file banner) rather than the iOS bezel in the design canvas, which is
presentation-only.
The Coach/Member role switcher described in the design doc is deliberately
NOT rendered yet: it would switch into a mode with no screens built (Coach
mode is an explicitly separate, later phase). Re-add it here once C1-C6 ship.
The Coach/Member role switcher renders once has_coach_access is true (an
account with >=1 current-season staff assignment -- mobile/mixins.py's
PersonScopeMixin) -- Coach mode (C1-C6, mobile/coach_views.py) is being
built out screen by screen; only what's actually shipped is linked to.
{% endcomment %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
@@ -74,6 +75,12 @@
{% endif %}
</a>
</div>
{% if has_coach_access %}
<div class="role-switcher">
<a class="role-switcher-item role-switcher-item-active" href="{% url "mobile:home" %}">{% trans "Member" %}</a>
<a class="role-switcher-item" href="{% url "mobile:coach_today" %}">{% trans "Coach" %}</a>
</div>
{% endif %}
{% block header_extra %}{% endblock header_extra %}
</header>

View File

@@ -0,0 +1,72 @@
{% extends "mobile/coach/base.html" %}
{% load i18n %}
{% comment %}
C2 -- design_handoff_rosterchief_platform/README.md's C2 section: check
off who actually showed up, a separate axis from their RSVP (see
CoachAttendanceView's own docstring). The mock's "fixed white footer"
Save button is a plain in-flow button instead -- every other coach/member
sub-page in this codebase keeps its action inline rather than adding a
second fixed bar above the shell's own tab bar.
{% endcomment %}
{% block header_extra %}
<div class="mt-3">
<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-mono text-sm text-on-dark">{{ checked_in_count }}/{{ total_count }}</span>
</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-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>
</div>
{% endblock header_extra %}
{% 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 filter_param %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?">
{% trans "All" %} {{ total_count }}
</a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "silent" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=silent">
{% trans "Silent" %} {{ silent_count }}
</a>
<a class="flex h-9 flex-1 items-center justify-center rounded-full font-display text-xs font-extrabold tracking-wide uppercase {% if filter_param == "goalies" %}bg-ink text-white{% else %}border border-line bg-white text-muted{% endif %}" href="?filter=goalies">
{% trans "Goalies" %}
</a>
</div>
<form method="post" action="{% url "mobile:coach_attendance" event.pk %}">
{% csrf_token %}
<div class="m-card flex flex-col overflow-hidden">
{% for row in rows %}
<div class="flex items-center gap-3 px-4 py-2.5 {% if not forloop.last %}border-b border-rule{% endif %} {% if row.is_silent %}bg-warn-bg{% endif %}" {% if can_manage_active_team %}x-data="{ state: '{{ row.showed_up|yesno:'true,false,' }}' }"{% endif %}>
<div class="w-8 shrink-0 text-center font-display text-lg font-extrabold text-ink tabular-nums">{{ row.membership.jersey_number|default:"—" }}</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ row.member.get_full_name }}</div>
<div class="text-xs text-muted">{{ row.membership.position|default:"—" }}</div>
</div>
{% if can_manage_active_team %}
<input type="hidden" name="showed_up_{{ row.pk }}" :value="state">
<div class="flex h-11 w-23 shrink-0 overflow-hidden rounded-lg">
<button type="button" class="flex-1" :class="state === 'true' ? 'bg-ok text-white' : 'bg-rule text-dim'" @click="state = 'true'" aria-label="{% trans "In" %}">
<svg class="mx-auto" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</button>
<button type="button" class="flex-1" :class="state === 'false' ? 'bg-club text-white' : 'bg-rule text-dim'" @click="state = 'false'" aria-label="{% trans "Out" %}">
<svg class="mx-auto" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
{% elif row.showed_up is not None %}
<span class="pill {% if row.showed_up %}pill-ok{% else %}pill-danger{% endif %} shrink-0">{% if row.showed_up %}{% trans "In" %}{% else %}{% trans "Out" %}{% endif %}</span>
{% endif %}
</div>
{% empty %}
<div class="px-4 py-6 text-center text-sm text-muted">{% trans "No one matches this filter." %}</div>
{% endfor %}
</div>
{% if can_manage_active_team %}
<button class="btn btn-dark mt-4 w-full" type="submit">{% trans "Save attendance" %}</button>
{% endif %}
</form>
{% endblock content %}

View File

@@ -0,0 +1,117 @@
{% load static i18n %}
{% comment %}
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/
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
of navy, a light .coach-sheet body that overlaps the header by 20px (the mode's own
signature, see assets/mobile.css's own comment), and a dark .coach-tab-bar instead of
the white one.
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
manifest, two modes, not two separate PWAs.
The tab bar only ever links to screens that actually exist. Today (C1) is the only
coach screen built so far, so it's the only coach item -- Me reuses the *existing*
member mobile:me page rather than a separate coach-Me screen (see CoachTodayView's
own docstring). No dead links: as C2/C4/C5/C6 land, they get their own tab/entry
point then, not stubbed in ahead of time (same principle mobile/templates/mobile/
me.html's own comment already applies to "Household & contacts"/"Coach mode" before
this existed).
{% endcomment %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0b1220">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}{{ screen_title }} &middot; {{ club.name }}{% endblock title %}</title>
<link rel="manifest" href="{% url "mobile:manifest" %}">
<link rel="apple-touch-icon" href="{% url "mobile:icon" size=192 %}">
<link rel="icon" href="{% url "mobile:icon" size=192 %}">
<link rel="stylesheet" href="{% static "css/mobile.css" %}">
{% if club.secondary_color %}
<style>
:root {
--tenant-club: {{ club.secondary_color }};
--tenant-club-dark: color-mix(in srgb, {{ club.secondary_color }} 80%, black);
--tenant-club-content: {{ club.secondary_content_color }};
}
</style>
{% endif %}
<script src="{% static "js/htmx.js" %}" defer></script>
<script src="{% static "js/alpine.js" %}" defer></script>
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="flex h-screen flex-col overflow-hidden bg-ink font-sans text-slate" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-csrftoken="{{ csrf_token }}">
<header class="coach-header">
<div class="flex items-center gap-2.5">
{% if club.logo %}
<img class="app-crest" src="{{ club.logo.url }}" alt="">
{% else %}
<span class="app-crest app-crest-fallback">{{ club.initials }}</span>
{% endif %}
<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>
{% if me %}<span class="block font-mono text-xs text-on-dark">{% trans "Head coach" %} &middot; {{ me.first_name }}</span>{% endif %}
</span>
</div>
{% if staffed_teams|length > 1 %}
<div class="mt-3 flex gap-2 overflow-x-auto scrollbar-hide">
{% 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 }}">
{{ team.short_name }}
</a>
{% endfor %}
</div>
{% endif %}
<div class="role-switcher">
<a class="role-switcher-item" href="{% url "mobile:home" %}">{% trans "Member" %}</a>
<a class="role-switcher-item role-switcher-item-active" href="{% url "mobile:coach_today" %}">{% trans "Coach" %}</a>
</div>
{% block header_extra %}{% endblock header_extra %}
</header>
<main class="coach-sheet">
<div class="flex flex-col gap-4 px-4 py-5">
{% if messages %}
<div class="flex flex-col gap-2">
{% for message in messages %}
<div class="m-card p-3 text-sm font-medium text-ink">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock content %}
</div>
</main>
<nav class="coach-tab-bar">
<a class="coach-tab-bar-item {% if active_tab == "coach_today" %}coach-tab-bar-item-active{% endif %}" href="{% url "mobile:coach_today" %}">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/></svg>
<span class="tab-bar-label">{% trans "Today" %}</span>
</a>
<a class="coach-tab-bar-item" href="{% url "mobile:me" %}">
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8"/></svg>
<span class="tab-bar-label">{% trans "Me" %}</span>
</a>
</nav>
<script src="{% static "js/mobile-app.js" %}" defer></script>
{% block extra_body %}{% endblock extra_body %}
</body>
</html>

View File

@@ -0,0 +1,99 @@
{% extends "mobile/coach/base.html" %}
{% load i18n %}
{% comment %}
C1 -- design_handoff_rosterchief_platform/README.md's C1 section: three
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
member-side RSVP. See CoachTodayView's own docstring for what's scoped
down from the mock and why.
{% endcomment %}
{% block content %}
{% if not staffed_teams %}
<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>
<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>
{% else %}
<div class="grid grid-cols-3 gap-2.5">
<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="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Squad" %}</div>
</div>
<div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ in_count }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "In" %}</div>
</div>
<div class="m-card p-3 text-center">
<div class="font-display text-2xl leading-none font-extrabold text-club">{{ silent_count }}</div>
<div class="mt-1 font-display text-[10px] font-extrabold tracking-wide text-muted uppercase">{% trans "Silent" %}</div>
</div>
</div>
{% if tonight_event %}
<div class="m-card-dark overflow-hidden">
<div class="flex items-center justify-between px-4 py-2.5">
<span class="font-display text-xs font-extrabold text-ice uppercase tracking-wide">{% blocktrans with time=tonight_event.start|date:"H:i" %}Tonight &middot; {{ time }}{% endblocktrans %}</span>
</div>
<div class="px-4 pb-4">
<p class="font-display text-xl leading-none font-extrabold uppercase">{{ tonight_event.title }}</p>
{% if can_manage_active_team %}
<a class="btn mt-3 w-full bg-ice text-ice-ink" href="{% url "mobile:coach_attendance" tonight_event.pk %}">{% trans "Check attendance" %}</a>
{% endif %}
</div>
</div>
{% endif %}
{% if needs_you %}
<div class="m-card overflow-hidden">
<div class="border-b border-line px-4 py-3 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Needs you" %}</div>
{% for item in needs_you %}
<div class="flex items-center gap-3 border-l-4 {% if item.severity == "club" %}border-club{% elif item.severity == "ice" %}border-ice{% else %}border-warn{% endif %} px-4 py-3">
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ item.title }}</div>
<div class="text-xs text-muted">{{ item.detail }}</div>
</div>
{% if item.severity == "warn" and session_event and can_manage_active_team %}
<a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{% url "mobile:coach_attendance" session_event.pk %}">{% trans "Review" %}</a>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if hero_attendance %}
<div>
<div class="mb-2.5 font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Also yours" %}</div>
<div class="m-card-dark p-4">
<p class="font-display text-xs font-extrabold text-info uppercase tracking-wide">{% blocktrans with date=hero_attendance.event.start|date:"D d M H:i" %}Your own next event &middot; {{ date }}{% endblocktrans %}</p>
<p class="mt-1 font-display text-xl leading-none font-extrabold uppercase">{{ hero_attendance.event.title }}</p>
{% if rsvp_closed %}
<span class="pill pill-neutral mt-3">{{ hero_attendance.get_status_display }}</span>
{% else %}
<div class="mt-3 flex gap-2">
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
<input type="hidden" name="status" value="present">
<button class="btn btn-positive w-full" type="submit">{% trans "In" %}</button>
</form>
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
<input type="hidden" name="status" value="absent">
<button class="btn w-full bg-steel text-on-dark" type="submit">{% trans "Out" %}</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if not tonight_event and not needs_you and not hero_attendance %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing needs your attention right now." %}</p>
</div>
{% endif %}
{% endif %}
{% endblock content %}