shopfloor dashboard: show upcoming cards, state chips, mixed-row headings
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 6s

Scheduled notifications with a special display style rendered nowhere at all.
styledGroups only read notifications.current, so anything upcoming fell through
to the standard Upcoming list, which drops banner/carousel/grid styles. A
scheduled banner was therefore invisible until the moment it went live. Upcoming
items now join the styled groups, tagged so they can be told apart, and each row
sorts live cards ahead of not-yet-started ones.

State chips name what a card is doing: UPCOMING with its start time, or
RESOLVED for the tail a type's grace window buys it. Pending cards also dim to
0.72 so a glance never mistakes one for live. The dim rule is scoped to
.recognition-card.active.pending on purpose: inactive carousel cards sit at
opacity 0, and an unscoped .pending outranks that, which stacks the whole
carousel back into view at once.

Rows keyed by board category can hold more than one type, and the heading then
speaks for none of them. Such rows keep a neutral heading instead of wearing
the first type's colour, and each card carries its own type chip. The chip is
named rather than only coloured, since colour alone does not survive a
colourblind reader at board distance.

Carousel rows gain one pip per card, so a reader can see how many are in the
rotation and how long until theirs returns.
This commit is contained in:
cproudlock
2026-08-07 14:28:01 -04:00
parent fca775c737
commit a10e695d7c

View File

