Colour every board row from its own types, and say what has not started yet
Two of the board's colours were still hardcoded: carousel headings were gold and grid headings blue, left over from when carousel meant Recognition and grid meant Recertification. A type wearing another type's colour is worse than no colour at all, and it was the whole of the "why did my category turn blue" puzzle - it had turned from one style's hardcoded colour to another's. Headings now take a colour from the types beneath them. Where a row holds several types, the one with the most cards wins, ties going to whichever sorts first on the board. Live cards decide it; the upcoming set only gets a say when nothing on the row has started yet, so a heading always describes what is happening now. That covers the standard rows too - the old getSectionClass painted the list red off a legacy 'danger' keyword. Upcoming cards say so plainly: STARTS TOMORROW 2:06 PM, or the weekday and date beyond that, on an amber chip, with a dashed border and a hollow carousel pip. Shape as well as colour, so it reads from across the floor and does not depend on telling amber from white. Cards and tiles also carry the type's colour as a bar down the left edge, the way the legacy board marked them; a 2px border reads as chrome at distance.
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