Build M1 Home for the mobile app

Hero card for the soonest upcoming event with a quick In/Out RSVP,
a "needs your answer" list, a season-dues card, and a news teaser --
all scoped to the person switcher's current selection. The RSVP quick
action posts to the (still placeholder-GET) event-detail URL that a
later screen (M2) will build out fully.

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:39:18 +02:00
parent 09df5d25b8
commit 18627d2843
5 changed files with 427 additions and 3 deletions

View File

@@ -0,0 +1,122 @@
{% extends "mobile/base.html" %}
{% load i18n %}
{% comment %}
M1 -- design_handoff_rosterchief_platform/README.md's M1 section. Four
independently-optional cards, all scoped to scope_person (mobile/mixins.py):
a hero for the soonest upcoming event with a quick In/Out RSVP, a "needs
your answer" list, a season-dues card, and a news teaser.
{% endcomment %}
{% block content %}
{% if scope_person %}
{% if hero_attendance %}
<div class="m-card-dark overflow-hidden">
<div class="p-4 pb-0">
<p class="font-display text-xs font-extrabold text-info uppercase tracking-wide">{% blocktrans with date=hero_attendance.event.start|date:"D d M" %}Next up &middot; {{ date }}{% endblocktrans %}</p>
<p class="mt-1 font-display text-2xl leading-none font-extrabold uppercase">{{ hero_attendance.event.title }}</p>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-4 font-mono text-xs text-on-dark">
<span>{% blocktrans with time=hero_attendance.event.start|date:"H:i" %}{{ time }} face-off{% endblocktrans %}</span>
{% if hero_attendance.event.gathering %}
<span>{% blocktrans with time=hero_attendance.event.gathering|date:"H:i" %}Meet {{ time }}{% endblocktrans %}</span>
{% endif %}
{% if hero_attendance.event.location %}<span>{{ hero_attendance.event.location.name }}</span>{% endif %}
</div>
{% if rsvp_closed %}
<p class="mt-3 font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">{% trans "Replies are closed for this event" %}</p>
<span class="pill pill-neutral mt-2">{{ hero_attendance.get_status_display }}</span>
{% else %}
<p class="mt-3 font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">{% blocktrans with name=scope_person.first_name %}{{ name }} &mdash; are you in?{% endblocktrans %}</p>
<div class="mt-2 flex gap-2">
<form class="flex-1" method="post" action="{% url "mobile:event_detail" hero_attendance.event.pk %}">
{% csrf_token %}
<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="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 needs_answer %}
<div class="m-card p-4">
<div class="flex items-center justify-between">
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Needs your answer" %}</span>
<span class="font-display text-lg font-extrabold text-club">{{ needs_answer|length }}</span>
</div>
<div class="mt-3 flex flex-col gap-2.5">
{% for attendance in needs_answer %}
{% if not forloop.first %}<div class="h-px bg-rule"></div>{% endif %}
<a class="flex items-center gap-3" href="{% url "mobile:event_detail" attendance.event.pk %}">
<div class="w-9.5 shrink-0 text-center">
<div class="font-display text-xl leading-none font-extrabold text-ink">{{ attendance.event.start|date:"d" }}</div>
<div class="font-mono text-[10px] text-muted uppercase tracking-wide">{{ attendance.event.start|date:"M" }}</div>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{{ attendance.event.title }}</div>
<div class="text-xs text-muted">{{ scope_person.first_name }} &middot; {{ attendance.event.start|date:"H:i" }}</div>
</div>
<span class="pill pill-warn shrink-0">{% trans "Reply" %}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% if dues_membership %}
<div class="m-card flex items-center gap-3 p-4">
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-danger-bg">
<span class="font-display text-lg font-extrabold text-club">&euro;</span>
</div>
<div class="min-w-0 flex-1">
<div class="text-sm font-semibold text-ink">{% blocktrans with name=scope_person.first_name %}Season dues &mdash; {{ name }}{% endblocktrans %}</div>
<div class="text-xs text-muted">
{% if dues_invoice %}
{% blocktrans with amount=dues_balance|floatformat:2 due=dues_invoice.due_date|date:"d M" %}&euro; {{ amount }} &middot; due {{ due }}{% endblocktrans %}
{% else %}
{% blocktrans with amount=dues_balance|floatformat:2 %}&euro; {{ amount }}{% endblocktrans %}
{% endif %}
</div>
</div>
<span class="btn btn-dark shrink-0">{% trans "Pay" %}</span>
</div>
{% endif %}
{% if news_item %}
<div>
<div class="mb-2.5 flex items-baseline justify-between">
<span class="font-display text-xs font-extrabold text-muted uppercase tracking-wide">{% trans "Club news" %}</span>
</div>
<a class="m-card block overflow-hidden" href="{% url "mobile:news_detail" news_item.slug %}">
<div class="flex h-[104px] items-center justify-center bg-line text-xs text-dim">{% trans "News photo" %}</div>
<div class="p-3.5">
<div class="font-display text-[11px] font-extrabold text-club uppercase tracking-wide">
{% if news_team %}{{ news_team.name }}{% else %}{% trans "Club news" %}{% endif %} &middot; {{ news_item.published_at|date:"d M" }}
</div>
<div class="mt-1 font-display text-xl leading-tight font-extrabold text-ink uppercase">{{ news_item.title }}</div>
</div>
</a>
</div>
{% endif %}
{% if not hero_attendance and not needs_answer and not dues_membership and not news_item %}
<div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing to show right now." %}</p>
</div>
{% endif %}
{% else %}
<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, their schedule and updates will show up here." %}</p>
</div>
{% endif %}
{% endblock content %}

