Build out coach mode's Today screen: always show the next event, not just tonight

The session card only rendered when something was scheduled today -- a
coach with nothing until Thursday saw an empty screen even though there was
a real next event to show. It now shows whenever there's any upcoming
session, labelled "Tonight" or "Next up · <date>" accordingly, and the KPI
tiles get a header naming which session they're counting.

"Needs you" also now checks every one of the team's next few upcoming games
for a missing line-up, not just whichever happens to be the very next
session -- a practice landing before Saturday's game no longer hides that
the game's own line-up still needs building. Each entry links straight to
its own game's line-up screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
This commit is contained in:
2026-08-22 20:46:13 +02:00
parent fc624ebd96
commit 3b634fda22
3 changed files with 115 additions and 25 deletions

View File

@@ -38,22 +38,32 @@ IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.
class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView): class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C1 -- three stat tiles (Squad/In/Silent) for the active team's next """C1 -- three stat tiles (Squad/In/Silent) for the active team's next
upcoming session, a "tonight's session" card when one is scheduled today, upcoming session, a session card for it (today's, if there's one on the
a "needs you" list, and an "Also yours" card surfacing the coach's own calendar today, otherwise whichever is soonest), a "needs you" list, and
member-side RSVP obligation (the same hero_attendance/rsvp_closed pattern an "Also yours" card surfacing the coach's own member-side RSVP
mobile.views.HomeView already computes, scoped to self.me only -- a obligation (the same hero_attendance/rsvp_closed pattern mobile.views.
coach's own obligations, not the whole roster's). HomeView already computes, scoped to self.me only -- a coach's own
obligations, not the whole roster's).
"Needs you" is scoped down from the design mock to what has real backing "Needs you" is scoped down from the design mock to what has real backing
data today: a silent-players count and (for a game) an unpublished- data today: a silent-players count for the next session, plus an
line-up flag for the next session. The mock's member-blocker row stays unpublished-line-up flag for *every* upcoming game within
deferred -- no coach-facing member-edit screen exists yet to link to. UPCOMING_GAMES_CHECKED (not just whichever happens to be the very next
session -- a practice landing before Saturday's game shouldn't hide that
the game's own line-up still needs building). The mock's member-blocker
row stays deferred -- no coach-facing member-edit screen exists yet to
link to.
""" """
template_name = "mobile/coach/today.html" template_name = "mobile/coach/today.html"
screen_title = _("Today") screen_title = _("Today")
active_tab = "coach_today" active_tab = "coach_today"
#: How many of the team's soonest upcoming games to check for a missing
#: line-up -- unbounded would mean querying arbitrarily far into a full
#: season; this many is already more advance notice than useful.
UPCOMING_GAMES_CHECKED = 5
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
now = timezone.now() now = timezone.now()
today = timezone.localdate() today = timezone.localdate()
@@ -79,11 +89,30 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
in_count = attendances.filter(status__in=IN_STATUSES).count() in_count = attendances.filter(status__in=IN_STATUSES).count()
silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count() silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count()
if silent_count > 0: if silent_count > 0:
needs_you.append({"severity": "warn", "title": _("Silent players"), "detail": _("%(count)d haven't answered yet") % {"count": silent_count}}) needs_you.append(
if session_event.kind == Event.EventKind.GAME: {
lineup = Lineup.objects.filter(event=session_event).first() "severity": "warn",
if lineup is None or lineup.published_at is None: "title": _("Silent players"),
needs_you.append({"severity": "club", "title": _("Line-up not published"), "detail": _("Build it before the game.")}) "detail": _("%(count)d haven't answered yet") % {"count": silent_count},
"action_label": _("Review"),
"action_url": reverse("mobile:coach_attendance", kwargs={"event_id": session_event.pk}) if self.can_manage_active_team else "",
}
)
upcoming_games = list(upcoming.filter(kind=Event.EventKind.GAME)[: self.UPCOMING_GAMES_CHECKED])
published_event_ids = set(Lineup.objects.filter(event__in=upcoming_games, published_at__isnull=False).values_list("event_id", flat=True))
for game in upcoming_games:
if game.pk in published_event_ids:
continue
needs_you.append(
{
"severity": "club",
"title": _("Line-up not published"),
"detail": _("%(title)s · %(date)s") % {"title": game.title, "date": timezone.localtime(game.start).strftime("%a %d %b, %H:%M")},
"action_label": _("Build"),
"action_url": reverse("mobile:coach_lineup", kwargs={"event_id": game.pk}) if self.can_manage_active_team else "",
}
)
hero_attendance = None hero_attendance = None
rsvp_closed = False rsvp_closed = False

View File

@@ -18,6 +18,15 @@
<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 session_event %}
<div class="flex items-center justify-between">
<span class="font-display text-xs font-extrabold text-on-dark-dim uppercase tracking-wide">
{% if tonight_event %}{% trans "Stats for tonight" %}{% else %}{% blocktrans with date=session_event.start|date:"D d M" %}Stats for {{ date }}{% endblocktrans %}{% endif %}
</span>
<span class="truncate font-display text-xs font-extrabold text-ice uppercase tracking-wide">{{ session_event.title }}</span>
</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>
@@ -33,18 +42,21 @@
</div> </div>
</div> </div>
{% if tonight_event %} {% if session_event %}
<div class="m-card-dark overflow-hidden"> <div class="m-card-dark overflow-hidden">
<div class="flex items-center justify-between px-4 py-2.5"> <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> <span class="font-display text-xs font-extrabold text-ice uppercase tracking-wide">
{% if tonight_event %}{% blocktrans with time=session_event.start|date:"H:i" %}Tonight &middot; {{ time }}{% endblocktrans %}{% else %}{% blocktrans with date=session_event.start|date:"D d M H:i" %}Next up &middot; {{ date }}{% endblocktrans %}{% endif %}
</span>
</div> </div>
<div class="px-4 pb-4"> <div class="px-4 pb-4">
<p class="font-display text-xl leading-none font-extrabold uppercase">{{ tonight_event.title }}</p> <p class="font-display text-xl leading-none font-extrabold uppercase">{{ session_event.title }}</p>
{% if session_event.location %}<p class="mt-1 text-sm text-on-dark-dim">{{ session_event.location.name }}</p>{% endif %}
{% if can_manage_active_team %} {% if can_manage_active_team %}
<div class="mt-3 flex gap-2"> <div class="mt-3 flex gap-2">
<a class="btn flex-1 bg-ice text-ice-ink" href="{% url "mobile:coach_attendance" tonight_event.pk %}">{% trans "Check attendance" %}</a> <a class="btn flex-1 bg-ice text-ice-ink" href="{% url "mobile:coach_attendance" session_event.pk %}">{% trans "Check attendance" %}</a>
{% if tonight_event.kind == "game" %} {% if session_event.kind == "game" %}
<a class="btn flex-1 bg-steel text-on-dark" href="{% url "mobile:coach_lineup" tonight_event.pk %}">{% trans "Line-up" %}</a> <a class="btn flex-1 bg-steel text-on-dark" href="{% url "mobile:coach_lineup" session_event.pk %}">{% trans "Line-up" %}</a>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -61,10 +73,8 @@
<div class="text-sm font-semibold text-ink">{{ item.title }}</div> <div class="text-sm font-semibold text-ink">{{ item.title }}</div>
<div class="text-xs text-muted">{{ item.detail }}</div> <div class="text-xs text-muted">{{ item.detail }}</div>
</div> </div>
{% if item.severity == "warn" and session_event and can_manage_active_team %} {% if item.action_url %}
<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> <a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{{ item.action_url }}">{{ item.action_label }}</a>
{% elif item.severity == "club" 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_lineup" session_event.pk %}">{% trans "Build" %}</a>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@@ -86,7 +96,7 @@
</div> </div>
{% endif %} {% endif %}
{% if not tonight_event and not needs_you and not hero_attendance %} {% if not session_event and not needs_you and not hero_attendance %}
<div class="m-card p-6 text-center"> <div class="m-card p-6 text-center">
<p class="text-sm text-muted">{% trans "Nothing needs your attention right now." %}</p> <p class="text-sm text-muted">{% trans "Nothing needs your attention right now." %}</p>
</div> </div>

View File

@@ -15,6 +15,7 @@ from news.models import News
from notifications.models import Notification from notifications.models import Notification
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
from .coach_views import CoachTodayView
from .models import CalendarFeedToken, PushSubscription from .models import CalendarFeedToken, PushSubscription
from .services.icons import render_fallback_icon from .services.icons import render_fallback_icon
@@ -2405,7 +2406,7 @@ class CoachTodayViewTests(TestCase):
self.assertEqual(response.context["tonight_event"], event) self.assertEqual(response.context["tonight_event"], event)
self.assertContains(response, "Practice") self.assertContains(response, "Practice")
def test_no_session_today_omits_the_tonight_card(self): def test_no_session_today_still_shows_the_next_upcoming_one(self):
event = Event.objects.create(club=self.club, title="Next week", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=5)) event = Event.objects.create(club=self.club, title="Next week", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=5))
event.teams.add(self.team) event.teams.add(self.team)
self.client.force_login(self.user) self.client.force_login(self.user)
@@ -2415,6 +2416,8 @@ class CoachTodayViewTests(TestCase):
self.assertIsNone(response.context["tonight_event"]) self.assertIsNone(response.context["tonight_event"])
self.assertEqual(response.context["session_event"], event) self.assertEqual(response.context["session_event"], event)
self.assertNotContains(response, "Tonight") self.assertNotContains(response, "Tonight")
self.assertContains(response, "Next up")
self.assertContains(response, "Next week")
def test_silent_players_are_counted_and_listed_in_needs_you(self): def test_silent_players_are_counted_and_listed_in_needs_you(self):
other_member = Member.objects.create(first_name="Anna", last_name="Player") other_member = Member.objects.create(first_name="Anna", last_name="Player")
@@ -2472,6 +2475,54 @@ class CoachTodayViewTests(TestCase):
self.assertNotContains(response, "Line-up not published") self.assertNotContains(response, "Line-up not published")
def test_a_later_games_missing_lineup_shows_even_when_the_next_session_is_a_practice(self):
# session_event (the very next thing on the calendar) is a practice --
# the game further out still needs flagging, not just whatever's soonest.
practice = Event.objects.create(club=self.club, title="Practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(minutes=5))
practice.teams.add(self.team)
game = Event.objects.create(club=self.club, title="Saturday's game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=3))
game.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["session_event"], practice)
self.assertContains(response, "Saturday&#x27;s game")
self.assertContains(response, "Line-up not published")
def test_several_upcoming_games_each_missing_a_lineup_are_all_listed(self):
first = Event.objects.create(club=self.club, title="Game one", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=1))
first.teams.add(self.team)
second = Event.objects.create(club=self.club, title="Game two", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=3))
second.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Game one")
self.assertContains(response, "Game two")
self.assertContains(response, "Line-up not published", count=2)
def test_missing_lineup_build_link_points_at_the_specific_game(self):
game = Event.objects.create(club=self.club, title="Big game", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=1))
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_missing_lineup_check_is_capped(self):
cap = CoachTodayView.UPCOMING_GAMES_CHECKED
for day in range(cap + 1):
game = Event.objects.create(club=self.club, title=f"Game {day}", kind=Event.EventKind.GAME, start=timezone.now() + datetime.timedelta(days=day + 1))
game.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertContains(response, "Line-up not published", count=cap)
def test_header_shows_the_persons_actual_role_not_a_hardcoded_label(self): def test_header_shows_the_persons_actual_role_not_a_hardcoded_label(self):
self.client.force_login(self.user) self.client.force_login(self.user)