@@ -37,8 +37,10 @@
v-for="n in group.items"
:key="n.notificationid"
class="banner-strip"
:class="{ pending: n.upcoming || n.resolved }"
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
>
<span v-if="stateLabel(n)" class="state-chip">{{ stateLabel(n) }}</span>
{{ n.notification }}
</div>
</section>
@@ -47,14 +49,15 @@
<section v-else-if="group.displaystyle === 'carousel'" class="recognition-section">
<div
class="section-title recognition"
:style="{ backgroundColor: getTypeColor(group.typecolor), color: textOn(group.typecolor) }"
:class="{ mixed: isMixed(group) }"
:style="headingStyle(group)"
>{{ group.typename }}</div>
<div class="recognition-carousel">
<div
v-for="(rec, idx) in group.items"
:key="`${rec.notificationid}-${rec.employeesso}`"
class="recognition-card"
:class="{ active: idx === carouselIndex(group) }"
:class="{ active: idx === carouselIndex(group), pending: rec.upcoming || rec.resolved }"
:style="{ '--accent': getTypeColor(rec.typecolor) }"
>
<!-- A type that names no employee is a message, not a person:
@@ -78,17 +81,41 @@
<div v-if="hasEmployee(rec)" class="recognition-header">
<div class="recognition-name">{{ rec.employeename }}</div>
</div>
<!-- On a shared category row the heading names the category, so
the card has to say which type it is. Named, not just
coloured: colour alone is no use to a colourblind reader
across a 1920 board. -->
<div class="chip-row">
<span
v-if="isMixed(group)"
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>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
</div>
</div>
</div>
<!-- One pip per card, so a reader knows how many are in the rotation
and how long until theirs comes back round. -->
<div v-if="group.items.length > 1" class="carousel-pips">
<span
v-for="(rec, idx) in group.items"
:key="`pip-${rec.notificationid}-${rec.employeesso}`"
class="pip"
:class="{ on: idx === carouselIndex(group) }"
:style="{ backgroundColor: getTypeColor(rec.typecolor) }"
></span>
</div>
</section>
<!-- Grid - a row of tiles, cycling a page at a time -->
<section v-else class="recert-section">
<div
class="section-title recert-title"
:style="{ backgroundColor: getTypeColor(group.typecolor), color: textOn(group.typecolor) }"
:class="{ mixed: isMixed(group) }"
:style="headingStyle(group)"
>
<span>{{ group.typename }} ({{ group.items.length }})</span>
<span v-if="gridRangeLabel(group)" class="recert-range">{{ gridRangeLabel(group) }}</span>
@@ -105,7 +132,7 @@
v-for="rec in gridPageItems(group)"
:key="`grid-${rec.notificationid}-${rec.employeesso}`"
class="recert-tile"
:class="{ 'message-tile': !hasEmployee(rec) }"
:class="{ 'message-tile': !hasEmployee(rec), pending: rec.upcoming || rec.resolved }"
:style="{ '--accent': getTypeColor(rec.typecolor) }"
>
<template v-if="hasEmployee(rec)">
@@ -127,6 +154,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>
</div>
</div>
</section>
@@ -264,7 +292,14 @@ const STYLE_ORDER = { banner: 0, carousel: 1, grid: 2 }
// row, so same-category types of different styles still render separately.
const styledGroups = computed(() => {
const groups = new Map()
for (const n of notifications.value.current) {
// Upcoming cards belong here too. They used to be filtered into the standard
// Upcoming list, which drops anything with a special style - so a scheduled
// banner or carousel card rendered nowhere at all until it went live.
const tagged = [
...notifications.value.current.map(n => ({ ...n, upcoming: false })),
...notifications.value.upcoming.map(n => ({ ...n, upcoming: true }))
]
for (const n of tagged) {
if (!SPECIAL_STYLES.includes(n.displaystyle)) continue
const heading = (n.boardcategory || '').trim() || n.typename || n.displaystyle
const key = `${n.displaystyle}|${heading}`
@@ -282,6 +317,10 @@ const styledGroups = computed(() => {
}
groups.get(key).items.push(n)
}
// 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) =>
(STYLE_ORDER[a.displaystyle] - STYLE_ORDER[b.displaystyle]) ||
a.typename.localeCompare(b.typename)
@@ -465,6 +504,29 @@ function getTypeColor(typecolor) {
return aliases[typecolor] || typecolor || '#14abef'
}
// 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)}`
return item.resolved ? 'RESOLVED' : ''
}
// 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.
function headingStyle(group) {
if (isMixed(group)) return {}
return {
backgroundColor: getTypeColor(group.typecolor),
color: textOn(group.typecolor)
}
}
// Text colour for a heading painted an arbitrary type colour. Gold needs dark
// text, navy needs white, and a site picks its own hexes - so this is computed
// rather than a per-colour rule.
@@ -701,6 +763,14 @@ 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;
}
@@ -732,9 +802,75 @@ function handlePhotoError(e) {
.recognition-carousel {
position: relative;
/* Enough for a photo card; a message-only card is shorter and leaves the
rest as breathing room rather than resizing the board every 8 seconds. */
min-height: 170px;
}
.carousel-pips {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 10px;
}
.pip {
width: 14px;
height: 14px;
border-radius: 50%;
opacity: 0.35;
transition: opacity 0.4s ease, transform 0.4s ease;
}
.pip.on {
opacity: 1;
transform: scale(1.25);
}
.chip-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
/* Scheduled or just-ended cards stay legible but visibly quieter than live
ones, so a glance at the board never mistakes one for the other. Carousel
cards are dimmed only while ACTIVE: the inactive ones are held at opacity 0
by .recognition-card, and a plain .pending rule outranks it, which brought
the whole stack back into view on top of each other. */
.recognition-card.active.pending,
.recert-tile.pending,
.banner-strip.pending {
opacity: 0.72;
}
.state-chip {
padding: 4px 14px;
border-radius: 999px;
font-size: 18px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.16);
border: 2px solid rgba(255, 255, 255, 0.45);
color: #fff;
white-space: nowrap;
}
.state-chip.tile-state {
font-size: 14px;
padding: 2px 10px;
margin-top: 8px;
}
.type-chip {
padding: 4px 14px;
border-radius: 999px;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 8px;
}
.recognition-card {
position: absolute;
top: 0;
@@ -774,6 +910,8 @@ function handlePhotoError(e) {
}
.recognition-content {
display: flex;
flex-direction: column;
flex: 1;
}