View File

@@ -1,11 +1,15 @@
import datetime import datetime
from decimal import Decimal
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from club.models import Club, ClubMembership, Season from club.models import Club, ClubMembership, DuesInvoice, Season
from events.models import Attendance, Event
from members.models import Family, FamilyMembership, Member from members.models import Family, FamilyMembership, Member
from news.models import News
from .models import PushSubscription from .models import PushSubscription
from .services.icons import render_fallback_icon from .services.icons import render_fallback_icon
@@ -147,3 +151,173 @@ class RenderFallbackIconTests(TestCase):
png_bytes = render_fallback_icon(club, size=64) png_bytes = render_fallback_icon(club, size=64)
self.assertTrue(png_bytes.startswith(b"\x89PNG")) self.assertTrue(png_bytes.startswith(b"\x89PNG"))
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class HomeViewTests(TestCase):
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section."""
@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.future = timezone.now() + datetime.timedelta(days=7)
def _get(self, url_name=None, url=None):
url = url or reverse(f"mobile:{url_name}")
return self.client.get(url, HTTP_HOST="ajax-united.rosterchief.app")
def make_event(self, **kwargs):
kwargs.setdefault("club", self.club)
kwargs.setdefault("title", "Training")
kwargs.setdefault("start", self.future)
return Event.objects.create(**kwargs)
def test_hero_shows_the_soonest_upcoming_event(self):
soon = self.make_event(title="Away · Herentals", start=self.future)
later = self.make_event(title="Practice · Ice 3", start=self.future + datetime.timedelta(days=5))
Attendance.objects.create(event=soon, member=self.member)
Attendance.objects.create(event=later, member=self.member)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.context["hero_attendance"].event, soon)
self.assertContains(response, "Away")
def test_hero_is_absent_when_no_upcoming_event(self):
self.client.force_login(self.user)
response = self._get("home")
self.assertIsNone(response.context["hero_attendance"])
def test_needs_your_answer_only_lists_no_response_and_maybe(self):
answered = self.make_event(title="Already answered", start=self.future)
awaiting = self.make_event(title="Awaiting reply", start=self.future + datetime.timedelta(days=2))
maybe = self.make_event(title="Maybe reply", start=self.future + datetime.timedelta(days=4))
Attendance.objects.create(event=answered, member=self.member, status=Attendance.AttendanceStatus.PRESENT)
Attendance.objects.create(event=awaiting, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
Attendance.objects.create(event=maybe, member=self.member, status=Attendance.AttendanceStatus.MAYBE)
self.client.force_login(self.user)
response = self._get("home")
needs_answer_events = {attendance.event for attendance in response.context["needs_answer"]}
self.assertEqual(needs_answer_events, {awaiting, maybe})
def test_needs_your_answer_excludes_the_hero_event_even_if_unanswered(self):
soon = self.make_event(title="Soonest", start=self.future)
Attendance.objects.create(event=soon, member=self.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.context["hero_attendance"].event, soon)
self.assertEqual(list(response.context["needs_answer"]), [])
def test_dues_card_shows_the_outstanding_balance(self):
membership = ClubMembership.objects.get(club=self.club, member=self.member, season=self.season)
membership.fee_amount = Decimal("420.00")
membership.save(update_fields=["fee_amount"])
DuesInvoice.objects.create(club=self.club, membership=membership, amount=Decimal("420.00"), due_date=timezone.localdate() + datetime.timedelta(days=10))
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.context["dues_balance"], Decimal("420.00"))
self.assertContains(response, "420")
def test_dues_card_is_absent_when_nothing_is_owed(self):
self.client.force_login(self.user)
response = self._get("home")
self.assertIsNone(response.context["dues_membership"])
def test_news_teaser_shows_the_latest_published_item(self):
News.objects.create(club=self.club, title="Old news", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=5))
latest = News.objects.create(club=self.club, title="Signed: New Player", body="Body.", status=News.Status.PUBLISHED, published_at=timezone.now() - datetime.timedelta(days=1))
News.objects.create(club=self.club, title="Still a draft", body="Body.", status=News.Status.DRAFT)
self.client.force_login(self.user)
response = self._get("home")
self.assertEqual(response.context["news_item"], latest)
self.assertContains(response, "Signed: New Player")
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("home")
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.context["scope_person"])
self.assertContains(response, "No one to show yet")
@override_settings(ROSTERCHIEF_BASE_DOMAIN="rosterchief.app", ALLOWED_HOSTS=["rosterchief.app", "ajax-united.rosterchief.app", "testserver"])
class EventDetailRsvpTests(TestCase):
"""M1's quick In/Out RSVP action, posted from the Home hero card."""
@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.event = Event.objects.create(club=cls.club, title="Training", start=timezone.now() + datetime.timedelta(days=7))
cls.attendance = Attendance.objects.create(event=cls.event, member=cls.member, status=Attendance.AttendanceStatus.NO_RESPONSE)
def _post(self, event, data):
return self.client.post(reverse("mobile:event_detail", kwargs={"pk": event.pk}), data=data, HTTP_HOST="ajax-united.rosterchief.app")
def test_posting_present_updates_attendance_and_redirects_home(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "present"})
self.assertRedirects(response, reverse("mobile:home"), fetch_redirect_response=False)
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.PRESENT)
def test_posting_absent_creates_attendance_when_none_existed(self):
other_event = Event.objects.create(club=self.club, title="Away game", start=timezone.now() + datetime.timedelta(days=8))
self.client.force_login(self.user)
self._post(other_event, {"status": "absent"})
self.assertEqual(Attendance.objects.get(event=other_event, member=self.member).status, Attendance.AttendanceStatus.ABSENT)
def test_cannot_rsvp_for_a_member_who_isnt_managed(self):
stranger = Member.objects.create(first_name="Someone", last_name="Else")
self.client.force_login(self.user)
response = self._post(self.event, {"status": "present", "member_id": str(stranger.pk)})
self.assertEqual(response.status_code, 400)
self.attendance.refresh_from_db()
self.assertEqual(self.attendance.status, Attendance.AttendanceStatus.NO_RESPONSE)
def test_rejects_an_unknown_status_value(self):
self.client.force_login(self.user)
response = self._post(self.event, {"status": "maybe"})
self.assertEqual(response.status_code, 400)
def test_cannot_rsvp_for_an_event_from_another_club(self):
other_club = Club.objects.create(name="Other Club", slug="other-club", secondary_color="#e4002b")
other_event = Event.objects.create(club=other_club, title="Not ours", start=timezone.now() + datetime.timedelta(days=3))
self.client.force_login(self.user)
response = self._post(other_event, {"status": "present"})
self.assertEqual(response.status_code, 404)

