Enable hx-boost app-wide for SPA-like navigation; Me page polish

hx-boost="true" on both shells' <body> turns every same-shell link/form
into an AJAX navigation (swapping <body>'s children, pushing the URL)
instead of a full browser reload -- htmx.js was already loaded on every
page but had zero actual usage anywhere in the codebase until now.

Only <body>'s children swap under a boost, never the tag itself, so
anything that crosses between the two differently-styled shells (Member's
bg-paper vs Coach's bg-ink, different data attributes) is marked
hx-boost="false" to force a real navigation instead: both role-switcher
links, the Me page's "Teams I coach/manage" row (leads into Coach mode),
and Coach Today's "Also yours" quick-RSVP forms (post to the Member-shell
event_detail view). The calendar-sync "Add to calendar" webcal:// link is
also excluded -- it's meant to hand off to the OS calendar app, not be
treated as in-app navigation.

Also, on the Me page:
- Renamed the mode switcher and the "Teams I coach" card to "Manager"/
  "Teams I coach/manage" -- a team manager assigned there isn't
  necessarily a coach.
- Fixed the team badge, which was slicing short_name to 2 characters
  ("U16" showing as "U1") -- replaced with a plain icon instead of text,
  so there's nothing left to truncate (the full team name is still the
  row's own label).
- Styled the whole card dark (m-card-dark), matching the design mock's
  own dark "Coach mode" promo card treatment for this part of M5.

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:59:47 +02:00
parent c0c7ec9f1e
commit 1ced0619a3
9 changed files with 60 additions and 28 deletions

View File

@@ -12,6 +12,17 @@
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.
hx-boost="true" on <body>: every same-shell link/form becomes an AJAX
navigation (swapping <body>'s children, pushing the URL) instead of a
full browser reload -- the SPA-like feel without an SPA. Only <body>'s
*children* swap, not the tag itself, so anything that crosses into the
differently-styled Coach shell (coach/base.html: bg-ink vs this page's
bg-paper, its own hx-headers/data attrs) is marked hx-boost="false" to
force a real navigation instead -- a boosted cross-shell swap would leave
the OLD shell's <body> class/attributes in place under the NEW shell's
content. See coach/base.html's own comment for the same rule mirrored
the other direction.
{% endcomment %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
@@ -51,7 +62,7 @@
{% block extra_head %}{% endblock extra_head %}
</head>
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-vapid-public-key="{{ vapid_public_key }}" data-csrftoken="{{ csrf_token }}">
<body class="flex h-screen flex-col overflow-hidden bg-paper font-sans text-slate" hx-boost="true" hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}' data-vapid-public-key="{{ vapid_public_key }}" data-csrftoken="{{ csrf_token }}">
<header class="app-header">
<div class="flex items-center gap-2.5">
<a class="flex items-center gap-2.5" href="{% url "mobile:home" %}">
@@ -78,7 +89,7 @@
{% 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>
<a class="role-switcher-item" href="{% url "mobile:coach_today" %}" hx-boost="false">{% trans "Manager" %}</a>
</div>
{% endif %}
{% block header_extra %}{% endblock header_extra %}

View File

@@ -20,7 +20,7 @@
<p class="text-sm text-muted">{% trans "Subscribe this link in your phone or computer's calendar app to see everyone you manage's schedule alongside your own. It includes every event they're invited to, not just the ones they've replied to." %}</p>
<div class="m-card p-4">
<a class="btn btn-primary flex w-full items-center justify-center" href="{{ webcal_url }}">{% trans "Add to calendar" %}</a>
<a class="btn btn-primary flex w-full items-center justify-center" href="{{ webcal_url }}" hx-boost="false">{% trans "Add to calendar" %}</a>
<p class="mt-2 text-center text-xs text-dim">{% trans "Opens directly in Apple/macOS Calendar. On Android or a desktop calendar app, copy the link below and add it as a new calendar by URL." %}</p>
<div class="mt-4" x-data="{ copied: false }">

View File

@@ -13,13 +13,16 @@
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).
The tab bar deliberately stays minimal (Today + Me) even with all six coach screens
built -- C2/C3/C4/C5/C6 are reached from Today's own action buttons and "needs you"
list, not separate tab items; Me reuses the *existing* member mobile:me page rather
than a separate coach-Me screen (see CoachTodayView's own docstring).
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
comment for the full reasoning. Only <body>'s children swap, not the tag itself, so
anything that crosses back into the Member shell (bg-paper, its own data attrs) is
marked hx-boost="false" to force a real navigation instead.
{% endcomment %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE|default:"en" }}">
@@ -54,7 +57,7 @@
{% 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 }}">
<body class="flex h-screen flex-col overflow-hidden bg-ink font-sans text-slate" hx-boost="true" 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 %}
@@ -79,8 +82,8 @@
{% 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>
<a class="role-switcher-item" href="{% url "mobile:home" %}" hx-boost="false">{% trans "Member" %}</a>
<a class="role-switcher-item role-switcher-item-active" href="{% url "mobile:coach_today" %}">{% trans "Manager" %}</a>
</div>
{% block header_extra %}{% endblock header_extra %}

View File

@@ -86,14 +86,21 @@
{% if rsvp_closed %}
<span class="pill pill-neutral mt-3">{{ hero_attendance.get_status_display }}</span>
{% else %}
{% comment %}
hx-boost="false" -- mobile:event_detail is a Member-shell view
(bg-paper, not this page's bg-ink); boosting would swap its
response into this page's <body> without updating the <body>
tag's own class, leaving the wrong background. See base.html's
own comment for the full reasoning.
{% endcomment %}
<div class="mt-3 flex gap-2">
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" hx-boost="false">
{% 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 %}">
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}" hx-boost="false">
{% csrf_token %}
<input type="hidden" name="member_id" value="{{ hero_attendance.member.pk }}">
<input type="hidden" name="status" value="absent">

View File

@@ -9,7 +9,7 @@
screen to lead to and is omitted, same for the mockup's "Coach mode"
promo. "Payments & dues" does lead somewhere (mobile:payments) and
carries its "N OPEN" pill only once open_dues_count is actually > 0.
"Teams I coach" is new, beyond the mockup -- one row per current-season
"Teams I coach/manage" is new, beyond the mockup -- one row per current-season
staff assignment, linking straight into Coach mode for that team.
The avatar/name/subtitle row lives in header_extra -- merged into the
shared navy app-header (base.html) rather than a separately-coloured
@@ -68,17 +68,19 @@
{% if staff_assignments %}
<div>
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Teams I coach" %}</div>
<div class="m-card overflow-hidden">
<div class="mb-2 font-display text-xs font-extrabold tracking-wide text-muted uppercase">{% trans "Teams I coach/manage" %}</div>
<div class="m-card-dark overflow-hidden">
{% for assignment in staff_assignments %}
{% 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:coach_today" %}?team={{ assignment.team.pk }}">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-ink font-display text-xs font-extrabold text-ice">{{ assignment.team.short_name|slice:":2" }}</span>
{% if not forloop.first %}<div class="h-px bg-white/10"></div>{% endif %}
<a class="flex items-center gap-3 p-3.5" href="{% url "mobile:coach_today" %}?team={{ assignment.team.pk }}" hx-boost="false">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-steel text-ice">
<svg width="20" height="20" 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>
<div class="min-w-0 flex-1">
<div class="text-[15px] font-semibold text-ink">{{ assignment.team.name }}</div>
<div class="text-xs text-muted">{{ assignment.position }}</div>
<div class="text-[15px] font-semibold text-white">{{ assignment.team.name }}</div>
<div class="text-xs text-on-dark-dim">{{ assignment.position }}</div>
</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>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" class="shrink-0 text-on-dark-dim" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5l7 7-7 7"/></svg>
</a>
{% endfor %}
</div>