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"
|
||||
: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;
|
||||
|
||||
Reference in New Issue
Block a user