diff --git a/frontend/src/views/ShopfloorDashboard.vue b/frontend/src/views/ShopfloorDashboard.vue index d182cb9..3f58f6f 100644 --- a/frontend/src/views/ShopfloorDashboard.vue +++ b/frontend/src/views/ShopfloorDashboard.vue @@ -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) }" > - {{ stateLabel(n) }} + {{ stateLabel(n) }} {{ n.notification }} @@ -49,7 +49,6 @@
{{ group.typename }}
@@ -163,7 +161,7 @@
-
+
Current Notifications
@@ -198,7 +196,7 @@
-
Upcoming
+
Upcoming
{ // 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;