Let an end time mean the card leaves the board

The shopfloor feed kept every ended notification up for a hardcoded 30 minutes,
flagged resolved. A card with an 8:00 end time was still on the board at 8:29,
which reads as an expiry that did not work - and in the carousel, grid and
banner sections it read that way with no visual sign at all, since only the
standard cards render the resolved state.

The tail is now notificationtypes.gracewindowminutes, set per type on the
Notification Types page and defaulting to 0, so an end time means what it says.
A type whose cards are worth acknowledging after they clear - an incident, say
- opts into a tail, and only that type's cards get one.

The feed widens its query to the largest configured tail and then holds each
row to its own type's window. That keeps one portable query rather than a
per-type interval expression in SQL, and with every type at 0 it collapses to
"still showing".

Also fixes resolved serializing as null rather than false for a card with no
end time, which the and-chain produced.
This commit is contained in:
cproudlock
2026-08-07 09:22:34 -04:00
parent 536a8f0825
commit fef5e28952
6 changed files with 259 additions and 13 deletions

View File

@@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data):
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
# Ceiling on the per-type post-expiry tail. A day is already far longer than
# "recently ended"; anything more is an end time that should have been later.
_MAX_GRACE_MINUTES = 1440
def _apply_display_fields(t, data):
"""Set shopfloor display-behavior columns on a NotificationType from request
@@ -153,6 +157,17 @@ def _apply_display_fields(t, data):
if ds not in _DISPLAY_STYLES:
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
t.displaystyle = ds
if 'gracewindowminutes' in data:
raw = data.get('gracewindowminutes')
raw = 0 if raw in (None, '') else raw
try:
minutes = int(raw)
except (TypeError, ValueError):
return "gracewindowminutes must be a whole number of minutes"
if minutes < 0 or minutes > _MAX_GRACE_MINUTES:
return ("gracewindowminutes must be between 0 and %d"
% _MAX_GRACE_MINUTES)
t.gracewindowminutes = minutes
return None
@@ -163,10 +178,11 @@ def _config_version():
reach pages that are already open."""
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
parts = [
"%s|%s|%s|%d|%d|%s|%s|%s|%d" % (
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d" % (
t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
int(t.gracewindowminutes or 0),
)
for t in types
]
@@ -698,8 +714,15 @@ def get_shopfloor_notifications():
# All units: only show notifications with NULL businessunitid
base_query = base_query.filter(Notification.businessunitid.is_(None))
# Current notifications (active now or ended within 30 minutes)
thirty_min_ago = now - timedelta(minutes=30)
# Current notifications: showing now, plus anything whose type asks to keep
# ended cards up for a while (gracewindowminutes, 0 by default - an end time
# means the card leaves the board then). The window is per type, so the SQL
# only widens to the largest configured tail and each row is then held to
# its own; that keeps this one portable query instead of a per-type interval
# expression, and with every type at 0 it collapses to "still showing".
widest_grace = db.session.query(
db.func.max(NotificationType.gracewindowminutes)).scalar() or 0
widest_grace_start = now - timedelta(minutes=int(widest_grace))
current_query = base_query.filter(
db.or_(
# Active and currently showing
@@ -708,16 +731,23 @@ def get_shopfloor_notifications():
db.or_(Notification.starttime.is_(None), Notification.starttime <= now),
db.or_(Notification.endtime.is_(None), Notification.endtime >= now)
),
# Recently ended (within 30 min) - show as resolved
# Ended, but possibly inside its type's tail - narrowed below
db.and_(
Notification.endtime.isnot(None),
Notification.endtime >= thirty_min_ago,
Notification.endtime >= widest_grace_start,
Notification.endtime < now
)
)
).order_by(Notification.notificationid.desc())
current_notifications = current_query.all()
def _within_grace(n):
"""True unless the row ended outside its own type's tail."""
if n.endtime is None or n.endtime >= now:
return True
grace = (n.notificationtype.gracewindowminutes or 0) if n.notificationtype else 0
return n.endtime >= now - timedelta(minutes=int(grace))
current_notifications = [n for n in current_query.all() if _within_grace(n)]
# Upcoming notifications (starts within next 5 days)
five_days = now + timedelta(days=5)
@@ -731,7 +761,9 @@ def get_shopfloor_notifications():
def notification_to_shopfloor(n, employee_override=None):
"""Convert notification to shopfloor format."""
is_resolved = n.endtime and n.endtime < now
# bool, not the datetime-or-None the and-chain yields: a card with no
# end time was serializing resolved as null.
is_resolved = bool(n.endtime and n.endtime < now)
ntype = n.notificationtype
show_photo = bool(ntype and ntype.showemployeephoto)