{
// 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;