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

@@ -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)
}