Board row ordering, type-derived row colours, and clearer upcoming events
This commit is contained in:
@@ -37,10 +37,10 @@
|
|||||||
v-for="n in group.items"
|
v-for="n in group.items"
|
||||||
:key="n.notificationid"
|
:key="n.notificationid"
|
||||||
class="banner-strip"
|
class="banner-strip"
|
||||||
:class="{ pending: n.upcoming || n.resolved }"
|
:class="[stateKind(n) ? 'pending' : '', stateKind(n)]"
|
||||||
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
|
: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 }}
|
{{ n.notification }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -49,7 +49,6 @@
|
|||||||
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
|
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
|
||||||
<div
|
<div
|
||||||
class="section-title recognition"
|
class="section-title recognition"
|
||||||
:class="{ mixed: isMixed(group) }"
|
|
||||||
:style="headingStyle(group)"
|
:style="headingStyle(group)"
|
||||||
>{{ group.typename }}</div>
|
>{{ group.typename }}</div>
|
||||||
<div class="recognition-carousel">
|
<div class="recognition-carousel">
|
||||||
@@ -57,7 +56,7 @@
|
|||||||
v-for="(rec, idx) in group.items"
|
v-for="(rec, idx) in group.items"
|
||||||
:key="`${rec.notificationid}-${rec.employeesso}`"
|
:key="`${rec.notificationid}-${rec.employeesso}`"
|
||||||
class="recognition-card"
|
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) }"
|
:style="{ '--accent': getTypeColor(rec.typecolor) }"
|
||||||
>
|
>
|
||||||
<!-- A type that names no employee is a message, not a person:
|
<!-- A type that names no employee is a message, not a person:
|
||||||
@@ -91,7 +90,7 @@
|
|||||||
class="type-chip"
|
class="type-chip"
|
||||||
:style="{ backgroundColor: getTypeColor(rec.typecolor), color: textOn(rec.typecolor) }"
|
:style="{ backgroundColor: getTypeColor(rec.typecolor), color: textOn(rec.typecolor) }"
|
||||||
>{{ rec.typename }}</span>
|
>{{ 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>
|
||||||
<div class="recognition-message">{{ rec.notification }}</div>
|
<div class="recognition-message">{{ rec.notification }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,7 +103,7 @@
|
|||||||
v-for="(rec, idx) in group.items"
|
v-for="(rec, idx) in group.items"
|
||||||
:key="`pip-${rec.notificationid}-${rec.employeesso}`"
|
:key="`pip-${rec.notificationid}-${rec.employeesso}`"
|
||||||
class="pip"
|
class="pip"
|
||||||
:class="{ on: idx === carouselIndex(group) }"
|
:class="{ on: idx === carouselIndex(group), scheduled: rec.upcoming }"
|
||||||
:style="{ backgroundColor: getTypeColor(rec.typecolor) }"
|
:style="{ backgroundColor: getTypeColor(rec.typecolor) }"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +113,6 @@
|
|||||||
<section v-else class="recert-section">
|
<section v-else class="recert-section">
|
||||||
<div
|
<div
|
||||||
class="section-title recert-title"
|
class="section-title recert-title"
|
||||||
:class="{ mixed: isMixed(group) }"
|
|
||||||
:style="headingStyle(group)"
|
:style="headingStyle(group)"
|
||||||
>
|
>
|
||||||
<span>{{ group.typename }} ({{ group.items.length }})</span>
|
<span>{{ group.typename }} ({{ group.items.length }})</span>
|
||||||
@@ -132,7 +130,7 @@
|
|||||||
v-for="rec in gridPageItems(group)"
|
v-for="rec in gridPageItems(group)"
|
||||||
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
|
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
|
||||||
class="recert-tile"
|
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) }"
|
:style="{ '--accent': getTypeColor(rec.typecolor) }"
|
||||||
>
|
>
|
||||||
<template v-if="hasEmployee(rec)">
|
<template v-if="hasEmployee(rec)">
|
||||||
@@ -154,7 +152,7 @@
|
|||||||
<!-- No employee: the message IS the tile, so a grid type like
|
<!-- No employee: the message IS the tile, so a grid type like
|
||||||
Awareness does not render rows of blank placeholder faces. -->
|
Awareness does not render rows of blank placeholder faces. -->
|
||||||
<div v-else class="recert-message">{{ rec.notification }}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -163,7 +161,7 @@
|
|||||||
|
|
||||||
<!-- Current Notifications -->
|
<!-- Current Notifications -->
|
||||||
<section v-if="currentNotifications.length" class="notifications-section">
|
<section v-if="currentNotifications.length" class="notifications-section">
|
||||||
<div class="section-title" :class="getSectionClass(currentNotifications)">
|
<div class="section-title" :style="itemsHeadingStyle(currentNotifications)">
|
||||||
Current Notifications
|
Current Notifications
|
||||||
</div>
|
</div>
|
||||||
<div class="events-list">
|
<div class="events-list">
|
||||||
@@ -198,7 +196,7 @@
|
|||||||
|
|
||||||
<!-- Upcoming Notifications -->
|
<!-- Upcoming Notifications -->
|
||||||
<section v-if="upcomingNotifications.length" class="notifications-section">
|
<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 class="events-list">
|
||||||
<div
|
<div
|
||||||
v-for="n in upcomingNotifications"
|
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.
|
// type set to carousel/grid/banner renders that way - not just the built-ins.
|
||||||
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
|
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
|
||||||
|
|
||||||
// Sections render in style order, so the board keeps its shape as types come
|
// Rows run in the order each type is given (boardorder, low first), so a site
|
||||||
// and go: strips at the top, then rotating cards, then tile rows.
|
// 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 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,
|
// One row per TYPE, unless types opt into a shared boardcategory - Change,
|
||||||
// Awareness and Incident under one "Alerts" heading, say, while Recognition and
|
// 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
|
// category is one heading, so it can only carry one colour; the
|
||||||
// individual cards below still wear their own type's colour.
|
// individual cards below still wear their own type's colour.
|
||||||
typecolor: n.typecolor,
|
typecolor: n.typecolor,
|
||||||
|
// A shared row sits wherever its earliest-ordered type puts it.
|
||||||
|
boardorder: DEFAULT_BOARD_ORDER,
|
||||||
items: []
|
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.
|
// Live cards lead each row; what has not started yet follows.
|
||||||
for (const group of groups.values()) {
|
for (const group of groups.values()) {
|
||||||
group.items.sort((a, b) => Number(a.upcoming) - Number(b.upcoming))
|
group.items.sort((a, b) => Number(a.upcoming) - Number(b.upcoming))
|
||||||
}
|
}
|
||||||
return [...groups.values()].sort((a, b) =>
|
return [...groups.values()].sort((a, b) =>
|
||||||
|
(a.boardorder - b.boardorder) ||
|
||||||
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
|
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
|
||||||
a.typename.localeCompare(b.typename)
|
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
|
// 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.
|
// tail a type's grace window buys it; upcoming is scheduled and not yet showing.
|
||||||
function stateLabel(item) {
|
function stateLabel(item) {
|
||||||
if (item.upcoming) return `UPCOMING ${formatDateTime(item.starttime)}`
|
if (item.upcoming) return `STARTS ${startsWhen(item.starttime)}`
|
||||||
return item.resolved ? 'RESOLVED' : ''
|
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
|
// A row holding more than one type: the heading names a category, so it cannot
|
||||||
// speak for any single type's colour or name.
|
// speak for any single type's colour or name.
|
||||||
function isMixed(group) {
|
function isMixed(group) {
|
||||||
return new Set(group.items.map(item => item.typename)).size > 1
|
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
|
// The colour a set of cards weighs most heavily towards: whichever type has
|
||||||
// heading, since picking the first type's colour just mislabels the rest.
|
// 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) {
|
function headingStyle(group) {
|
||||||
if (isMixed(group)) return {}
|
return itemsHeadingStyle(group.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemsHeadingStyle(items) {
|
||||||
|
const typecolor = dominantColor(items)
|
||||||
return {
|
return {
|
||||||
backgroundColor: getTypeColor(group.typecolor),
|
backgroundColor: getTypeColor(typecolor),
|
||||||
color: textOn(group.typecolor)
|
color: textOn(typecolor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,12 +604,6 @@ function textOn(backgroundcolor) {
|
|||||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 150 ? '#231b00' : '#fff'
|
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) {
|
function formatTime(dateStr) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||||
@@ -685,13 +743,12 @@ function handlePhotoError(e) {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
margin-bottom: 15px;
|
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;
|
background: #4181ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title.recognition {
|
.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
|
/* Recertification grid - blue, compact tiles so 20-30 people are all visible
|
||||||
@@ -700,9 +757,6 @@ function handlePhotoError(e) {
|
|||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
}
|
}
|
||||||
.section-title.recert-title {
|
.section-title.recert-title {
|
||||||
/* Fallback only - the real colour is the type's, set inline. */
|
|
||||||
background: #0d6efd;
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -727,10 +781,12 @@ function handlePhotoError(e) {
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
.recert-tile {
|
.recert-tile {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
background: linear-gradient(135deg, #16233b 0%, #0d2137 100%);
|
background: linear-gradient(135deg, #16233b 0%, #0d2137 100%);
|
||||||
border: 2px solid var(--accent, #0d6efd);
|
border: 2px solid var(--accent, #0d6efd);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px 10px;
|
padding: 14px 10px 14px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -763,20 +819,7 @@ function handlePhotoError(e) {
|
|||||||
overflow: hidden;
|
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 {
|
.section-title.upcoming {
|
||||||
background: #6c757d;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Recognition carousel */
|
/* Recognition carousel */
|
||||||
@@ -843,6 +886,30 @@ function handlePhotoError(e) {
|
|||||||
opacity: 0.72;
|
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 {
|
.state-chip {
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -871,7 +938,25 @@ function handlePhotoError(e) {
|
|||||||
margin-bottom: 8px;
|
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 {
|
.recognition-card {
|
||||||
|
overflow: hidden;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -879,7 +964,7 @@ function handlePhotoError(e) {
|
|||||||
background: linear-gradient(135deg, #2b2718 0%, #1d1a10 100%);
|
background: linear-gradient(135deg, #2b2718 0%, #1d1a10 100%);
|
||||||
border: 3px solid var(--accent, #ffc107);
|
border: 3px solid var(--accent, #ffc107);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px 25px;
|
padding: 20px 25px 20px 39px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 25px;
|
gap: 25px;
|
||||||
@@ -921,18 +1006,6 @@ function handlePhotoError(e) {
|
|||||||
margin-bottom: 10px;
|
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 {
|
.recognition-name {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ because requiring the app server to mount the SFLD share would turn a
|
|||||||
permissions slip into an unexplained empty download.
|
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 import Blueprint, current_app, request, Response
|
||||||
from flask_jwt_extended import jwt_required
|
from flask_jwt_extended import jwt_required
|
||||||
|
|
||||||
@@ -22,15 +25,42 @@ from shopdb.api import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..models import BackupRevision
|
from ..models import BackupRevision
|
||||||
|
from ..models.backup import _utciso
|
||||||
from ..services.registry import REGISTRY, getkind
|
from ..services.registry import REGISTRY, getkind
|
||||||
|
|
||||||
backups_bp = Blueprint('backups', __name__)
|
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):
|
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
|
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:
|
if revision.sourcefilename:
|
||||||
return '{} - {}'.format(stamp, revision.sourcefilename)
|
return '{} - {}'.format(stamp, revision.sourcefilename)
|
||||||
return stamp
|
return stamp
|
||||||
@@ -129,8 +159,7 @@ def asset_info(assetid):
|
|||||||
revision.payload, assetid,
|
revision.payload, assetid,
|
||||||
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
|
partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES'))
|
||||||
data['backuprevisionid'] = revision.backuprevisionid
|
data['backuprevisionid'] = revision.backuprevisionid
|
||||||
data['collectedat'] = (revision.collectedat.isoformat()
|
data['collectedat'] = _utciso(revision.collectedat)
|
||||||
if revision.collectedat else None)
|
|
||||||
return success_response(data)
|
return success_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -111,11 +112,13 @@ const diffs = ref({})
|
|||||||
const assetname = ref('')
|
const assetname = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
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) {
|
function formatWhen(value) {
|
||||||
if (!value) return 'unknown'
|
if (!value) return 'unknown'
|
||||||
const d = new Date(value)
|
return formatInZone(value, siteTimezone.value, { second: '2-digit' }) || value
|
||||||
return isNaN(d) ? value : d.toLocaleString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function display(value) {
|
function display(value) {
|
||||||
@@ -124,6 +127,16 @@ function display(value) {
|
|||||||
return String(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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
@@ -183,7 +196,10 @@ async function download(rev, fmt) {
|
|||||||
URL.revokeObjectURL(link.href)
|
URL.revokeObjectURL(link.href)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(async () => {
|
||||||
|
await loadTimezone()
|
||||||
|
await load()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ from datetime import datetime
|
|||||||
from shopdb.api import db
|
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):
|
class BackupRevision(db.Model):
|
||||||
"""A single point-in-time configuration snapshot of an asset."""
|
"""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.
|
# download <machinenumber>.reg without a second round trip.
|
||||||
'assetnumber': self.asset.assetnumber if self.asset else None,
|
'assetnumber': self.asset.assetnumber if self.asset else None,
|
||||||
'sourcehostname': self.sourcehostname,
|
'sourcehostname': self.sourcehostname,
|
||||||
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
|
# Both columns hold NAIVE UTC (see collectedat above), so the wire
|
||||||
'createdat': self.createdat.isoformat() if self.createdat else None,
|
# 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:
|
if includepayload:
|
||||||
data['payloadjson'] = self.payload
|
data['payloadjson'] = self.payload
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ def _apply_expiry_fields(t, data):
|
|||||||
|
|
||||||
_DISPLAY_STYLES = ('standard', 'carousel', 'grid', 'banner')
|
_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
|
# 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.
|
# "recently ended"; anything more is an end time that should have been later.
|
||||||
_MAX_GRACE_MINUTES = 1440
|
_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
|
# Blank stores as NULL: "no category" is the absence of one, not the
|
||||||
# empty-string category that every uncategorised type would share.
|
# empty-string category that every uncategorised type would share.
|
||||||
t.boardcategory = category or None
|
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:
|
if 'gracewindowminutes' in data:
|
||||||
raw = data.get('gracewindowminutes')
|
raw = data.get('gracewindowminutes')
|
||||||
raw = 0 if raw in (None, '') else raw
|
raw = 0 if raw in (None, '') else raw
|
||||||
@@ -185,11 +199,12 @@ def _config_version():
|
|||||||
reach pages that are already open."""
|
reach pages that are already open."""
|
||||||
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
|
||||||
parts = [
|
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,
|
t.notificationtypeid, t.typecolor, t.displaystyle,
|
||||||
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
|
||||||
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
|
||||||
int(t.gracewindowminutes or 0), t.boardcategory or '',
|
int(t.gracewindowminutes or 0), t.boardcategory or '',
|
||||||
|
int(t.boardorder if t.boardorder is not None else 100),
|
||||||
)
|
)
|
||||||
for t in types
|
for t in types
|
||||||
]
|
]
|
||||||
@@ -791,6 +806,7 @@ def get_shopfloor_notifications():
|
|||||||
# blank gives the type a row of its own.
|
# blank gives the type a row of its own.
|
||||||
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
|
||||||
'boardcategory': (ntype.boardcategory or '') if ntype else '',
|
'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)
|
# Employee info (photo only when the type wants it)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
<span class="mono">{{ t.typecolor }}</span>
|
<span class="mono">{{ t.typecolor }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
<span class="badge">#{{ t.boardorder ?? 100 }}</span>
|
||||||
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
|
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
|
||||||
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
|
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -90,6 +91,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="field">
|
||||||
<span>Board category (optional)</span>
|
<span>Board category (optional)</span>
|
||||||
<input v-model="form.boardcategory" type="text" maxlength="50"
|
<input v-model="form.boardcategory" type="text" maxlength="50"
|
||||||
@@ -230,6 +242,7 @@ function openNew() {
|
|||||||
expiryhour: null,
|
expiryhour: null,
|
||||||
gracewindowminutes: 0,
|
gracewindowminutes: 0,
|
||||||
boardcategory: '',
|
boardcategory: '',
|
||||||
|
boardorder: 100,
|
||||||
isactive: true
|
isactive: true
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
@@ -250,6 +263,7 @@ function openEdit(t) {
|
|||||||
expiryhour: t.expiryhour ?? null,
|
expiryhour: t.expiryhour ?? null,
|
||||||
gracewindowminutes: t.gracewindowminutes ?? 0,
|
gracewindowminutes: t.gracewindowminutes ?? 0,
|
||||||
boardcategory: t.boardcategory || '',
|
boardcategory: t.boardcategory || '',
|
||||||
|
boardorder: t.boardorder ?? 100,
|
||||||
isactive: t.isactive !== false
|
isactive: t.isactive !== false
|
||||||
}
|
}
|
||||||
editing.value = true
|
editing.value = true
|
||||||
|
|||||||
61
plugins/notifications/migrations/versions/0005_boardorder.py
Normal file
61
plugins/notifications/migrations/versions/0005_boardorder.py
Normal 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)
|
||||||
@@ -89,6 +89,13 @@ class NotificationType(db.Model):
|
|||||||
# displaystyle - a banner and a tile row cannot be the same row.
|
# displaystyle - a banner and a tile row cannot be the same row.
|
||||||
boardcategory = db.Column(db.String(50), nullable=True)
|
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):
|
def __repr__(self):
|
||||||
return f"<NotificationType {self.typename}>"
|
return f"<NotificationType {self.typename}>"
|
||||||
|
|
||||||
@@ -107,7 +114,8 @@ class NotificationType(db.Model):
|
|||||||
'showemployeephoto': bool(self.showemployeephoto),
|
'showemployeephoto': bool(self.showemployeephoto),
|
||||||
'displaystyle': self.displaystyle or 'standard',
|
'displaystyle': self.displaystyle or 'standard',
|
||||||
'gracewindowminutes': int(self.gracewindowminutes or 0),
|
'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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
|
|||||||
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
|
||||||
# notifications indexes businessunitid, then adds the per-type grace window and
|
# notifications indexes businessunitid, then adds the per-type grace window and
|
||||||
# the shared board category.
|
# 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
|
# Plugins built after the cutover: their 0001 baseline really creates tables the
|
||||||
# core chain never owned.
|
# core chain never owned.
|
||||||
|
|||||||
@@ -669,3 +669,44 @@ def test_a_kind_that_sets_emptytext_still_gets_it():
|
|||||||
|
|
||||||
def test_base_kind_defaults_to_hiding_when_empty():
|
def test_base_kind_defaults_to_hiding_when_empty():
|
||||||
assert registry.BackupKind.emptytext is None
|
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)
|
||||||
|
|||||||
106
tests/test_plugins/test_board_order.py
Normal file
106
tests/test_plugins/test_board_order.py
Normal 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}
|
||||||
Reference in New Issue
Block a user