View File

@@ -12,15 +12,24 @@ before every screen is built out one at a time.
import json import json
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views import View from django.views import View
from django.views.generic import TemplateView from django.views.generic import TemplateView
from club.models import ClubMembership
from club.services.access import current_season
from club.services.fees import remaining_balance
from events.models import Attendance, Event
from members.models import Member from members.models import Member
from members.views import ClubScopedPublicMixin from members.views import ClubScopedPublicMixin
from news.models import News
from teams.models import TeamMembership
from .mixins import PersonScopeMixin from .mixins import PersonScopeMixin
from .models import PushSubscription from .models import PushSubscription
@@ -121,10 +130,88 @@ class _PlaceholderScreen(PersonScopeMixin, LoginRequiredMixin, TemplateView):
return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs) return super().get_context_data(screen_title=self.screen_title, active_tab=self.active_tab, **kwargs)
class HomeView(_PlaceholderScreen): class HomeView(PersonScopeMixin, LoginRequiredMixin, TemplateView):
"""M1 -- design_handoff_rosterchief_platform/README.md's M1 section: a
hero card for scope_person's soonest upcoming event (with a quick In/Out
RSVP -- see EventDetailView.post below), a "needs your answer" list of
upcoming events still NO_RESPONSE/MAYBE, a season-dues card when money is
owed, and a news teaser. Every card is independently optional -- an
empty-state screen is just the four `if`s below all being falsy.
"""
template_name = "mobile/home.html"
screen_title = _("Home") screen_title = _("Home")
active_tab = "home" active_tab = "home"
def get_context_data(self, **kwargs):
scope_person = self.scope_person
now = timezone.now()
hero_attendance = None
rsvp_closed = False
needs_answer = []
dues_membership = None
dues_balance = None
dues_invoice = None
news_item = None
if scope_person is not None:
upcoming = Attendance.objects.filter(
member=scope_person,
event__club=self.request.club,
event__cancelled=False,
event__start__gte=now,
).select_related("event", "event__location")
hero_attendance = upcoming.order_by("event__start").first()
if hero_attendance is not None:
deadline = hero_attendance.event.deadline
rsvp_closed = deadline is not None and deadline < now
needs_answer_qs = upcoming.filter(status__in=[Attendance.AttendanceStatus.NO_RESPONSE, Attendance.AttendanceStatus.MAYBE]).order_by("event__start")
if hero_attendance is not None:
needs_answer_qs = needs_answer_qs.exclude(pk=hero_attendance.pk)
needs_answer = list(needs_answer_qs)
season = current_season(self.request.club)
if season is not None:
membership = (
ClubMembership.objects.filter(club=self.request.club, member=scope_person, season=season)
.exclude(fee_status=ClubMembership.FeeStatus.WAIVED)
.select_related("dues_invoice")
.first()
)
if membership is not None:
balance = remaining_balance(membership)
if balance > 0:
dues_membership = membership
dues_balance = balance
dues_invoice = getattr(membership, "dues_invoice", None)
team_ids = list(TeamMembership.objects.filter(member=scope_person, season=season).values_list("team_id", flat=True))
else:
team_ids = []
news_item = (
News.objects.filter(club=self.request.club, status=News.Status.PUBLISHED, published_at__lte=now)
.filter(Q(teams__isnull=True) | Q(teams__id__in=team_ids))
.order_by("-published_at")
.distinct()
.first()
)
return super().get_context_data(
hero_attendance=hero_attendance,
rsvp_closed=rsvp_closed,
needs_answer=needs_answer,
dues_membership=dues_membership,
dues_balance=dues_balance,
dues_invoice=dues_invoice,
news_item=news_item,
news_team=news_item.teams.first() if news_item is not None else None,
**kwargs,
)
class CalendarView(_PlaceholderScreen): class CalendarView(_PlaceholderScreen):
screen_title = _("Calendar") screen_title = _("Calendar")
@@ -132,9 +219,27 @@ class CalendarView(_PlaceholderScreen):
class EventDetailView(_PlaceholderScreen): class EventDetailView(_PlaceholderScreen):
"""GET is still the M2 placeholder (a later screen owns the full detail
page); POST is M1's quick In/Out RSVP action, reused by any future screen
that posts the same {status, member_id} shape at this URL."""
screen_title = _("Event") screen_title = _("Event")
active_tab = "calendar" active_tab = "calendar"
def post(self, request, *args, **kwargs):
status = request.POST.get("status")
if status not in (Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.ABSENT):
return HttpResponseBadRequest(_("Unknown RSVP status."))
member_id = request.POST.get("member_id") or (str(self.scope_person.pk) if self.scope_person else None)
member = next((person for person in self.managed_people if str(person.pk) == member_id), None) if member_id else None
if member is None:
return HttpResponseBadRequest(_("You can't RSVP for that person."))
event = get_object_or_404(Event, pk=kwargs["pk"], club=request.club)
Attendance.objects.update_or_create(event=event, member=member, defaults={"status": status})
return HttpResponseRedirect(reverse("mobile:home"))
class NewsDetailView(_PlaceholderScreen): class NewsDetailView(_PlaceholderScreen):
screen_title = _("News") screen_title = _("News")

