Add search result type filters and fix dashboard caching

- Add granular filter buttons for search results: Equipment, PCs, Servers,
  Switches, Access Points (instead of single "Machines" filter)
- Fix network devices showing as "Machine" - now shows actual type (Server,
  Switch, Access Point, etc.) with appropriate icons
- Add cache-busting timestamp to shopfloor dashboard API fetch to prevent
  stale data from browser/proxy caching
- Fix search results page width (col-lg-auto to col-12)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-23 11:25:47 -05:00
parent 1b7946900c
commit 365d18d334
2 changed files with 644 additions and 151 deletions

View File

@@ -262,7 +262,7 @@ Set rs = Nothing
<div class="clearfix"></div>
<div class="content-wrapper">
<div class="row">
<div class="col-lg-auto">
<div class="col-12">
<div class="card">
<div class="card-body">
<ul class="nav nav-tabs nav-tabs-primary top-icon nav-justified">
@@ -319,6 +319,44 @@ Set rs = Nothing
<h5 class="card-title">Search Results</h5>
<!-- Result Type Filter -->
<div id="resultFilters" class="result-filters" style="display:none; margin-bottom: 15px;">
<span style="font-weight: 600; margin-right: 10px; color: #888;">Filter by:</span>
<button class="filter-btn active" data-filter="all" onclick="filterResults('all')">
<i class="zmdi zmdi-view-list"></i> All <span class="filter-count" id="count-all"></span>
</button>
<button class="filter-btn" data-filter="app" onclick="filterResults('app')" style="display:none;">
<i class="zmdi zmdi-apps"></i> Applications <span class="filter-count" id="count-app"></span>
</button>
<button class="filter-btn" data-filter="kb" onclick="filterResults('kb')" style="display:none;">
<i class="zmdi zmdi-book"></i> KB Articles <span class="filter-count" id="count-kb"></span>
</button>
<button class="filter-btn" data-filter="equipment" onclick="filterResults('equipment')" style="display:none;">
<i class="zmdi zmdi-reader"></i> Equipment <span class="filter-count" id="count-equipment"></span>
</button>
<button class="filter-btn" data-filter="pc" onclick="filterResults('pc')" style="display:none;">
<i class="zmdi zmdi-desktop-windows"></i> PCs <span class="filter-count" id="count-pc"></span>
</button>
<button class="filter-btn" data-filter="server" onclick="filterResults('server')" style="display:none;">
<i class="zmdi zmdi-dns"></i> Servers <span class="filter-count" id="count-server"></span>
</button>
<button class="filter-btn" data-filter="switch" onclick="filterResults('switch')" style="display:none;">
<i class="zmdi zmdi-device-hub"></i> Switches <span class="filter-count" id="count-switch"></span>
</button>
<button class="filter-btn" data-filter="accesspoint" onclick="filterResults('accesspoint')" style="display:none;">
<i class="zmdi zmdi-wifi"></i> Access Points <span class="filter-count" id="count-accesspoint"></span>
</button>
<button class="filter-btn" data-filter="printer" onclick="filterResults('printer')" style="display:none;">
<i class="zmdi zmdi-print"></i> Printers <span class="filter-count" id="count-printer"></span>
</button>
<button class="filter-btn" data-filter="employee" onclick="filterResults('employee')" style="display:none;">
<i class="zmdi zmdi-account"></i> Employees <span class="filter-count" id="count-employee"></span>
</button>
<button class="filter-btn" data-filter="notification" onclick="filterResults('notification')" style="display:none;">
<i class="zmdi zmdi-notifications-none"></i> Notifications <span class="filter-count" id="count-notification"></span>
</button>
</div>
<%
' Show educational banner if this is a shared link
If isShared And search <> "" Then
@@ -488,11 +526,13 @@ Set rsNotif = Nothing
' Now get Machines (by machine number, alias, notes, machine type, or vendor)
' Also search by hostname for PCs
' Note: Join machinetypes directly to machines.machinetypeid (not through models)
' This ensures network devices show correct type (Firewall, Switch, etc.) instead of "Machine"
Dim rsMachines
strSQL = "SELECT m.machineid, m.machinenumber, m.alias, m.hostname, m.pctypeid, mt.machinetype " & _
"FROM machines m " & _
"LEFT JOIN machinetypes mt ON m.machinetypeid = mt.machinetypeid " & _
"LEFT JOIN models mo ON m.modelnumberid = mo.modelnumberid " & _
"LEFT JOIN machinetypes mt ON mo.machinetypeid = mt.machinetypeid " & _
"LEFT JOIN vendors v ON mo.vendorid = v.vendorid " & _
"WHERE (m.machinenumber LIKE ? OR m.alias LIKE ? OR m.machinenotes LIKE ? OR mt.machinetype LIKE ? OR v.vendor LIKE ? OR m.hostname LIKE ?) " & _
" AND m.isactive = 1 " & _
@@ -707,7 +747,7 @@ Next
rowStyle = ""
End If
Response.Write("<tr id='" & rowId & "'" & rowStyle & ">")
Response.Write("<tr id='" & rowId & "' data-type='app'" & rowStyle & ">")
' Column 1: Empty for apps
Response.Write("<td>&nbsp;</td>")
@@ -740,7 +780,7 @@ Next
rowStyle = ""
End If
Response.Write("<tr id='" & rowId & "'" & rowStyle & ">")
Response.Write("<tr id='" & rowId & "' data-type='kb'" & rowStyle & ">")
' Column 1: Edit icon
Response.Write("<td><a href='./editlink.asp?linkid=" & linkid & "'><i class='zmdi zmdi-edit' title='Edit This Link'></i></a></td>")
@@ -773,7 +813,7 @@ Next
rowStyle = ""
End If
Response.Write("<tr id='" & rowId & "'" & rowStyle & ">")
Response.Write("<tr id='" & rowId & "' data-type='notification'" & rowStyle & ">")
' Column 1: Empty for notifications
Response.Write("<td>&nbsp;</td>")
@@ -802,19 +842,56 @@ Next
Dim machineIsPC, machineDetailPage, machineListPage, machineIcon, machineIconColor, machineLabel
machineIsPC = (UBound(dataFields) >= 3 And dataFields(3) = "True")
' Set page, icon and label based on PC vs Equipment
' Set page, icon, label and filter data-type based on PC vs Equipment vs Network Device
Dim machineDataType
If machineIsPC Then
machineDetailPage = "displaypc.asp"
machineListPage = "displaypcs.asp"
machineIcon = "zmdi-desktop-windows"
machineIconColor = "text-primary"
machineLabel = "PC"
machineDataType = "pc"
Else
machineDetailPage = "displaymachine.asp"
machineListPage = "displaymachines.asp"
machineIcon = "zmdi-reader"
machineIconColor = "text-warning"
machineLabel = "Machine"
' Use actual machine type from database for label (Server, Switch, etc.)
' Fall back to "Machine" if not available
If Not IsNull(machineType) And machineType <> "" Then
machineLabel = machineType
Else
machineLabel = "Machine"
End If
' Set icon and data-type based on machine type
Select Case LCase(machineLabel)
Case "server"
machineIcon = "zmdi-dns"
machineIconColor = "text-danger"
machineDataType = "server"
Case "switch"
machineIcon = "zmdi-device-hub"
machineIconColor = "text-info"
machineDataType = "switch"
Case "access point"
machineIcon = "zmdi-wifi"
machineIconColor = "text-success"
machineDataType = "accesspoint"
Case "firewall"
machineIcon = "zmdi-shield-security"
machineIconColor = "text-warning"
machineDataType = "equipment"
Case "idf"
machineIcon = "zmdi-input-composite"
machineIconColor = "text-secondary"
machineDataType = "equipment"
Case "camera"
machineIcon = "zmdi-videocam"
machineIconColor = "text-primary"
machineDataType = "equipment"
Case Else
machineIcon = "zmdi-reader"
machineIconColor = "text-warning"
machineDataType = "equipment"
End Select
End If
rowId = "machine-result-" & machineId
@@ -824,7 +901,7 @@ Next
rowStyle = ""
End If
Response.Write("<tr id='" & rowId & "'" & rowStyle & ">")
Response.Write("<tr id='" & rowId & "' data-type='" & machineDataType & "'" & rowStyle & ">")
' Column 1: Empty for machines
Response.Write("<td>&nbsp;</td>")
@@ -855,7 +932,7 @@ Next
rowStyle = ""
End If
Response.Write("<tr id='" & rowId & "'" & rowStyle & ">")
Response.Write("<tr id='" & rowId & "' data-type='printer'" & rowStyle & ">")
' Column 1: Empty for printers
Response.Write("<td>&nbsp;</td>")
@@ -892,7 +969,7 @@ Next
rowStyle = ""
End If
Response.Write("<tr id='" & rowId & "'" & rowStyle & ">")
Response.Write("<tr id='" & rowId & "' data-type='employee'" & rowStyle & ">")
' Column 1: Empty for employees
Response.Write("<td>&nbsp;</td>")
@@ -960,6 +1037,44 @@ Next
<script src="assets/js/app-script.js"></script>
<style>
/* Result type filter buttons */
.result-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.filter-btn {
padding: 6px 14px;
font-size: 13px;
border: 1px solid rgba(102, 126, 234, 0.3);
border-radius: 20px;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
cursor: pointer;
transition: all 0.2s ease;
}
.filter-btn:hover {
background: rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.5);
}
.filter-btn.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-color: transparent;
}
.filter-btn i {
margin-right: 5px;
}
.filter-count {
font-size: 11px;
opacity: 0.8;
margin-left: 4px;
}
.filter-btn.active .filter-count {
opacity: 1;
}
/* Highlighter effect - works in both dark and light themes */
.highlighted-result {
background-color: rgba(255, 235, 59, 0.25) !important; /* Semi-transparent yellow */
@@ -1182,8 +1297,88 @@ function showToast() {
}, 3500);
}
// Filter results by type
function filterResults(type) {
var rows = document.querySelectorAll('#searchResults tbody tr[data-type]');
var filterBar = document.getElementById('resultFilters');
var buttons = filterBar.querySelectorAll('.filter-btn');
// Update active button
buttons.forEach(function(btn) {
btn.classList.remove('active');
if (btn.getAttribute('data-filter') === type) {
btn.classList.add('active');
}
});
// Show/hide rows based on type
rows.forEach(function(row) {
if (type === 'all' || row.getAttribute('data-type') === type) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
// Initialize filter counts and show filter bar if multiple types
function initializeFilters() {
var rows = document.querySelectorAll('#searchResults tbody tr[data-type]');
var counts = {
all: 0,
app: 0,
kb: 0,
equipment: 0,
pc: 0,
server: 0,
switch: 0,
accesspoint: 0,
printer: 0,
employee: 0,
notification: 0
};
var typesFound = {};
// Count rows by type
rows.forEach(function(row) {
var type = row.getAttribute('data-type');
counts.all++;
if (counts[type] !== undefined) {
counts[type]++;
typesFound[type] = true;
}
});
// Update count badges
for (var type in counts) {
var countEl = document.getElementById('count-' + type);
if (countEl) {
countEl.textContent = '(' + counts[type] + ')';
}
}
// Show filter buttons only for types that exist
var filterBar = document.getElementById('resultFilters');
var buttons = filterBar.querySelectorAll('.filter-btn[data-filter]');
buttons.forEach(function(btn) {
var filter = btn.getAttribute('data-filter');
if (filter === 'all' || typesFound[filter]) {
btn.style.display = '';
}
});
// Show filter bar only if there are multiple types
var numTypes = Object.keys(typesFound).length;
if (numTypes > 1) {
filterBar.style.display = 'flex';
}
}
// Scroll to highlighted result on page load
window.addEventListener('DOMContentLoaded', function() {
// Initialize filter bar
initializeFilters();
var urlParams = new URLSearchParams(window.location.search);
var highlightId = urlParams.get('highlight');
var highlightType = urlParams.get('type');

View File

@@ -6,7 +6,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>West Jefferson - Events & Notifications</title>
<title>West Jefferson Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
@@ -23,13 +23,13 @@
color: #fff;
margin: 0;
padding: 0;
overflow: hidden;
}
.container {
max-width: 100%;
margin: 0 auto;
padding: 20px 40px;
padding-bottom: 80px;
}
.header {
@@ -174,7 +174,7 @@
}
.events-section {
margin-bottom: 20px;
margin-bottom: 10px;
}
.events-grid {
@@ -196,7 +196,7 @@
.section-title {
font-size: 22px;
font-weight: 800;
margin-bottom: 5px;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 8px;
display: inline-block;
@@ -414,7 +414,6 @@
.recognition-carousel-wrapper {
position: relative;
width: 100%;
min-height: 150px;
}
.recognition-card {
@@ -537,22 +536,16 @@
color: #fff;
}
.recognition-dots {
text-align: center;
margin-top: 15px;
}
.recognition-dots .dot {
display: inline-block;
width: 10px;
height: 10px;
background: #444;
border-radius: 50%;
margin: 0 5px;
}
.recognition-dots .dot.active {
background: #0d6efd;
.event-counter {
position: absolute;
top: 10px;
right: 15px;
background: rgba(0, 0, 61, 0.7);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
color: #fff;
font-weight: 600;
}
/* Upcoming events carousel */
@@ -565,7 +558,6 @@
.upcoming-carousel-wrapper {
position: relative;
width: 100%;
min-height: 200px;
}
.event-card.upcoming.carousel-item {
@@ -578,6 +570,7 @@
position: absolute;
top: 0;
left: 0;
right: 0;
}
.event-card.upcoming.carousel-item.active {
@@ -596,13 +589,111 @@
opacity: 0;
}
/* Current events carousel (2-column pairs) */
.current-carousel-container {
position: relative;
overflow: hidden;
width: 100%;
}
.current-carousel-wrapper {
position: relative;
width: 100%;
}
.current-carousel-pair {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
width: 100%;
transition: transform 0.8s ease-in-out, opacity 0.8s ease-in-out;
}
.current-carousel-pair:not(.active) {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.current-carousel-pair.active {
transform: translateY(0);
opacity: 1;
position: relative;
}
.current-carousel-pair.exit-up {
transform: translateY(-100%);
opacity: 0;
}
.current-carousel-pair.enter-down {
transform: translateY(100%);
opacity: 0;
}
.current-carousel-pair .event-card {
margin-bottom: 0;
}
.current-pair-counter {
text-align: center;
font-size: 14px;
font-weight: 600;
color: #eaeaea;
margin-top: 10px;
padding: 5px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
/* Non-incident events carousel (for Change/Awareness - Incidents stay pinned) */
.non-incident-carousel-container {
position: relative;
overflow: hidden;
width: 100%;
}
.non-incident-carousel-wrapper {
position: relative;
width: 100%;
}
.non-incident-carousel-wrapper .event-card.carousel-item {
width: 100%;
transition: transform 0.8s ease-in-out, opacity 0.8s ease-in-out;
margin-bottom: 0;
}
.non-incident-carousel-wrapper .event-card.carousel-item:not(.active) {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.non-incident-carousel-wrapper .event-card.carousel-item.active {
transform: translateY(0);
opacity: 1;
position: relative;
}
.non-incident-carousel-wrapper .event-card.carousel-item.exit-up {
transform: translateY(-100%);
opacity: 0;
}
.non-incident-carousel-wrapper .event-card.carousel-item.enter-down {
transform: translateY(100%);
opacity: 0;
}
/* ============================================ */
/* RESPONSIVE SIZING FOR LOW-RES DISPLAYS (720p) */
/* ============================================ */
@media screen and (max-height: 900px) {
.container {
padding: 15px 30px;
padding-bottom: 70px;
}
.header {
@@ -750,8 +841,26 @@
padding: 3px 8px;
}
.event-counter {
font-size: 9px;
padding: 2px 6px;
top: 6px;
right: 8px;
}
.recognition-carousel-wrapper {
min-height: 100px;
}
.current-carousel-wrapper {
}
.current-carousel-pair {
gap: 8px;
}
.current-pair-counter {
font-size: 10px;
margin-top: 5px;
}
}
@@ -761,7 +870,6 @@
@media screen and (min-width: 2560px) {
.container {
padding: 40px 80px;
padding-bottom: 120px;
}
.header {
@@ -903,14 +1011,27 @@
padding: 10px 24px;
}
.recognition-carousel-wrapper {
min-height: 300px;
.event-counter {
font-size: 24px;
padding: 8px 20px;
top: 20px;
right: 25px;
}
.recognition-dots .dot {
width: 20px;
height: 20px;
margin: 0 10px;
.recognition-carousel-wrapper {
}
.current-carousel-wrapper {
}
.current-carousel-pair {
gap: 30px;
}
.current-pair-counter {
font-size: 28px;
margin-top: 20px;
padding: 10px;
}
}
</style>
@@ -923,7 +1044,7 @@
</div>
<div class="header-center">
<div class="fiscal-week" id="fiscalWeek"></div>
<h1>West Jefferson Events</h1>
<h1>West Jefferson Dashboard</h1>
</div>
<div class="filter-container">
<div class="clock" id="clock"></div>
@@ -967,6 +1088,19 @@
let recognitionCarouselInterval = null;
let isRecognitionTransitioning = false;
// Current events carousel state - NOW FOR NON-INCIDENTS ONLY
// Incidents stay pinned, other events rotate
let nonIncidentEvents = [];
let currentNonIncidentIndex = 0;
let nonIncidentCarouselInterval = null;
let isNonIncidentTransitioning = false;
// Keep old variables for compatibility but they won't be used
let currentEventPairs = [];
let currentPairIndex = 0;
let currentCarouselInterval = null;
let isCurrentTransitioning = false;
// Get business unit from URL parameter
function getBusinessUnitFromURL() {
const urlParams = new URLSearchParams(window.location.search);
@@ -1054,8 +1188,8 @@
// Render events on the page (with smart delay during transitions)
function renderEvents(data) {
// If carousel is mid-transition, delay the render
if (isTransitioning) {
// If any carousel is mid-transition, delay the render
if (isTransitioning || isNonIncidentTransitioning || isRecognitionTransitioning) {
console.log('Carousel: Transition in progress, delaying render by 800ms');
pendingDataRender = data;
return;
@@ -1068,92 +1202,7 @@
const recognitions = data.current ? data.current.filter(e => e.typecolor === 'recognition') : [];
const otherCurrentEvents = data.current ? data.current.filter(e => e.typecolor !== 'recognition') : [];
// Current Events - show all in grid layout (excluding Recognition) - FIRST priority
if (otherCurrentEvents.length > 0) {
// Split into active and resolved events
const activeEvents = otherCurrentEvents.filter(e => !e.resolved);
const resolvedEvents = otherCurrentEvents.filter(e => e.resolved);
// Sort function: by type priority (Incident > Change > Awareness > TBD), then by date (earliest first)
const typePriority = {
'Incident': 1,
'Change': 2,
'Awareness': 3,
'Recognition': 4,
'TBD': 5
};
const sortByTypeAndDate = (a, b) => {
// First, sort by type priority
const priorityA = typePriority[a.typename] || 999;
const priorityB = typePriority[b.typename] || 999;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// If same type, sort by start time (earliest first)
const dateA = new Date(a.starttime);
const dateB = new Date(b.starttime);
return dateA - dateB;
};
// Sort both active and resolved events
activeEvents.sort(sortByTypeAndDate);
resolvedEvents.sort(sortByTypeAndDate);
// Combine with active first, resolved last
const sortedEvents = [...activeEvents, ...resolvedEvents];
// Determine badge color based on highest severity
const severity = getHighestSeverity(data.current);
html += '<div class="events-section">';
html += `<div class="section-title current severity-${severity}">CURRENT EVENTS</div>`;
// Determine grid class based on number of events
const gridClass = sortedEvents.length === 1 ? 'single' : 'multi';
html += `<div class="events-grid ${gridClass}">`;
sortedEvents.forEach(event => {
const borderColor = getColorFromType(event.typecolor);
const ticketBadge = event.ticketnumber
? `<a href="${getServiceNowUrl(event.ticketnumber)}" target="_blank" class="event-ticket">${escapeHtml(event.ticketnumber)}</a>`
: '';
const resolvedIndicator = event.resolved ? '<div class="resolved-indicator">RESOLVED</div>' : '';
const endTimeText = event.resolved
? `<br><strong>Resolved:</strong> ${formatDateTime(event.endtime)}`
: event.endtime ? `<br><strong>Ends:</strong> ${formatDateTime(event.endtime)}` : '<br><strong>Status:</strong> Ongoing (no end time)';
// Calculate progressive fade for resolved incidents (fade over 30 minutes)
let opacity = 1.0;
let resolvedClass = '';
if (event.resolved && event.minutes_since_end !== null && event.minutes_since_end !== undefined) {
// Fade from 1.0 to 0.5 over 30 minutes, then drop off
opacity = Math.max(0.5, 1.0 - (event.minutes_since_end / 30) * 0.5);
resolvedClass = ' resolved';
}
html += `
<div class="event-card current${resolvedClass}" style="border-left-color: ${borderColor}; opacity: ${opacity};">
${resolvedIndicator}
<div>${ticketBadge}</div>
<div class="event-header">
<div class="event-title">${escapeHtml(event.notification)}</div>
</div>
<div class="event-time">
<strong>Started:</strong> ${formatDateTime(event.starttime)}${endTimeText}
</div>
</div>
`;
});
html += '</div>'; // Close grid
html += '</div>'; // Close section
}
// Recognition Carousel Section - AFTER current events, BEFORE upcoming
// Recognition Carousel Section - FIRST (top of dashboard)
if (recognitions.length > 0) {
recognitionEvents = recognitions;
@@ -1197,22 +1246,148 @@
});
html += '</div>'; // Close carousel wrapper
// Add dots if multiple recognitions
if (recognitions.length > 1) {
html += '<div class="recognition-dots">';
recognitions.forEach((_, index) => {
html += `<span class="dot ${index === currentRecognitionIndex ? 'active' : ''}" data-index="${index}"></span>`;
});
html += '</div>';
}
html += '</div>'; // Close carousel container
html += '</div>'; // Close section
} else {
recognitionEvents = [];
}
// Current Events - NEW LOGIC: Incidents pinned, others rotate
// Separate incidents from other event types
const incidents = otherCurrentEvents.filter(e => e.typename === 'Incident');
const nonIncidents = otherCurrentEvents.filter(e => e.typename !== 'Incident');
// Sort function: by date (earliest first), then resolved status
const sortByDateAndResolved = (a, b) => {
// Active events before resolved
if (a.resolved !== b.resolved) {
return a.resolved ? 1 : -1;
}
// Then by start time (earliest first)
const dateA = new Date(a.starttime);
const dateB = new Date(b.starttime);
return dateA - dateB;
};
// Sort incidents and non-incidents
incidents.sort(sortByDateAndResolved);
nonIncidents.sort(sortByDateAndResolved);
// Helper function to render a single event card
const renderEventCard = (event, index, total, showCounter = true) => {
const borderColor = getColorFromType(event.typecolor);
const ticketBadge = event.ticketnumber
? `<a href="${getServiceNowUrl(event.ticketnumber)}" target="_blank" class="event-ticket">${escapeHtml(event.ticketnumber)}</a>`
: '';
const resolvedIndicator = event.resolved ? '<div class="resolved-indicator">RESOLVED</div>' : '';
const eventCounter = (showCounter && total > 1) ? `<div class="event-counter">${index + 1} of ${total}</div>` : '';
const endTimeText = event.resolved
? `<br><strong>Resolved:</strong> ${formatDateTime(event.endtime)}`
: event.endtime ? `<br><strong>Ends:</strong> ${formatDateTime(event.endtime)}` : '<br><strong>Status:</strong> Ongoing (no end time)';
// Calculate progressive fade for resolved incidents (fade over 30 minutes)
let opacity = 1.0;
let resolvedClass = '';
if (event.resolved && event.minutes_since_end !== null && event.minutes_since_end !== undefined) {
opacity = Math.max(0.5, 1.0 - (event.minutes_since_end / 30) * 0.5);
resolvedClass = ' resolved';
}
return `
<div class="event-card current${resolvedClass}" style="border-left-color: ${borderColor}; opacity: ${opacity};">
${resolvedIndicator}
${eventCounter}
<div>${ticketBadge}</div>
<div class="event-header">
<div class="event-title">${escapeHtml(event.notification)}</div>
</div>
<div class="event-time">
<strong>Started:</strong> ${formatDateTime(event.starttime)}${endTimeText}
</div>
</div>
`;
};
// Determine badge color based on highest severity
const severity = getHighestSeverity(data.current);
// Only show current events section if there are incidents OR non-incidents
if (incidents.length > 0 || nonIncidents.length > 0) {
html += '<div class="events-section">';
html += `<div class="section-title current severity-${severity}">CURRENT EVENTS</div>`;
// ========================================
// INCIDENTS SECTION - Always visible (pinned)
// ========================================
if (incidents.length > 0) {
html += '<div class="incidents-pinned-section" style="margin-bottom: 10px;">';
const gridClass = incidents.length === 1 ? 'single' : 'multi';
html += `<div class="events-grid ${gridClass}">`;
incidents.forEach((event, index) => {
html += renderEventCard(event, index, incidents.length, false); // No counter for pinned
});
html += '</div>'; // Close incidents grid
html += '</div>'; // Close incidents section
}
// ========================================
// NON-INCIDENTS SECTION - Rotating carousel
// ========================================
if (nonIncidents.length > 0) {
nonIncidentEvents = nonIncidents;
// Preserve current index if valid
if (currentNonIncidentIndex >= nonIncidents.length) {
currentNonIncidentIndex = 0;
}
html += '<div class="non-incidents-carousel-section">';
if (nonIncidents.length === 1) {
// Single non-incident - no carousel needed
html += '<div class="events-grid single">';
html += renderEventCard(nonIncidents[0], 0, 1, false);
html += '</div>';
} else {
// Multiple non-incidents - use carousel
html += '<div class="non-incident-carousel-container">';
html += '<div class="non-incident-carousel-wrapper" id="nonIncidentCarousel">';
nonIncidents.forEach((event, index) => {
const activeClass = index === currentNonIncidentIndex ? 'active' : 'enter-down';
html += `
<div class="event-card current carousel-item ${activeClass}" data-index="${index}" style="border-left-color: ${getColorFromType(event.typecolor)};">
<div class="event-counter">${index + 1} of ${nonIncidents.length}</div>
<div>${event.ticketnumber ? `<a href="${getServiceNowUrl(event.ticketnumber)}" target="_blank" class="event-ticket">${escapeHtml(event.ticketnumber)}</a>` : ''}</div>
<div class="event-header">
<div class="event-title">${escapeHtml(event.notification)}</div>
</div>
<div class="event-time">
<strong>Started:</strong> ${formatDateTime(event.starttime)}
${event.resolved ? `<br><strong>Resolved:</strong> ${formatDateTime(event.endtime)}` : event.endtime ? `<br><strong>Ends:</strong> ${formatDateTime(event.endtime)}` : '<br><strong>Status:</strong> Ongoing'}
</div>
</div>
`;
});
html += '</div>'; // Close carousel wrapper
html += '</div>'; // Close carousel container
}
html += '</div>'; // Close non-incidents section
} else {
nonIncidentEvents = [];
}
html += '</div>'; // Close events-section
} else {
nonIncidentEvents = [];
}
// Clear old carousel state (not used anymore)
currentEventPairs = [];
// Upcoming Events - carousel with slide-up transition
if (data.upcoming && data.upcoming.length > 0) {
// Sort upcoming events by type priority, then by date
@@ -1262,8 +1437,10 @@
? `<a href="${getServiceNowUrl(event.ticketnumber)}" target="_blank" class="event-ticket">${escapeHtml(event.ticketnumber)}</a>`
: '';
const activeClass = index === currentUpcomingIndex ? 'active' : 'enter-down';
const upcomingCounter = sortedUpcoming.length > 1 ? `<div class="event-counter">${index + 1} of ${sortedUpcoming.length}</div>` : '';
html += `
<div class="event-card upcoming carousel-item ${activeClass}" data-index="${index}" style="border-left-color: ${borderColor};">
${upcomingCounter}
<div>${ticketBadge}</div>
<div class="event-header">
<div class="event-title">${escapeHtml(event.notification)}</div>
@@ -1289,6 +1466,13 @@
container.innerHTML = html;
// Start carousels if there are events
// NEW: Non-incident carousel (replaces old current events carousel)
if (nonIncidentEvents.length > 1) {
startNonIncidentCarousel();
} else {
stopNonIncidentCarousel();
}
if (upcomingEvents.length > 1) {
startUpcomingCarousel();
} else {
@@ -1303,6 +1487,63 @@
}
}
// ========================================
// NON-INCIDENT CAROUSEL FUNCTIONS (NEW)
// Incidents stay pinned, other events rotate
// ========================================
function startNonIncidentCarousel() {
console.log(`Non-Incident Carousel: Starting with ${nonIncidentEvents.length} events`);
stopNonIncidentCarousel();
nonIncidentCarouselInterval = setInterval(rotateNonIncidentEvent, 5000);
}
function stopNonIncidentCarousel() {
if (nonIncidentCarouselInterval) {
clearInterval(nonIncidentCarouselInterval);
nonIncidentCarouselInterval = null;
}
}
function rotateNonIncidentEvent() {
if (nonIncidentEvents.length <= 1) {
return;
}
const carousel = document.getElementById('nonIncidentCarousel');
if (!carousel) {
return;
}
const items = carousel.querySelectorAll('.carousel-item');
if (items.length === 0) {
return;
}
// Mark transition as active
isNonIncidentTransitioning = true;
const currentItem = items[currentNonIncidentIndex];
const nextIndex = (currentNonIncidentIndex + 1) % nonIncidentEvents.length;
const nextItem = items[nextIndex];
// Slide current item up and out
currentItem.classList.remove('active');
currentItem.classList.add('exit-up');
// Slide next item up from bottom
nextItem.classList.remove('enter-down');
nextItem.classList.add('active');
// After transition, reset the exited item
setTimeout(() => {
currentItem.classList.remove('exit-up');
currentItem.classList.add('enter-down');
isNonIncidentTransitioning = false;
}, 800);
currentNonIncidentIndex = nextIndex;
}
// Start the upcoming events carousel
function startUpcomingCarousel() {
console.log(`Carousel: Starting carousel with ${upcomingEvents.length} events`);
@@ -1435,12 +1676,6 @@
counter.textContent = `${nextIndex + 1} of ${recognitionEvents.length}`;
}
// Update dots
const dots = document.querySelectorAll('.recognition-dots .dot');
dots.forEach((dot, i) => {
dot.classList.toggle('active', i === nextIndex);
});
// After transition, reset the exited item for next rotation
setTimeout(() => {
currentItem.classList.remove('exit-up');
@@ -1451,6 +1686,66 @@
currentRecognitionIndex = nextIndex;
}
// Start the current events carousel (2-column pairs)
function startCurrentCarousel() {
console.log(`Current Carousel: Starting with ${currentEventPairs.length} pairs`);
// Clear any existing interval
stopCurrentCarousel();
// Start new interval (5 seconds)
currentCarouselInterval = setInterval(rotateCurrentPair, 5000);
}
// Stop the current events carousel
function stopCurrentCarousel() {
if (currentCarouselInterval) {
clearInterval(currentCarouselInterval);
currentCarouselInterval = null;
}
}
// Rotate to next pair of current events
function rotateCurrentPair() {
if (currentEventPairs.length <= 1) {
return;
}
const carousel = document.getElementById('currentCarousel');
if (!carousel) {
return;
}
const pairs = carousel.querySelectorAll('.current-carousel-pair');
if (pairs.length === 0) {
return;
}
// Mark transition as active
isCurrentTransitioning = true;
const currentPair = pairs[currentPairIndex];
const nextIndex = (currentPairIndex + 1) % currentEventPairs.length;
const nextPair = pairs[nextIndex];
// Slide current pair up and out
currentPair.classList.remove('active');
currentPair.classList.add('exit-up');
// Slide next pair up from bottom
nextPair.classList.remove('enter-down');
nextPair.classList.add('active');
// After transition, reset the exited pair for next rotation
setTimeout(() => {
currentPair.classList.remove('exit-up');
currentPair.classList.add('enter-down');
isCurrentTransitioning = false;
}, 800);
currentPairIndex = nextIndex;
}
// Escape HTML to prevent XSS
function escapeHtml(text) {
const div = document.createElement('div');
@@ -1543,9 +1838,11 @@
// Fetch notifications from API
async function fetchNotifications() {
try {
let url = '../apishopfloor.asp';
// Add timestamp to prevent any caching (browser, proxy, etc.)
let timestamp = new Date().getTime();
let url = '../apishopfloor.asp?_t=' + timestamp;
if (selectedBusinessUnit) {
url += '?businessunit=' + encodeURIComponent(selectedBusinessUnit);
url += '&businessunit=' + encodeURIComponent(selectedBusinessUnit);
}
const response = await fetch(url);
@@ -1614,6 +1911,7 @@
if (document.hidden) {
clearInterval(updateInterval);
clearInterval(clockInterval);
stopNonIncidentCarousel(); // Incidents stay pinned, others rotate
stopUpcomingCarousel();
stopRecognitionCarousel();
} else {
@@ -1622,7 +1920,7 @@
clockInterval = setInterval(updateClock, 1000);
fetchNotifications();
updateInterval = setInterval(fetchNotifications, 10000);
// Carousel will restart in renderEvents when data loads
// Carousels will restart in renderEvents when data loads
}
});