Scaffold the mobile member app: PWA shell, push, and app-shell tokens

New `mobile` Django app mounted at /app/ -- the installed PWA for Member
mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach
mode (C1-C6) is a later phase and has no routes yet.

Foundation pieces:
- assets/mobile.css: Tailwind v4 theme reusing management.css's design
  tokens (same per-club --tenant-* theming pattern), plus the ice/coach
  accent and mobile's 14px card radius.
- PushSubscription model + pywebpush-based sender (mobile/services/push.py),
  wired to notifications.Notification via a post_save signal so the
  existing notification system gains a push channel without knowing about
  PWAs itself.
- Per-club manifest.webmanifest + service worker (served at /app/sw.js)
  + a server-rendered fallback home-screen icon (club initials on
  secondary_color) for clubs without an uploaded logo -- confirmed with
  the user as the fallback, never a generic RosterChief mark.
- App shell (base.html): navy header, person switcher (every child a
  signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets.
  Vendored htmx + Alpine for the screens built on top of it.
- Placeholder views/routes for all seven M1-M7 screens so the shell is
  fully wired end-to-end before each screen is built out individually.

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 10:27:13 +02:00
parent 0ecdeac354
commit 09df5d25b8
29 changed files with 1657 additions and 6 deletions

View File

36
mobile/services/icons.py Normal file
View File

@@ -0,0 +1,36 @@
"""Server-rendered fallback PWA icon: a club's initials on its own
secondary_color -- confirmed with the user as the fallback for a club that
hasn't uploaded a logo, rather than a shared RosterChief mark (Club.initials'
own docstring: "Never the RosterChief mark -- that would pass our branding
off as the club's own", same reasoning applied to the home-screen icon).
Rendered on request by mobile.views.AppIconView, not stored -- a club's
colours/initials change rarely enough that regenerating a small PNG per
request is cheaper than adding cache invalidation for it.
"""
import io
from PIL import Image, ImageDraw, ImageFont
_DEFAULT_BACKGROUND = "#e4002b"
_DEFAULT_FOREGROUND = "#ffffff"
def render_fallback_icon(club, size: int = 512) -> bytes:
background = club.secondary_color or _DEFAULT_BACKGROUND
foreground = club.secondary_content_color or _DEFAULT_FOREGROUND
image = Image.new("RGB", (size, size), background)
draw = ImageDraw.Draw(image)
initials = club.initials or "RC"
font = ImageFont.load_default(size=int(size * 0.42))
left, top, right, bottom = draw.textbbox((0, 0), initials, font=font)
text_width, text_height = right - left, bottom - top
draw.text(((size - text_width) / 2 - left, (size - text_height) / 2 - top), initials, font=font, fill=foreground)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()

48
mobile/services/push.py Normal file
View File

@@ -0,0 +1,48 @@
"""Web Push delivery -- the push channel for notifications.Notification.
Wired in from mobile.signals (a post_save on Notification), deliberately kept
out of the notifications app itself, which stays channel-agnostic (see that
app's models.py docstring: "the whole point is reusing this for other kinds
of activity later"). A backward dependency the other way -- notifications
importing mobile -- would be the wrong direction: notifications has no
reason to know a PWA exists.
"""
import json
import logging
from django.conf import settings
from pywebpush import WebPushException, webpush
from .. import models
logger = logging.getLogger(__name__)
def send_push_to_member(member, *, title: str, body: str, url: str = "/app/") -> None:
if not settings.VAPID_PRIVATE_KEY:
# Dev/no-config default -- see the settings.py comment next to VAPID_PRIVATE_KEY.
return
payload = json.dumps({"title": title, "body": body, "url": url})
for subscription in models.PushSubscription.objects.filter(member=member):
try:
webpush(
subscription_info=subscription.as_subscription_info(),
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims={"sub": f"mailto:{settings.VAPID_ADMIN_EMAIL}"},
)
except WebPushException as exc:
status = exc.response.status_code if exc.response is not None else None
if status in (404, 410):
# The browser's push service says this registration is gone for good --
# not a transient failure, so keeping it around would only mean retrying
# a subscription that will never accept a push again.
subscription.delete()
else:
logger.warning("Push send failed for %s: %s", member, exc)
except OSError as exc:
# Never fatal -- same reasoning as every other branded send in this app
# (see e.g. notifications.services._send_email).
logger.warning("Push send failed for %s: %s", member, exc)