Keep Coach Today's session card current past an event's start time

Previously the "Tonight"/"Next up" card and the missing-line-up check flipped
to the next session the instant the current one started (start__gte=now).
Now an event stays current until 30 minutes past its end time, or 90 minutes
past its start when no end is set (most training events carry none) -- a
coach mid-practice or mid-game no longer sees the card jump ahead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 12:43:17 +02:00
parent d0e9dde8ed
commit 3b64fae43f
2 changed files with 79 additions and 1 deletions

View File

@@ -5,8 +5,11 @@ club/season plumbing already factored into club.services.access -- see
mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding.
"""
import datetime
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Case, F, When
from django.http import Http404, HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
@@ -40,6 +43,25 @@ IN_STATUSES = [Attendance.AttendanceStatus.PRESENT, Attendance.AttendanceStatus.
#: Distinct from NO_RESPONSE ("silent"), which is a non-answer rather than a no.
OUT_STATUSES = [Attendance.AttendanceStatus.ABSENT, Attendance.AttendanceStatus.EXCUSED, Attendance.AttendanceStatus.NOT_SELECTED]
#: How long an event stays "current" (CoachTodayView's session card, and the
#: missing-line-up nudge) past the moment it starts -- events.start__gte=now
#: alone would flip to the next session the instant this one begins, while
#: the coach is still mid-practice/mid-game. Past end (when set) plus a grace
#: window; past start plus an assumed length when it isn't (most training
#: events carry no end time -- see Event.end's own help text).
STILL_CURRENT_GRACE = datetime.timedelta(minutes=30)
STILL_CURRENT_ASSUMED_DURATION = datetime.timedelta(minutes=90)
def _still_current_events(team):
"""Events for ``team`` that haven't yet reached their "still current"
cutoff -- see STILL_CURRENT_GRACE/STILL_CURRENT_ASSUMED_DURATION above."""
cutoff = Case(
When(end__isnull=False, then=F("end") + STILL_CURRENT_GRACE),
default=F("start") + STILL_CURRENT_ASSUMED_DURATION,
)
return Event.objects.filter(teams=team, cancelled=False).annotate(still_current_cutoff=cutoff).filter(still_current_cutoff__gte=timezone.now()).order_by("start")
class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
"""C1 -- three stat tiles (Squad/In/Silent) for the active team's next
@@ -86,7 +108,7 @@ class CoachTodayView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
if team is not None:
squad_count = TeamMembership.objects.filter(team=team, season=season).count() if season is not None else 0
upcoming = Event.objects.filter(teams=team, cancelled=False, start__gte=now).order_by("start")
upcoming = _still_current_events(team)
tonight_event = upcoming.filter(start__date=today).first()
session_event = tonight_event or upcoming.first()

View File

@@ -2419,6 +2419,62 @@ class CoachTodayViewTests(TestCase):
self.assertContains(response, "Next up")
self.assertContains(response, "Next week")
def test_a_session_with_an_end_time_stays_current_until_30_minutes_past_it(self):
ongoing = Event.objects.create(
club=self.club,
title="Ongoing game",
kind=Event.EventKind.GAME,
start=timezone.now() - datetime.timedelta(hours=2),
end=timezone.now() - datetime.timedelta(minutes=20),
)
ongoing.teams.add(self.team)
later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1))
later.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["session_event"], ongoing)
def test_a_session_with_an_end_time_switches_30_minutes_past_it(self):
finished = Event.objects.create(
club=self.club,
title="Finished game",
kind=Event.EventKind.GAME,
start=timezone.now() - datetime.timedelta(hours=2),
end=timezone.now() - datetime.timedelta(minutes=40),
)
finished.teams.add(self.team)
later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1))
later.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["session_event"], later)
def test_a_session_with_no_end_time_stays_current_until_90_minutes_past_start(self):
ongoing = Event.objects.create(club=self.club, title="Ongoing practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(minutes=80))
ongoing.teams.add(self.team)
later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1))
later.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["session_event"], ongoing)
def test_a_session_with_no_end_time_switches_90_minutes_past_start(self):
finished = Event.objects.create(club=self.club, title="Finished practice", kind=Event.EventKind.TRAINING, start=timezone.now() - datetime.timedelta(minutes=100))
finished.teams.add(self.team)
later = Event.objects.create(club=self.club, title="Later practice", kind=Event.EventKind.TRAINING, start=timezone.now() + datetime.timedelta(days=1))
later.teams.add(self.team)
self.client.force_login(self.user)
response = self._get()
self.assertEqual(response.context["session_event"], later)
def test_silent_players_are_counted_and_listed_in_needs_you(self):
other_member = Member.objects.create(first_name="Anna", last_name="Player")
TeamMembership.objects.create(team=self.team, member=other_member, season=self.season)