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:
@@ -38,22 +38,32 @@ IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.
|
||||
|
||||
class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
"""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,
|
||||
a "needs you" list, and an "Also yours" card surfacing the coach's own
|
||||
member-side RSVP obligation (the same hero_attendance/rsvp_closed pattern
|
||||
mobile.views.HomeView already computes, scoped to self.me only -- a
|
||||
coach's own obligations, not the whole roster's).
|
||||
upcoming session, a session card for it (today's, if there's one on the
|
||||
calendar today, otherwise whichever is soonest), a "needs you" list, and
|
||||
an "Also yours" card surfacing the coach's own member-side RSVP
|
||||
obligation (the same hero_attendance/rsvp_closed pattern mobile.views.
|
||||
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
|
||||
data today: a silent-players count and (for a game) an unpublished-
|
||||
line-up flag for the next session. The mock's member-blocker row stays
|
||||
deferred -- no coach-facing member-edit screen exists yet to link to.
|
||||
data today: a silent-players count for the next session, plus an
|
||||
unpublished-line-up flag for *every* upcoming game within
|
||||
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"
|
||||
screen_title = _("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):
|
||||
now = timezone.now()
|
||||
today = timezone.localdate()
|
||||
@@ -79,11 +89,30 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
|
||||
in_count = attendances.filter(status__in=IN_STATUSES).count()
|
||||
silent_count = attendances.filter(status=Attendance.AttendanceStatus.NO_RESPONSE).count()
|
||||
if silent_count > 0:
|
||||
needs_you.append({"severity": "warn", "title": _("Silent players"), "detail": _("%(count)d haven't answered yet") % {"count": silent_count}})
|
||||
if session_event.kind == Event.EventKind.GAME:
|
||||
lineup = Lineup.objects.filter(event=session_event).first()
|
||||
if lineup is None or lineup.published_at is None:
|
||||
needs_you.append({"severity": "club", "title": _("Line-up not published"), "detail": _("Build it before the game.")})
|
||||
needs_you.append(
|
||||
{
|
||||
"severity": "warn",
|
||||
"title": _("Silent players"),
|
||||
"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
|
||||
rsvp_closed = False
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
{% 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="m-card p-3 text-center">
|
||||
<div class="font-display text-2xl leading-none font-extrabold text-ink">{{ squad_count }}</div>
|
||||
@@ -33,18 +42,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if tonight_event %}
|
||||
{% if session_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 · {{ 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 · {{ time }}{% endblocktrans %}{% else %}{% blocktrans with date=session_event.start|date:"D d M H:i" %}Next up · {{ date }}{% endblocktrans %}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<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 %}
|
||||
<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>
|
||||
{% if tonight_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-ice text-ice-ink" href="{% url "mobile:coach_attendance" session_event.pk %}">{% trans "Check attendance" %}</a>
|
||||
{% if session_event.kind == "game" %}
|
||||
<a class="btn flex-1 bg-steel text-on-dark" href="{% url "mobile:coach_lineup" session_event.pk %}">{% trans "Line-up" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -61,10 +73,8 @@
|
||||
<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>
|
||||
{% 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>
|
||||
{% if item.action_url %}
|
||||
<a class="shrink-0 font-display text-xs font-extrabold tracking-wide text-club uppercase" href="{{ item.action_url }}">{{ item.action_label }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -86,7 +96,7 @@
|
||||
</div>
|
||||
{% 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">
|
||||
<p class="text-sm text-muted">{% trans "Nothing needs your attention right now." %}</p>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ from news.models import News
|
||||
from notifications.models import Notification
|
||||
from teams.models import Position, RefereeLevel, RefereeProfile, StaffAssignment, Team, TeamMembership
|
||||
|
||||
from .coach_views import CoachTodayView
|
||||
from .models import CalendarFeedToken, PushSubscription
|
||||
from .services.icons import render_fallback_icon
|
||||
|
||||
@@ -2405,7 +2406,7 @@ class CoachTodayViewTests(TestCase):
|
||||
self.assertEqual(response.context["tonight_event"], event)
|
||||
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.teams.add(self.team)
|
||||
self.client.force_login(self.user)
|
||||
@@ -2415,6 +2416,8 @@ class CoachTodayViewTests(TestCase):
|
||||
self.assertIsNone(response.context["tonight_event"])
|
||||
self.assertEqual(response.context["session_event"], event)
|
||||
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):
|
||||
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")
|
||||
|
||||
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'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):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user