View File

@@ -41,6 +41,7 @@
--font-weight-extrabold: 800; --font-weight-extrabold: 800;
--tracking-wide: 0.025em; --tracking-wide: 0.025em;
--tracking-wider: 0.05em; --tracking-wider: 0.05em;
--leading-tight: 1.25;
--leading-relaxed: 1.625; --leading-relaxed: 1.625;
--radius-sm: 0.25rem; --radius-sm: 0.25rem;
--radius-md: 0.375rem; --radius-md: 0.375rem;
@@ -2774,6 +2775,9 @@
.right-0 { .right-0 {
right: 0; right: 0;
} }
.right-1 {
right: var(--spacing);
}
.right-1\.5 { .right-1\.5 {
right: calc(var(--spacing) * 1.5); right: calc(var(--spacing) * 1.5);
} }
@@ -3928,6 +3932,9 @@
.h-\[68px\] { .h-\[68px\] {
height: 68px; height: 68px;
} }
.h-\[104px\] {
height: 104px;
}
.h-\[180px\] { .h-\[180px\] {
height: 180px; height: 180px;
} }
@@ -3988,6 +3995,12 @@
.w-8 { .w-8 {
width: calc(var(--spacing) * 8); width: calc(var(--spacing) * 8);
} }
.w-9 {
width: calc(var(--spacing) * 9);
}
.w-9\.5 {
width: calc(var(--spacing) * 9.5);
}
.w-10 { .w-10 {
width: calc(var(--spacing) * 10); width: calc(var(--spacing) * 10);
} }
@@ -4234,6 +4247,9 @@
.flex-wrap { .flex-wrap {
flex-wrap: wrap; flex-wrap: wrap;
} }
.items-baseline {
align-items: baseline;
}
.items-center { .items-center {
align-items: center; align-items: center;
} }
@@ -4750,6 +4766,9 @@
.pt-4 { .pt-4 {
padding-top: calc(var(--spacing) * 4); padding-top: calc(var(--spacing) * 4);
} }
.pb-0 {
padding-bottom: 0;
}
.pb-0\.5 { .pb-0\.5 {
padding-bottom: calc(var(--spacing) * 0.5); padding-bottom: calc(var(--spacing) * 0.5);
} }
@@ -4914,6 +4933,10 @@
--tw-leading: var(--leading-relaxed); --tw-leading: var(--leading-relaxed);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
} }
.leading-tight {
--tw-leading: var(--leading-tight);
line-height: var(--leading-tight);
}
.font-bold { .font-bold {
--tw-font-weight: var(--font-weight-bold); --tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);

File diff suppressed because one or more lines are too long