Board row ordering, type-derived row colours, and clearer upcoming events

This commit is contained in:
2026-08-08 14:24:28 -04:00
parent 3ebc1f353a
commit 4344adabf1
11 changed files with 450 additions and 72 deletions

View File

@@ -37,10 +37,10 @@
v-for="n in group.items"
:key="n.notificationid"
class="banner-strip"
:class="{ pending: n.upcoming || n.resolved }"
:class="[stateKind(n) ? 'pending' : '', stateKind(n)]"
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
>
<span v-if="stateLabel(n)" class="state-chip">{{ stateLabel(n) }}</span>
<span v-if="stateLabel(n)" class="state-chip" :class="stateKind(n)">{{ stateLabel(n) }}</span>
{{ n.notification }}
</div>
</section>
@@ -49,7 +49,6 @@
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
<div
class="section-title recognition"
:class="{ mixed: isMixed(group) }"
:style="headingStyle(group)"
>{{ group.typename }}</div>
<div class="recognition-carousel">
@@ -57,7 +56,7 @@
v-for="(rec, idx) in group.items"
:key="`${rec.notificationid}-${rec.employeesso}`"
class="recognition-card"
:class="{ active: idx === carouselIndex(group), pending: rec.upcoming || rec.resolved }"
:class="[{ active: idx === carouselIndex(group) }, stateKind(rec) ? 'pending' : '', stateKind(rec)]"
:style="{ '--accent': getTypeColor(rec.typecolor) }"
>
<!-- A type that names no employee is a message, not a person:
@@ -91,7 +90,7 @@
class="type-chip"
:style="{ backgroundColor: getTypeColor(rec.typecolor), color: textOn(rec.typecolor) }"
>{{ rec.typename }}</span>
<span v-if="stateLabel(rec)" class="state-chip">{{ stateLabel(rec) }}</span>
<span v-if="stateLabel(rec)" class="state-chip" :class="stateKind(rec)">{{ stateLabel(rec) }}</span>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
@@ -104,7 +103,7 @@
v-for="(rec, idx) in group.items"
:key="`pip-${rec.notificationid}-${rec.employeesso}`"
class="pip"
:class="{ on: idx === carouselIndex(group) }"
:class="{ on: idx === carouselIndex(group), scheduled: rec.upcoming }"
:style="{ backgroundColor: getTypeColor(rec.typecolor) }"
></span>
</div>
@@ -114,7 +113,6 @@
<section v-else class="recert-section">
<div
class="section-title recert-title"
:class="{ mixed: isMixed(group) }"
:style="headingStyle(group)"
>
<span>{{ group.typename }} ({{ group.items.length }})</span>
@@ -132,7 +130,7 @@
v-for="rec in gridPageItems(group)"
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
class="recert-tile"
:class="{ 'message-tile': !hasEmployee(rec), pending: rec.upcoming || rec.resolved }"
:class="[{ 'message-tile': !hasEmployee(rec) }, stateKind(rec) ? 'pending' : '', stateKind(rec)]"
:style="{ '--accent': getTypeColor(rec.typecolor) }"
>
<template v-if="hasEmployee(rec)">
@@ -154,7 +152,7 @@
<!-- No employee: the message IS the tile, so a grid type like
Awareness does not render rows of blank placeholder faces. -->
<div v-else class="recert-message">{{ rec.notification }}</div>
<span v-if="stateLabel(rec)" class="state-chip tile-state">{{ stateLabel(rec) }}</span>
<span v-if="stateLabel(rec)" class="state-chip tile-state" :class="stateKind(rec)">{{ stateLabel(rec) }}</span>
</div>
</div>
</section>
@@ -163,7 +161,7 @@
<!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(currentNotifications)">
<div class="section-title" :style="itemsHeadingStyle(currentNotifications)">
Current Notifications
</div>
<div class="events-list">
@@ -198,7 +196,7 @@
<!-- Upcoming Notifications -->
<section v-if="upcomingNotifications.length" class="notifications-section">
<div class="section-title upcoming">Upcoming</div>
<div class="section-title upcoming" :style="itemsHeadingStyle(upcomingNotifications)">Upcoming</div>
<div class="events-list">
<div
v-for="n in upcomingNotifications"
@@ -280,9 +278,11 @@ function fitToScreen() {
// type set to carousel/grid/banner renders that way - not just the built-ins.
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
// Sections render in style order, so the board keeps its shape as types come
// and go: strips at the top, then rotating cards, then tile rows.
// Rows run in the order each type is given (boardorder, low first), so a site
// decides what leads the screen. Style is only the tie-break, for types left on
// the same number: strips, then rotating cards, then tile rows.
const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
const DEFAULT_BOARD_ORDER = 100
// One row per TYPE, unless types opt into a shared boardcategory - Change,
// Awareness and Incident under one "Alerts" heading, say, while Recognition and
@@ -312,16 +312,22 @@ const styledGroups = computed(() => {
// category is one heading, so it can only carry one colour; the
// individual cards below still wear their own type's colour.
typecolor: n.typecolor,
// A shared row sits wherever its earliest-ordered type puts it.
boardorder: DEFAULT_BOARD_ORDER,
items: []
})
}
groups.get(key).items.push(n)
const group = groups.get(key)
group.items.push(n)
const order = Number.isFinite(n.boardorder) ? n.boardorder : DEFAULT_BOARD_ORDER
group.boardorder = Math.min(group.boardorder, order)
}
// Live cards lead each row; what has not started yet follows.
for (const group of groups.values()) {
group.items.sort((a, b) => Number(a.upcoming) - Number(b.upcoming))
}
return [...groups.values()].sort((a, b) =>
(a.boardorder - b.boardorder) ||
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
a.typename.localeCompare(b.typename)
)
@@ -507,23 +513,81 @@ function getTypeColor(typecolor) {
// What a card's state chip says, or '' while it is simply live. Resolved is the
// tail a type's grace window buys it; upcoming is scheduled and not yet showing.
function stateLabel(item) {
if (item.upcoming) return `UPCOMING ${formatDateTime(item.starttime)}`
if (item.upcoming) return `STARTS ${startsWhen(item.starttime)}`
return item.resolved ? 'RESOLVED' : ''
}
// '' while live, else which chip treatment the card wears.
function stateKind(item) {
if (item.upcoming) return 'upcoming'
return item.resolved ? 'resolved' : ''
}
// When an upcoming card starts, phrased for someone walking past: today and
// tomorrow by name, anything further out by weekday and date. An absolute
// timestamp alone makes a reader do the arithmetic.
function startsWhen(dateStr) {
if (!dateStr) return 'SOON'
const start = new Date(dateStr)
const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const startday = new Date(start.getFullYear(), start.getMonth(), start.getDate())
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = Math.round((startday - today) / 86400000)
if (days <= 0) return `TODAY ${time}`
if (days === 1) return `TOMORROW ${time}`
const when = start.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
return `${when} ${time}`
}
// A row holding more than one type: the heading names a category, so it cannot
// speak for any single type's colour or name.
function isMixed(group) {
return new Set(group.items.map(item => item.typename)).size > 1
}
// A single-type row wears that type's colour. A mixed one keeps the neutral
// heading, since picking the first type's colour just mislabels the rest.
// The colour a set of cards weighs most heavily towards: whichever type has
// the most cards, ties going to the type that sorts first on the board. A blend
// of several type colours just makes mud, and picking the first arbitrarily
// mislabels the rest - a plurality is at least true of the row.
//
// Live cards decide it. Only when nothing on the row has started yet does the
// upcoming set get a say, so a heading always describes what is happening now
// rather than what is scheduled.
function dominantColor(items) {
const live = items.filter(item => !item.upcoming && !item.resolved)
const scheduled = items.filter(item => item.upcoming)
const pool = live.length ? live : (scheduled.length ? scheduled : items)
const weights = new Map()
for (const item of pool) {
const key = item.typecolor || ''
const seen = weights.get(key) || { count: 0, order: Number.MAX_SAFE_INTEGER }
seen.count += 1
seen.order = Math.min(seen.order, Number.isFinite(item.boardorder)
? item.boardorder : DEFAULT_BOARD_ORDER)
weights.set(key, seen)
}
let winner = null
for (const [typecolor, seen] of weights) {
if (!winner || seen.count > winner.count ||
(seen.count === winner.count && seen.order < winner.order)) {
winner = { typecolor, ...seen }
}
}
return winner ? winner.typecolor : null
}
// Every heading on the board takes its colour from the types beneath it; none
// is hardcoded per row or per display style.
function headingStyle(group) {
if (isMixed(group)) return {}
return itemsHeadingStyle(group.items)
}
function itemsHeadingStyle(items) {
const typecolor = dominantColor(items)
return {
backgroundColor: getTypeColor(group.typecolor),
color: textOn(group.typecolor)
backgroundColor: getTypeColor(typecolor),
color: textOn(typecolor)
}
}
@@ -540,12 +604,6 @@ function textOn(backgroundcolor) {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 150 ? '#231b00' : '#fff'
}
function getSectionClass(notifications) {
// Use danger color if any active incidents
const hasIncident = notifications.some(n => n.typecolor === 'danger' && !n.resolved)
return hasIncident ? 'danger' : ''
}
function formatTime(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleTimeString('en-US', {
@@ -685,13 +743,12 @@ function handlePhotoError(e) {
text-transform: uppercase;
letter-spacing: 2px;
margin-bottom: 15px;
/* Neutral only until the inline, type-derived colour lands - every heading
on this board is coloured by the types beneath it. */
background: #4181ff;
}
.section-title.recognition {
/* Fallback only - the real colour is the type's, set inline. */
background: #ffc107;
color: #3a2e00;
}
/* Recertification grid - blue, compact tiles so 20-30 people are all visible
@@ -700,9 +757,6 @@ function handlePhotoError(e) {
margin-bottom: 25px;
}
.section-title.recert-title {
/* Fallback only - the real colour is the type's, set inline. */
background: #0d6efd;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
@@ -727,10 +781,12 @@ function handlePhotoError(e) {
gap: 14px;
}
.recert-tile {
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #16233b 0%, #0d2137 100%);
border: 2px solid var(--accent, #0d6efd);
border-radius: 10px;
padding: 14px 10px;
padding: 14px 10px 14px 20px;
display: flex;
flex-direction: column;
align-items: center;
@@ -763,20 +819,7 @@ function handlePhotoError(e) {
overflow: hidden;
}
/* A row of several types speaks for none of them, so it keeps the neutral
heading rather than wearing the first type's colour (or, via the per-style
fallbacks above, Recognition's gold and Recertification's blue). */
.section-title.mixed {
background: #4181ff;
color: #fff;
}
.section-title.danger {
background: #f5365c;
}
.section-title.upcoming {
background: #6c757d;
}
/* Recognition carousel */
@@ -843,6 +886,30 @@ function handlePhotoError(e) {
opacity: 0.72;
}
/* An upcoming card is dashed, not solid: shape carries the "not yet" even for
a reader who cannot pick the amber chip out at distance, or who is looking
at the board side-on. */
.recognition-card.upcoming,
.recert-tile.upcoming,
.banner-strip.upcoming {
border-style: dashed;
}
.state-chip.upcoming {
background: #ffc107;
border-color: #ffc107;
color: #231b00;
}
.state-chip.resolved {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.4);
color: #fff;
}
.pip.scheduled {
background: transparent !important;
box-shadow: inset 0 0 0 3px currentcolor;
}
.state-chip {
padding: 4px 14px;
border-radius: 999px;
@@ -871,7 +938,25 @@ function handlePhotoError(e) {
margin-bottom: 8px;
}
/* Type colour as a bar down the left edge, the way the legacy board marked a
card. The border alone reads as chrome at distance; a solid edge reads as
the type. Same idea as .event-indicator on the standard cards. */
.recognition-card::before,
.recert-tile::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 14px;
background: var(--accent, #ffc107);
}
.recert-tile::before {
width: 10px;
}
.recognition-card {
overflow: hidden;
position: absolute;
top: 0;
left: 0;
@@ -879,7 +964,7 @@ function handlePhotoError(e) {
background: linear-gradient(135deg, #2b2718 0%, #1d1a10 100%);
border: 3px solid var(--accent, #ffc107);
border-radius: 12px;
padding: 20px 25px;
padding: 20px 25px 20px 39px;
display: flex;
align-items: center;
gap: 25px;
@@ -921,18 +1006,6 @@ function handlePhotoError(e) {
margin-bottom: 10px;
}
.recognition-star {
font-size: 48px;
color: #ffc107;
margin-right: 20px;
animation: starPulse 2s ease-in-out infinite;
}
@keyframes starPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.recognition-name {
font-size: 36px;

View File

@@ -12,6 +12,9 @@ because requiring the app server to mount the SFLD share would turn a
permissions slip into an unexplained empty download.
"""
from datetime import timezone
from zoneinfo import ZoneInfo
from flask import Blueprint, current_app, request, Response
from flask_jwt_extended import jwt_required
@@ -22,15 +25,42 @@ from shopdb.api import (
)
from ..models import BackupRevision
from ..models.backup import _utciso
from ..services.registry import REGISTRY, getkind
backups_bp = Blueprint('backups', __name__)
_DEFAULTTZ = 'America/New_York'
def _sitezone():
"""Site-configured IANA zone (settings key site_timezone).
Same lookup the notifications plugin uses. A stored timestamp is UTC, so
anything rendered server-side has to be converted or it shows the wrong
wall clock for the site - four hours out at West Jefferson.
"""
from shopdb.api import Setting
row = Setting.query.filter_by(key='site_timezone').first()
name = row.value if row and row.value else _DEFAULTTZ
try:
return ZoneInfo(name)
except Exception:
return ZoneInfo(_DEFAULTTZ)
def _label(revision):
"""Panel list title: kind-agnostic, readable at a glance."""
"""Panel list title: kind-agnostic, readable at a glance.
Rendered in the SITE zone, not UTC: this string is baked server-side and
the client cannot correct it afterwards.
"""
when = revision.collectedat or revision.createdat
stamp = when.strftime('%Y-%m-%d %H:%M') if when else 'unknown time'
if not when:
return 'unknown time'
local = when.replace(tzinfo=timezone.utc).astimezone(_sitezone())
stamp = local.strftime('%Y-%m-%d %H:%M')
if revision.sourcefilename:
return '{} - {}'.format(stamp, revision.sourcefilename)
return stamp
@@ -129,8 +159,7 @@ def asset_info(assetid):
revision.payload, assetid,
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
data['backuprevisionid'] = revision.backuprevisionid
data['collectedat'] = (revision.collectedat.isoformat()
if revision.collectedat else None)
data['collectedat'] = _utciso(revision.collectedat)
return success_response(data)

View File

@@ -95,7 +95,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
import api, { settingsApi } from '@/api'
import { formatInZone, DEFAULT_TZ } from '@/utils/datetime'
const route = useRoute()
const router = useRouter()
@@ -111,11 +112,13 @@ const diffs = ref({})
const assetname = ref('')
const loading = ref(true)
const error = ref('')
// Timestamps are stored UTC and must show in the SITE's zone, not the viewer's,
// so a remote admin and someone at the bay read the same wall clock.
const siteTimezone = ref(DEFAULT_TZ)
function formatWhen(value) {
if (!value) return 'unknown'
const d = new Date(value)
return isNaN(d) ? value : d.toLocaleString()
return formatInZone(value, siteTimezone.value, { second: '2-digit' }) || value
}
function display(value) {
@@ -124,6 +127,16 @@ function display(value) {
return String(value)
}
async function loadTimezone() {
try {
const response = await settingsApi.get('site_timezone')
const value = response?.data?.data?.value
if (value) siteTimezone.value = value
} catch (e) {
// Keep the default zone; a missing setting must not blank the page.
}
}
async function load() {
loading.value = true
error.value = ''
@@ -183,7 +196,10 @@ async function download(rev, fmt) {
URL.revokeObjectURL(link.href)
}
onMounted(load)
onMounted(async () => {
await loadTimezone()
await load()
})
</script>
<style scoped>

View File

@@ -27,6 +27,15 @@ from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked as UTC, or None.
Every datetime this plugin stores is naive UTC. Serialising it without the
Z leaves the receiver to guess, and JavaScript guesses browser-local.
"""
return value.isoformat() + 'Z' if value else None
class BackupRevision(db.Model):
"""A single point-in-time configuration snapshot of an asset."""
@@ -123,8 +132,13 @@ class BackupRevision(db.Model):
# download <machinenumber>.reg without a second round trip.
'assetnumber': self.asset.assetnumber if self.asset else None,
'sourcehostname': self.sourcehostname,
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
'createdat': self.createdat.isoformat() if self.createdat else None,
# Both columns hold NAIVE UTC (see collectedat above), so the wire
# format says so with a trailing Z. Without it JavaScript's
# `new Date('2026-08-07T12:00:00')` parses the string as
# BROWSER-LOCAL and the timestamp silently shifts by the viewer's
# offset before any site-timezone formatting is applied.
'collectedat': _utciso(self.collectedat),
'createdat': _utciso(self.createdat),
}
if includepayload:
data['payloadjson'] = self.payload

View File

@@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data):
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
# Ceiling on a type's board position. Wide enough to leave gaps between rows
# (10, 20, 30 ...) so inserting one later needs no renumbering.
_MAX_BOARD_ORDER = 999
# 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
@@ -164,6 +168,16 @@ def _apply_display_fields(t, data):
# Blank stores as NULL: "no category" is the absence of one, not the
# empty-string category that every uncategorised type would share.
t.boardcategory = category or None
if 'boardorder' in data:
raw = data.get('boardorder')
raw = 100 if raw in (None, '') else raw
try:
order = int(raw)
except (TypeError, ValueError):
return "boardorder must be a whole number"
if order < 0 or order > _MAX_BOARD_ORDER:
return "boardorder must be between 0 and %d" % _MAX_BOARD_ORDER
t.boardorder = order
if 'gracewindowminutes' in data:
raw = data.get('gracewindowminutes')
raw = 0 if raw in (None, '') else raw
@@ -185,11 +199,12 @@ 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|%d|%s" % (
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s|%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), t.boardcategory or '',
int(t.boardorder if t.boardorder is not None else 100),
)
for t in types
]
@@ -791,6 +806,7 @@ def get_shopfloor_notifications():
# blank gives the type a row of its own.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
'boardcategory': (ntype.boardcategory or '') if ntype else '',
'boardorder': int(ntype.boardorder if ntype and ntype.boardorder is not None else 100),
}
# Employee info (photo only when the type wants it)

View File

@@ -33,6 +33,7 @@
<span class="mono">{{ t.typecolor }}</span>
</td>
<td>
<span class="badge">#{{ t.boardorder ?? 100 }}</span>
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
</td>
@@ -90,6 +91,17 @@
</select>
</label>
<label class="field">
<span>Board order</span>
<input v-model.number="form.boardorder" type="number" min="0" max="999" step="10" />
<small class="muted">
Where this type's row sits on the shopfloor board, lowest first
(Recognition 10, Recertification 20, everything else 100). Leave
gaps so a new row can be slotted in without renumbering. Types
sharing a category take the lowest order among them.
</small>
</label>
<label class="field">
<span>Board category (optional)</span>
<input v-model="form.boardcategory" type="text" maxlength="50"
@@ -230,6 +242,7 @@ function openNew() {
expiryhour: null,
gracewindowminutes: 0,
boardcategory: '',
boardorder: 100,
isactive: true
}
editing.value = true
@@ -250,6 +263,7 @@ function openEdit(t) {
expiryhour: t.expiryhour ?? null,
gracewindowminutes: t.gracewindowminutes ?? 0,
boardcategory: t.boardcategory || '',
boardorder: t.boardorder ?? 100,
isactive: t.isactive !== false
}
editing.value = true

View File

@@ -0,0 +1,61 @@
"""Add notificationtypes.boardorder (row order on the shopfloor board).
Rows were ordered by display style and then type name, so what led the screen
was an accident of styling and the alphabet. A site decides its own running
order now: low first, and rows sharing a category sort by the lowest order
among their types.
Existing rows default to 100. Recognition and Recertification are seeded ahead
of that (10 and 20) so the out-of-the-box board keeps the order sites already
expect, without pinning anything a site has since renamed.
Idempotent; downgrade drops the column.
Revision ID: notifications0005boardorder
Revises: notifications0004category
"""
from alembic import op
import sqlalchemy as sa
revision = 'notifications0005boardorder'
down_revision = 'notifications0004category'
branch_labels = None
depends_on = None
_TABLE = 'notificationtypes'
_COLUMN = 'boardorder'
# Only the two types every site starts with; anything else keeps the default.
_SEEDED_ORDER = (('Recognition', 10), ('Recertification', 20))
def _column_names(insp, table):
return {c['name'] for c in insp.get_columns(table)}
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
if _COLUMN in _column_names(insp, _TABLE):
return
op.add_column(_TABLE, sa.Column(_COLUMN, sa.Integer(), nullable=False,
server_default='100'))
# Seed the shipped types' order. Guarded on the default so a site that has
# already set an order (re-running after a manual add) is left alone.
for typename, order in _SEEDED_ORDER:
bind.execute(
sa.text('UPDATE notificationtypes SET boardorder = :order '
'WHERE typename = :typename AND boardorder = 100'),
{'order': order, 'typename': typename})
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
if _COLUMN in _column_names(insp, _TABLE):
op.drop_column(_TABLE, _COLUMN)

View File

@@ -89,6 +89,13 @@ class NotificationType(db.Model):
# displaystyle - a banner and a tile row cannot be the same row.
boardcategory = db.Column(db.String(50), nullable=True)
# Where this type's row sits on the shopfloor board, low first. A site
# decides what leads the screen - recognition above recertification above
# the rest - rather than inheriting an alphabetical or by-style accident.
# Rows sharing a category sort by the lowest order among their types.
boardorder = db.Column(db.Integer, nullable=False,
server_default='100', default=100)
def __repr__(self):
return f"<NotificationType {self.typename}>"
@@ -107,7 +114,8 @@ class NotificationType(db.Model):
'showemployeephoto': bool(self.showemployeephoto),
'displaystyle': self.displaystyle or 'standard',
'gracewindowminutes': int(self.gracewindowminutes or 0),
'boardcategory': self.boardcategory or ''
'boardcategory': self.boardcategory or '',
'boardorder': int(self.boardorder if self.boardorder is not None else 100)
}

View File

@@ -68,7 +68,7 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0005boardorder'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -669,3 +669,44 @@ def test_a_kind_that_sets_emptytext_still_gets_it():
def test_base_kind_defaults_to_hiding_when_empty():
assert registry.BackupKind.emptytext is None
# =============================================================================
# Timestamp wire format
# =============================================================================
def test_timestamps_are_serialised_as_utc(bk_app, bk_plugin):
"""Naive ISO is parsed as BROWSER-LOCAL by JavaScript, silently shifting
every timestamp by the viewer's offset before any site-timezone formatting
runs. The wire format has to say the value is UTC."""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
result = bk_plugin.apply_collector_payload(
_payload(collectedat='2026-08-07T12:00:00Z'))
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
data = revision.to_dict()
assert data['collectedat'].endswith('Z')
assert data['createdat'].endswith('Z')
# 12:00Z stored naive-UTC, so it round-trips as the same wall clock.
assert data['collectedat'].startswith('2026-08-07T12:00:00')
def test_utciso_helper_passes_none_through():
from plugins.backups.models.backup import _utciso
assert _utciso(None) is None
def test_panel_label_is_rendered_in_the_site_zone(bk_app, bk_plugin):
"""The label is baked server-side, so the client cannot correct it later.
A UTC afternoon is the morning of the same day at West Jefferson."""
from shopdb.core.models import Setting
from plugins.backups.models import BackupRevision
from plugins.backups.api.routes import _label
with bk_app.app_context():
_db.session.add(Setting(key='site_timezone', value='America/New_York'))
_db.session.commit()
result = bk_plugin.apply_collector_payload(
_payload(collectedat='2026-08-07T16:30:00Z'))
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
# 16:30 UTC on 2026-08-07 is 12:30 EDT.
assert '12:30' in _label(revision)

View File

@@ -0,0 +1,106 @@
"""Tests for notificationtypes.boardorder (row order on the shopfloor board).
Rows used to run in display-style order and then alphabetically, so what led
the screen was an accident of styling and the alphabet. Each type now carries a
position, low first, and a shared category row sits wherever its
earliest-ordered type puts it.
The sort itself is frontend (ShopfloorDashboard.vue); pinned here is the
contract it sorts on - the feed carries boardorder per card, the type API
round-trips and validates it, and the migration seeds the shipped order.
"""
from plugins.notifications.models import Notification, NotificationType
def _make_type(db, typename, boardorder=100, displaystyle='grid', boardcategory=None):
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
displaystyle=displaystyle, boardcategory=boardcategory,
boardorder=boardorder)
db.session.add(t)
db.session.commit()
return t
def _make_note(db, ntype, text):
n = Notification(notificationtypeid=ntype.notificationtypeid,
notification=text, businessunitid=None,
isactive=True, isshopfloor=True)
db.session.add(n)
db.session.commit()
return n
def _current(client):
resp = client.get('/api/notifications/shopfloor')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data']['current']
def test_feed_carries_the_order_per_card(client, db):
ntype = _make_type(db, 'Recognition', boardorder=10)
_make_note(db, ntype, 'ten years')
assert _current(client)[0]['boardorder'] == 10
def test_a_type_defaults_to_the_back(client, db):
"""A new type lands behind the seeded rows rather than jumping the queue."""
ntype = NotificationType(typename='Fresh', typecolor='#17a2b8', isactive=True)
db.session.add(ntype)
db.session.commit()
assert ntype.boardorder == 100
def test_api_round_trips_the_order(client, db, auth_headers):
create = client.post('/api/notifications/types',
json={'typename': 'Awareness', 'boardorder': 30},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
assert create.get_json()['data']['boardorder'] == 30
updated = client.put(f'/api/notifications/types/{typeid}',
json={'boardorder': 40}, headers=auth_headers)
assert updated.get_json()['data']['boardorder'] == 40
def test_api_rejects_a_nonsense_order(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'boardorder': 'first'},
headers=auth_headers)
assert resp.status_code == 400
assert 'boardorder' in resp.get_data(as_text=True)
def test_api_rejects_an_out_of_range_order(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'boardorder': 1000},
headers=auth_headers)
assert resp.status_code == 400
def test_order_change_moves_the_config_version(client, db, auth_headers):
"""Open kiosks reload on a layout change; a re-ordered board is one."""
ntype = _make_type(db, 'Awareness')
before = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
client.put(f'/api/notifications/types/{ntype.notificationtypeid}',
json={'boardorder': 30}, headers=auth_headers)
after = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
assert before != after
def test_types_in_one_category_can_carry_different_orders(client, db):
"""The row takes the lowest; the feed still reports each type's own."""
change = _make_type(db, 'Change', boardorder=30, boardcategory='Current Events')
incident = _make_type(db, 'Incident', boardorder=50, boardcategory='Current Events')
_make_note(db, change, 'retooling')
_make_note(db, incident, 'press down')
orders = {c['typename']: c['boardorder'] for c in _current(client)}
assert orders == {'Change': 30, 'Incident': 50}