// ==UserScript==
// @name KAAUH Lab - Robust Buttons & Highlighting
// @namespace Violentmonkey Scripts
// @version 1.7.8
// @description Adds navigation shortcut from Lab Orders to Lab Test Status tab. Also includes all previous enhancements with persistent input filters and anti-flickering optimizations. Fixed workbench button selection and relocated Reset button.
// @match https://his.kaauh.org/*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @author Hamad Al-Shegifi (Refactored for specific function)
// ==/UserScript==
(async function () { // The script must be async to use GM_getValue
'use strict';
console.log("KAAUH Lab - Robust Buttons & Highlighting Script v1.6.0 Loading...");
//================================================================================
// --- CONFIGURATION & STATE VARIABLES ---
//================================================================================
const WORKBENCH_SELECTION_KEY = 'kaauh_last_workbench_selection';
const FILTER_PERSISTENCE_KEY = 'kaauh_filter_persistence';
let state = {
lastUrl: location.href,
hasAppliedInitialFilters: false,
filterRetryCount: 0,
maxFilterRetries: 5, // Reduced from 10 to prevent excessive retries
isApplyingFilters: false, // [NEW] Prevent concurrent filter applications
lastFilterApplication: 0, // [NEW] Throttle filter applications
observerThrottle: null, // [NEW] Throttle observer callbacks
selectedWorkbenchId: null, // [FIXED] Track selected workbench
resetButtonRelocated: false, // [NEW] Track if reset button has been relocated
};
const SELECTORS = {
workbenchDropdown: '#filterSec',
statusDropdownTrigger: 'option[translateid="lab-test-analyzer.result-status.Ordered"]',
buttonGroupContainer: 'ul.nav.nav-tabs.tab-container',
buttonGroup: '.filter-btn-group',
agHeaderViewport: '.ag-header-viewport',
resetButton: 'button[translateid="lab-order-list.Reset"]',
referenceLabToggle: '.nova-toggle',
};
//================================================================================
// --- STYLE INJECTION (GM_addStyle) ---
//================================================================================
GM_addStyle(`
.filter-btn-group { display: flex !important; flex-wrap: nowrap !important; gap: 6px !important; margin-top: 12px !important; overflow-x: auto !important; padding-bottom: 6px !important; padding-inline: 10px !important; }
.filter-btn-group::-webkit-scrollbar { height: 8px; }
.filter-btn-group::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
.filter-btn-group::-webkit-scrollbar-thumb { background: #888; border-radius: 4px; }
.filter-btn-group::-webkit-scrollbar-thumb:hover { background: #555; }
.filter-btn-group .btn { padding: 6px 12px !important; font-size: 13px !important; font-weight: bold !important; border-radius: 6px !important; border: none !important; color: #fff !important; white-space: nowrap !important; cursor: pointer !important; flex-shrink: 0 !important; transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; }
.filter-btn-group .btn:hover { transform: scale(1.05); box-shadow: 0 4px 8px rgba(0,0,0,0.15); }
.filter-btn-group .btn.selected {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.8), 0 0 0 5px rgba(0, 123, 255, 0.5) !important;
transform: scale(1.02) !important;
z-index: 10 !important;
position: relative !important;
}
.filter-btn-group .btn:not(.selected) {
opacity: 0.4 !important;
}
/* [NEW] Reset button relocation styles */
.reset-button-container {
display: flex !important;
align-items: center !important;
gap: 15px !important;
margin-bottom: 10px !important;
}
.relocated-reset-button {
order: -1 !important;
margin-right: 15px !important;
}
${Array.from({ length: 20 }, (_, i) => {
const colors = [ '#28a745', '#ffc107', '#17a2b8', '#dc3545', '#6f42c1', '#fd7e14', '#20c997', '#6610f2', '#e83e8c', '#343a40', '#198754', '#0d6efd', '#d63384', '#6c757d', '#ff5733', '#9c27b0', '#00bcd4', '#795548', '#3f51b5'];
return `.btn-color-${i} { background-color: ${colors[i % colors.length]} !important; }`;
}).join('\n')}
.ag-row:hover .ag-cell {
background-color: lightblue !important;
}
`);
//================================================================================
// --- ENHANCED FILTER PERSISTENCE FUNCTIONS ---
//================================================================================
/**
* [NEW] Save current filter values to persistent storage
*/
async function saveFilterValues(filters) {
try {
await GM_setValue(FILTER_PERSISTENCE_KEY, JSON.stringify(filters));
console.log("Filter values saved:", filters);
} catch (e) {
console.error("Error saving filter values:", e);
}
}
/**
* [NEW] Load filter values from persistent storage
*/
async function loadFilterValues() {
try {
const filtersJSON = await GM_getValue(FILTER_PERSISTENCE_KEY, null);
return filtersJSON ? JSON.parse(filtersJSON) : {};
} catch (e) {
console.error("Error loading filter values:", e);
return {};
}
}
/**
* [OPTIMIZED] Improved setColumnFilter function with anti-flickering optimizations
*/
function setColumnFilter(columnName, value, shouldPersist = true) {
const headerViewport = document.querySelector(SELECTORS.agHeaderViewport);
if (!headerViewport) {
console.warn(`Header viewport not found for column ${columnName}`);
return false;
}
const allCols = Array.from(headerViewport.querySelectorAll('.ag-header-cell')).map(cell => cell.getAttribute('col-id'));
const columnIndex = allCols.indexOf(columnName);
if (columnIndex === -1) {
console.warn(`Column ${columnName} not found in grid`);
return false;
}
const filterInput = headerViewport.querySelector(`.ag-header-row[aria-rowindex="2"]`)?.children[columnIndex]?.querySelector('.ag-floating-filter-input');
if (!filterInput) {
console.warn(`Filter input not found for column ${columnName}`);
return false;
}
// [OPTIMIZATION] Check if value is already set to avoid unnecessary updates
if (filterInput.value === value) {
console.log(`Filter for ${columnName} already set to: "${value}"`);
return true;
}
console.log(`Setting filter for ${columnName} to: "${value}"`);
// [OPTIMIZATION] Use a single, smooth operation instead of multiple events
filterInput.value = value;
// [OPTIMIZATION] Use only the essential events, reduce redundancy
const inputEvent = new Event('input', { bubbles: true });
filterInput.dispatchEvent(inputEvent);
// [OPTIMIZATION] Delayed change event to ensure proper registration
setTimeout(() => {
const changeEvent = new Event('change', { bubbles: true });
filterInput.dispatchEvent(changeEvent);
console.log(`Filter applied for ${columnName}: "${value}"`);
}, 50); // Reduced delay from 100ms to 50ms
// Save to persistent storage if requested (including empty values)
if (shouldPersist) {
setTimeout(async () => {
const currentFilters = await loadFilterValues();
currentFilters[columnName] = value;
await saveFilterValues(currentFilters);
}, 100); // Reduced delay from 200ms to 100ms
}
return true;
}
/**
* [OPTIMIZED] Apply all saved filters with improved throttling
*/
async function applySavedFilters() {
// [OPTIMIZATION] Prevent concurrent filter applications
if (state.isApplyingFilters) {
console.log("Filter application already in progress, skipping...");
return true;
}
// [OPTIMIZATION] Throttle filter applications
const now = Date.now();
if (now - state.lastFilterApplication < 1000) { // 1 second throttle
console.log("Filter application throttled, skipping...");
return true;
}
state.isApplyingFilters = true;
state.lastFilterApplication = now;
try {
const savedFilters = await loadFilterValues();
if (Object.keys(savedFilters).length === 0) {
console.log("No saved filters to apply");
return true;
}
console.log("Applying saved filters:", savedFilters);
let allApplied = true;
// [OPTIMIZATION] Apply filters sequentially with small delays to prevent flickering
for (const [columnName, value] of Object.entries(savedFilters)) {
const success = setColumnFilter(columnName, value, false);
if (!success) {
allApplied = false;
}
// [OPTIMIZATION] Small delay between filter applications
await new Promise(resolve => setTimeout(resolve, 25));
}
return allApplied;
} finally {
// [OPTIMIZATION] Reset the flag after a delay
setTimeout(() => {
state.isApplyingFilters = false;
}, 500);
}
}
/**
* [NEW] Clear all saved filters
*/
async function clearSavedFilters() {
try {
await GM_setValue(FILTER_PERSISTENCE_KEY, JSON.stringify({}));
console.log("Saved filters cleared");
} catch (e) {
console.error("Error clearing saved filters:", e);
}
}
//================================================================================
// --- CORE LOGIC ---
//================================================================================
function setupNavigationShortcut() {
const labOrdersLink = document.querySelector('span.csi-menu-text[title="Lab Orders"]')?.closest('a');
if (!labOrdersLink || labOrdersLink.dataset.shortcutAttached === 'true') {
return;
}
labOrdersLink.addEventListener('click', () => {
setTimeout(() => {
const labTestStatusTab = document.querySelector('a[href="#/lab-orders/lab-test-analyzer"]');
if (labTestStatusTab) {
labTestStatusTab.click();
}
}, 100);
});
labOrdersLink.dataset.shortcutAttached = 'true';
}
/**
* [NEW] Relocate Reset button to be inline with Reference Lab Orders toggle
*/
function relocateResetButton() {
if (state.resetButtonRelocated) return;
const resetButton = document.querySelector(SELECTORS.resetButton);
const referenceLabToggle = document.querySelector(SELECTORS.referenceLabToggle);
if (!resetButton || !referenceLabToggle) {
console.log("Reset button or Reference Lab toggle not found for relocation");
return;
}
// Check if already relocated
if (resetButton.classList.contains('relocated-reset-button')) {
return;
}
console.log("Relocating Reset button to be inline with Reference Lab Orders toggle");
// Create a container for the reset button and toggle
const container = document.createElement('div');
container.className = 'reset-button-container';
// Clone the reset button and add styling
const relocatedResetButton = resetButton.cloneNode(true);
relocatedResetButton.classList.add('relocated-reset-button');
// Insert the container before the reference lab toggle
referenceLabToggle.parentNode.insertBefore(container, referenceLabToggle);
// Move elements into the container
container.appendChild(relocatedResetButton);
container.appendChild(referenceLabToggle);
// Remove the original reset button
resetButton.remove();
// Ensure the relocated button maintains its functionality
relocatedResetButton.addEventListener('click', async () => {
console.log("Relocated Reset button clicked");
await clearSavedFilters();
// Clear filters manually as fallback
setColumnFilter('testStatus', '', false);
setColumnFilter('sampleStatus', '', false);
// Reset workbench selection
const workbenchSelect = document.getElementById(SELECTORS.workbenchDropdown.substring(1));
if (workbenchSelect) {
workbenchSelect.selectedIndex = 0;
workbenchSelect.dispatchEvent(new Event('change', { bubbles: true }));
}
// Reset status dropdown
const statusDropdown = document.querySelector(SELECTORS.statusDropdownTrigger)?.closest('select');
if (statusDropdown) {
statusDropdown.selectedIndex = 0;
statusDropdown.dispatchEvent(new Event('change', { bubbles: true }));
}
});
state.resetButtonRelocated = true;
console.log("Reset button successfully relocated");
}
function applyConditionalCellStyles() {
const rows = document.querySelectorAll('.ag-row');
for (const row of rows) {
const clinicCell = row.querySelector('.ag-cell[col-id="clinic"]');
const isEmergency = clinicCell && clinicCell.textContent.trim().toUpperCase() === 'EMERGENCY';
const cellsInRow = row.querySelectorAll('.ag-cell');
for (const cell of cellsInRow) {
cell.style.backgroundColor = '';
cell.style.color = '';
cell.style.fontWeight = '';
if (isEmergency) {
cell.style.backgroundColor = '#ffcccb';
cell.style.color = 'black';
}
const colId = cell.getAttribute('col-id');
const text = cell.textContent.trim();
if (colId === 'testStatus') {
switch (text) {
case 'Resulted':
cell.style.backgroundColor = '#ffb733';
cell.style.color = 'black';
break;
case 'Ordered':
cell.style.backgroundColor = 'yellow';
cell.style.color = 'black';
break;
case 'VerifiedLevel1':
case 'VerifiedLevel2':
cell.style.backgroundColor = 'lightgreen';
cell.style.color = 'black';
break;
}
} else if (colId === 'sampleStatus') {
switch (text) {
case 'Received':
cell.style.backgroundColor = 'lightgreen';
cell.style.color = 'black';
cell.style.fontWeight = 'bold';
break;
case 'Rejected':
cell.style.backgroundColor = 'red';
cell.style.color = 'black';
cell.style.fontWeight = 'bold';
break;
case 'Collected':
cell.style.backgroundColor = 'orange';
cell.style.color = 'black';
cell.style.fontWeight = 'bold';
break;
}
}
}
}
}
/**
* [OPTIMIZED] Enhanced status dropdown listener with reduced delays
*/
function addStatusDropdownListener() {
const statusDropdown = document.querySelector(SELECTORS.statusDropdownTrigger)?.closest('select');
if (!statusDropdown || statusDropdown.dataset.statusListenerAttached === 'true') {
return;
}
statusDropdown.addEventListener('change', async (event) => {
const selectedOption = event.target.options[event.target.selectedIndex];
const selectedText = selectedOption.textContent.trim();
// [OPTIMIZATION] Reduced timeout from 500ms to 200ms
setTimeout(async () => {
// Handle the "---Select---" option to trigger a reset
if (selectedOption.value === '0') {
console.log("---Select--- chosen, clearing all filters and resetting.");
await clearSavedFilters();
let resetButton = document.querySelector(SELECTORS.resetButton) || document.querySelector('.relocated-reset-button');
if (!resetButton) {
console.log("Reset button not found by selector, trying fallback search...");
const allButtons = Array.from(document.querySelectorAll('button'));
resetButton = allButtons.find(btn => btn.textContent.trim().toLowerCase() === 'reset');
}
if (resetButton) {
console.log("Simulating click on Reset button.");
resetButton.click();
} else {
console.warn("Could not find Reset button. Manually clearing filters as a final fallback.");
setColumnFilter('testStatus', '', false);
setColumnFilter('sampleStatus', '', false);
}
return;
}
// Handle other statuses and save them
if (selectedText === "Sample Rejected") {
setColumnFilter('sampleStatus', 'Rejected');
setColumnFilter('testStatus', '');
console.log("Sample Rejected selected: sampleStatus='Rejected', testStatus='' (cleared)");
} else if (selectedText === "Sample Refused") {
setColumnFilter('sampleStatus', 'Refused');
setColumnFilter('testStatus', '');
console.log("Sample Refused selected: sampleStatus='Refused', testStatus='' (cleared)");
} else {
const statusMap = {
"Verified 1": "VerifiedLevel1",
"Verified 2": "VerifiedLevel2",
"Cancelled": "Cancelled",
};
const filterText = statusMap[selectedText] || selectedText;
setColumnFilter('testStatus', filterText);
setColumnFilter('sampleStatus', 'Received');
console.log(`Status selected: testStatus='${filterText}', sampleStatus='Received'`);
}
}, 200); // Reduced from 500ms
});
statusDropdown.dataset.statusListenerAttached = 'true';
}
/**
* [FIXED] Update button selection visual state
*/
function updateButtonSelection(selectedId) {
state.selectedWorkbenchId = selectedId;
const buttons = document.querySelectorAll('.filter-btn-group .btn');
buttons.forEach(btn => {
const btnId = btn.dataset.workbenchId;
if (btnId === selectedId) {
btn.classList.add('selected');
} else {
btn.classList.remove('selected');
}
});
}
/**
* [FIXED] Enhanced insertFilterButtons with proper selection state management
*/
function insertFilterButtons() {
if (document.querySelector(SELECTORS.buttonGroup)) return;
const target = document.querySelector(SELECTORS.buttonGroupContainer);
const select = document.getElementById(SELECTORS.workbenchDropdown.substring(1));
if (!target || !select || select.options.length <= 1) return;
const workbenches = Array.from(select.options).reduce((acc, option) => {
const id = option.value?.trim();
let name = option.textContent?.trim();
if (id && name) {
if (name.toLowerCase().includes('---select---') || ['select', 'all'].includes(name.toLowerCase())) {
name = 'ALL WORK BENCHES';
}
acc[name] = id;
}
return acc;
}, {});
if (Object.keys(workbenches).length <= 1) return;
const group = document.createElement('div');
group.className = 'filter-btn-group';
let colorIndex = 0;
for (const [name, id] of Object.entries(workbenches)) {
const btn = document.createElement('button');
btn.className = `btn btn-color-${colorIndex++ % 10}`;
btn.dataset.workbenchId = id; // [FIXED] Store workbench ID for selection tracking
const sessionKey = `workbench_status_${id || 'all'}`;
let currentStatus = sessionStorage.getItem(sessionKey) || 'Ordered';
btn.textContent = `${name} (${currentStatus})`;
// [FIXED] Set initial selection state based on current dropdown value
if (select.value === id) {
btn.classList.add('selected');
state.selectedWorkbenchId = id;
}
btn.addEventListener('click', async () => {
currentStatus = (currentStatus === 'Ordered') ? 'Resulted' : 'Ordered';
sessionStorage.setItem(sessionKey, currentStatus);
btn.textContent = `${name} (${currentStatus})`;
// [FIXED] Update visual selection state
updateButtonSelection(id);
GM_setValue(WORKBENCH_SELECTION_KEY, JSON.stringify({ id, status: currentStatus }));
select.value = id;
select.dispatchEvent(new Event('change', { bubbles: true }));
const statusDropdown = document.querySelector(SELECTORS.statusDropdownTrigger)?.closest('select');
if (statusDropdown) {
const optionToSelect = Array.from(statusDropdown.options).find(opt => opt.textContent.trim() === currentStatus);
if (optionToSelect && statusDropdown.value !== optionToSelect.value) {
statusDropdown.value = optionToSelect.value;
statusDropdown.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// [OPTIMIZATION] Reduced timeout from 200ms to 100ms
setTimeout(() => {
setColumnFilter('sampleStatus', 'Received');
}, 100);
});
group.appendChild(btn);
}
target.parentNode.insertBefore(group, target.nextSibling);
// [FIXED] Initialize selection state if no button is currently selected
if (!state.selectedWorkbenchId && select.value) {
updateButtonSelection(select.value);
}
}
async function applyPersistentWorkbenchFilter() {
if (state.hasAppliedInitialFilters) return;
const workbenchSelect = document.getElementById(SELECTORS.workbenchDropdown.substring(1));
const statusDropdown = document.querySelector(SELECTORS.statusDropdownTrigger)?.closest('select');
if (!workbenchSelect || !statusDropdown) return;
try {
const savedWorkbenchJSON = await GM_getValue(WORKBENCH_SELECTION_KEY, null);
if (savedWorkbenchJSON) {
const savedWorkbench = JSON.parse(savedWorkbenchJSON);
if (savedWorkbench?.id && savedWorkbench?.status) {
workbenchSelect.value = savedWorkbench.id;
const optionToSelect = Array.from(statusDropdown.options).find(opt => opt.textContent.trim() === savedWorkbench.status);
if (optionToSelect) {
statusDropdown.value = optionToSelect.value;
}
workbenchSelect.dispatchEvent(new Event('change', { bubbles: true }));
statusDropdown.dispatchEvent(new Event('change', { bubbles: true }));
// [FIXED] Update button selection state after applying persistent filter
updateButtonSelection(savedWorkbench.id);
// [OPTIMIZATION] Reduced timeout from 200ms to 100ms
setTimeout(() => {
setColumnFilter('sampleStatus', 'Received');
}, 100);
}
}
} catch (e) {
console.error("Error applying persistent workbench filter:", e);
}
state.hasAppliedInitialFilters = true;
}
/**
* [OPTIMIZED] Enhanced function to apply saved filters with improved retry mechanism
*/
async function applyFiltersWithRetry() {
const headerViewport = document.querySelector(SELECTORS.agHeaderViewport);
if (!headerViewport) {
return;
}
const success = await applySavedFilters();
if (!success && state.filterRetryCount < state.maxFilterRetries) {
state.filterRetryCount++;
console.log(`Filter application failed, retrying... (${state.filterRetryCount}/${state.maxFilterRetries})`);
// [OPTIMIZATION] Increased retry delay from 1000ms to 1500ms to reduce rapid retries
setTimeout(() => applyFiltersWithRetry(), 1500);
} else if (success) {
console.log("All saved filters applied successfully");
state.filterRetryCount = 0;
} else {
console.warn("Max filter retry attempts reached");
state.filterRetryCount = 0;
}
}
/**
* [OPTIMIZED] Throttled master observer callback to reduce flickering
*/
async function masterObserverCallback() {
// [OPTIMIZATION] Throttle observer callbacks to prevent excessive execution
if (state.observerThrottle) {
clearTimeout(state.observerThrottle);
}
state.observerThrottle = setTimeout(async () => {
if (location.href !== state.lastUrl) {
state.lastUrl = location.href;
state.hasAppliedInitialFilters = false;
state.filterRetryCount = 0;
state.isApplyingFilters = false; // Reset filter application flag
state.selectedWorkbenchId = null; // [FIXED] Reset selection state on URL change
state.resetButtonRelocated = false; // [NEW] Reset relocation state on URL change
}
setupNavigationShortcut();
if (!location.href.includes('/lab-orders/lab-test-analyzer')) {
return;
}
// [NEW] Relocate reset button
relocateResetButton();
insertFilterButtons();
addStatusDropdownListener();
await applyPersistentWorkbenchFilter();
// [OPTIMIZATION] Apply saved filters with improved throttling
await applyFiltersWithRetry();
applyConditionalCellStyles();
}, 100); // 100ms throttle for observer callbacks
}
// [OPTIMIZATION] Use throttled observer with reduced sensitivity
const observer = new MutationObserver(masterObserverCallback);
observer.observe(document.body, {
childList: true,
subtree: true,
// [OPTIMIZATION] Removed attributes and characterData observation to reduce noise
});
masterObserverCallback();
})();