Folder reorganize 1

This commit is contained in:
Rucus
2026-02-17 12:44:37 -06:00
parent ec99d85bc2
commit f0ae0ab905
17427 changed files with 2071 additions and 1059030 deletions

158
assets/js/boiler-panels.js Normal file
View File

@@ -0,0 +1,158 @@
(() => {
const PANEL_SELECTOR = '[data-refresh-url="data/boilers.php"]';
const TABLE_SELECTOR = '[data-boiler-section]';
const STORAGE_KEY = 'lasuca.boilerPanels';
const loadState = () => {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (!stored) {
return {};
}
const parsed = JSON.parse(stored);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (error) {
console.warn('Unable to load boiler panel state from storage.', error);
return {};
}
};
const saveState = (state) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('Unable to persist boiler panel state.', error);
}
};
const applyCollapsedState = (table, collapsed) => {
const rows = Array.from(table.rows).slice(1);
rows.forEach((row) => {
row.style.display = collapsed ? 'none' : '';
});
table.classList.toggle('is-collapsed', collapsed);
};
const formatSummary = (value, units) => {
if (value === undefined || value === null || value === '') {
return 'Steam Flow: —';
}
const numeric = Number(value);
const formatted = Number.isFinite(numeric)
? numeric.toLocaleString(undefined, { maximumFractionDigits: 1 })
: value;
const unitPart = units ? ` ${units}` : '';
return `Steam Flow: ${formatted}${unitPart}`;
};
const initialiseTable = (table, state) => {
const id = table.dataset.boilerSection;
if (!id) {
return;
}
const headerRow = table.querySelector('tr');
const headerCell = headerRow ? headerRow.querySelector('td, th') : null;
if (!headerCell) {
return;
}
const title = (table.dataset.boilerTitle || headerCell.textContent || '').trim() || 'Boiler';
const steamValue = table.dataset.steamValue || '';
const steamUnits = table.dataset.steamUnits || '';
const targetId = table.id || `${id}-table`;
table.id = targetId;
let headerWrapper = headerCell.querySelector('.boiler-panel__header');
let toggleButton;
let summary;
if (!headerWrapper) {
headerWrapper = document.createElement('div');
headerWrapper.className = 'boiler-panel__header';
toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.className = 'boiler-panel__toggle';
toggleButton.setAttribute('data-boiler-toggle', id);
toggleButton.setAttribute('aria-controls', targetId);
toggleButton.innerHTML = `
<span class="boiler-panel__title">${title}</span>
<span class="boiler-panel__indicator" aria-hidden="true"></span>
`;
summary = document.createElement('span');
summary.className = 'boiler-panel__summary';
summary.setAttribute('data-boiler-summary', '');
summary.textContent = formatSummary(steamValue, steamUnits);
headerWrapper.appendChild(toggleButton);
headerWrapper.appendChild(summary);
headerCell.innerHTML = '';
headerCell.appendChild(headerWrapper);
toggleButton.addEventListener('click', () => {
const nextCollapsed = !table.classList.contains('is-collapsed');
applyCollapsedState(table, nextCollapsed);
toggleButton.setAttribute('aria-expanded', (!nextCollapsed).toString());
state[id] = nextCollapsed;
saveState(state);
});
table.dataset.boilerInitialised = 'true';
} else {
toggleButton = headerWrapper.querySelector('.boiler-panel__toggle');
summary = headerWrapper.querySelector('[data-boiler-summary]');
}
if (toggleButton) {
toggleButton.setAttribute('aria-controls', targetId);
}
if (summary) {
summary.textContent = formatSummary(steamValue, steamUnits);
}
const collapsed = state[id] === true;
applyCollapsedState(table, collapsed);
if (toggleButton) {
toggleButton.setAttribute('aria-expanded', (!collapsed).toString());
}
};
const initialisePanel = (panel) => {
const state = loadState();
const tables = panel.querySelectorAll(TABLE_SELECTOR);
tables.forEach((table) => initialiseTable(table, state));
};
const handlePanelUpdate = (event) => {
if (!event || !event.currentTarget) {
return;
}
initialisePanel(event.currentTarget);
};
const setup = () => {
const panel = document.querySelector(PANEL_SELECTOR);
if (!panel) {
return;
}
panel.addEventListener('livepanel:updated', handlePanelUpdate);
if (panel.innerHTML.trim().length) {
initialisePanel(panel);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setup, { once: true });
} else {
setup();
}
})();

158
assets/js/cvp-panels.js Normal file
View File

@@ -0,0 +1,158 @@
(() => {
const PANEL_SELECTOR = '[data-refresh-url="data/cvp.php"]';
const TABLE_SELECTOR = '[data-boiler-section]';
const STORAGE_KEY = 'lasuca.boilerPanels';
const loadState = () => {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (!stored) {
return {};
}
const parsed = JSON.parse(stored);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (error) {
console.warn('Unable to load boiler panel state from storage.', error);
return {};
}
};
const saveState = (state) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('Unable to persist boiler panel state.', error);
}
};
const applyCollapsedState = (table, collapsed) => {
const rows = Array.from(table.rows).slice(1);
rows.forEach((row) => {
row.style.display = collapsed ? 'none' : '';
});
table.classList.toggle('is-collapsed', collapsed);
};
const formatSummary = (value, units) => {
if (value === undefined || value === null || value === '') {
return '';
}
const numeric = Number(value);
const formatted = Number.isFinite(numeric)
? numeric.toLocaleString(undefined, { maximumFractionDigits: 1 })
: value;
const unitPart = units ? ` ${units}` : '';
return `Steam Flow: ${formatted}${unitPart}`;
};
const initialiseTable = (table, state) => {
const id = table.dataset.boilerSection;
if (!id) {
return;
}
const headerRow = table.querySelector('tr');
const headerCell = headerRow ? headerRow.querySelector('td, th') : null;
if (!headerCell) {
return;
}
const title = (table.dataset.boilerTitle || headerCell.textContent || '').trim() || 'Boiler';
const steamValue = table.dataset.steamValue || '';
const steamUnits = table.dataset.steamUnits || '';
const targetId = table.id || `${id}-table`;
table.id = targetId;
let headerWrapper = headerCell.querySelector('.boiler-panel__header');
let toggleButton;
let summary;
if (!headerWrapper) {
headerWrapper = document.createElement('div');
headerWrapper.className = 'boiler-panel__header';
toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.className = 'boiler-panel__toggle';
toggleButton.setAttribute('data-boiler-toggle', id);
toggleButton.setAttribute('aria-controls', targetId);
toggleButton.innerHTML = `
<span class="boiler-panel__title">${title}</span>
<span class="boiler-panel__indicator" aria-hidden="true"></span>
`;
summary = document.createElement('span');
summary.className = 'boiler-panel__summary';
summary.setAttribute('data-boiler-summary', '');
summary.textContent = formatSummary(steamValue, steamUnits);
headerWrapper.appendChild(toggleButton);
headerWrapper.appendChild(summary);
headerCell.innerHTML = '';
headerCell.appendChild(headerWrapper);
toggleButton.addEventListener('click', () => {
const nextCollapsed = !table.classList.contains('is-collapsed');
applyCollapsedState(table, nextCollapsed);
toggleButton.setAttribute('aria-expanded', (!nextCollapsed).toString());
state[id] = nextCollapsed;
saveState(state);
});
table.dataset.boilerInitialised = 'true';
} else {
toggleButton = headerWrapper.querySelector('.boiler-panel__toggle');
summary = headerWrapper.querySelector('[data-boiler-summary]');
}
if (toggleButton) {
toggleButton.setAttribute('aria-controls', targetId);
}
if (summary) {
summary.textContent = formatSummary(steamValue, steamUnits);
}
const collapsed = state[id] === true;
applyCollapsedState(table, collapsed);
if (toggleButton) {
toggleButton.setAttribute('aria-expanded', (!collapsed).toString());
}
};
const initialisePanel = (panel) => {
const state = loadState();
const tables = panel.querySelectorAll(TABLE_SELECTOR);
tables.forEach((table) => initialiseTable(table, state));
};
const handlePanelUpdate = (event) => {
if (!event || !event.currentTarget) {
return;
}
initialisePanel(event.currentTarget);
};
const setup = () => {
const panel = document.querySelector(PANEL_SELECTOR);
if (!panel) {
return;
}
panel.addEventListener('livepanel:updated', handlePanelUpdate);
if (panel.innerHTML.trim().length) {
initialisePanel(panel);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setup, { once: true });
} else {
setup();
}
})();

View File

@@ -0,0 +1,158 @@
(() => {
const PANEL_SELECTOR = '[data-refresh-url="data/fabrication.php"]';
const TABLE_SELECTOR = '[data-boiler-section]';
const STORAGE_KEY = 'lasuca.boilerPanels';
const loadState = () => {
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (!stored) {
return {};
}
const parsed = JSON.parse(stored);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch (error) {
console.warn('Unable to load boiler panel state from storage.', error);
return {};
}
};
const saveState = (state) => {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.warn('Unable to persist boiler panel state.', error);
}
};
const applyCollapsedState = (table, collapsed) => {
const rows = Array.from(table.rows).slice(1);
rows.forEach((row) => {
row.style.display = collapsed ? 'none' : '';
});
table.classList.toggle('is-collapsed', collapsed);
};
const formatSummary = (value, units) => {
if (value === undefined || value === null || value === '') {
return '';
}
const numeric = Number(value);
const formatted = Number.isFinite(numeric)
? numeric.toLocaleString(undefined, { maximumFractionDigits: 1 })
: value;
const unitPart = units ? ` ${units}` : '';
return `Steam Flow: ${formatted}${unitPart}`;
};
const initialiseTable = (table, state) => {
const id = table.dataset.boilerSection;
if (!id) {
return;
}
const headerRow = table.querySelector('tr');
const headerCell = headerRow ? headerRow.querySelector('td, th') : null;
if (!headerCell) {
return;
}
const title = (table.dataset.boilerTitle || headerCell.textContent || '').trim() || 'Boiler';
const steamValue = table.dataset.steamValue || '';
const steamUnits = table.dataset.steamUnits || '';
const targetId = table.id || `${id}-table`;
table.id = targetId;
let headerWrapper = headerCell.querySelector('.boiler-panel__header');
let toggleButton;
let summary;
if (!headerWrapper) {
headerWrapper = document.createElement('div');
headerWrapper.className = 'boiler-panel__header';
toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.className = 'boiler-panel__toggle';
toggleButton.setAttribute('data-boiler-toggle', id);
toggleButton.setAttribute('aria-controls', targetId);
toggleButton.innerHTML = `
<span class="boiler-panel__title">${title}</span>
<span class="boiler-panel__indicator" aria-hidden="true"></span>
`;
summary = document.createElement('span');
summary.className = 'boiler-panel__summary';
summary.setAttribute('data-boiler-summary', '');
summary.textContent = formatSummary(steamValue, steamUnits);
headerWrapper.appendChild(toggleButton);
headerWrapper.appendChild(summary);
headerCell.innerHTML = '';
headerCell.appendChild(headerWrapper);
toggleButton.addEventListener('click', () => {
const nextCollapsed = !table.classList.contains('is-collapsed');
applyCollapsedState(table, nextCollapsed);
toggleButton.setAttribute('aria-expanded', (!nextCollapsed).toString());
state[id] = nextCollapsed;
saveState(state);
});
table.dataset.boilerInitialised = 'true';
} else {
toggleButton = headerWrapper.querySelector('.boiler-panel__toggle');
summary = headerWrapper.querySelector('[data-boiler-summary]');
}
if (toggleButton) {
toggleButton.setAttribute('aria-controls', targetId);
}
if (summary) {
summary.textContent = formatSummary(steamValue, steamUnits);
}
const collapsed = state[id] === true;
applyCollapsedState(table, collapsed);
if (toggleButton) {
toggleButton.setAttribute('aria-expanded', (!collapsed).toString());
}
};
const initialisePanel = (panel) => {
const state = loadState();
const tables = panel.querySelectorAll(TABLE_SELECTOR);
tables.forEach((table) => initialiseTable(table, state));
};
const handlePanelUpdate = (event) => {
if (!event || !event.currentTarget) {
return;
}
initialisePanel(event.currentTarget);
};
const setup = () => {
const panel = document.querySelector(PANEL_SELECTOR);
if (!panel) {
return;
}
panel.addEventListener('livepanel:updated', handlePanelUpdate);
if (panel.innerHTML.trim().length) {
initialisePanel(panel);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setup, { once: true });
} else {
setup();
}
})();

View File

@@ -0,0 +1,75 @@
(() => {
const HEADER_SELECTOR = '.app-header';
const HIDDEN_CLASS = 'app-header--hidden';
const MOBILE_BREAKPOINT = 960;
const SCROLL_THRESHOLD = 12;
let lastScrollY = window.scrollY || window.pageYOffset || 0;
let isHidden = false;
const header = document.querySelector(HEADER_SELECTOR);
if (!header) {
return;
}
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const shouldEnable = () => window.innerWidth <= MOBILE_BREAKPOINT && !prefersReducedMotion.matches;
const showHeader = (force = false) => {
if (isHidden || force) {
header.classList.remove(HIDDEN_CLASS);
isHidden = false;
}
};
const hideHeader = () => {
if (!isHidden) {
header.classList.add(HIDDEN_CLASS);
isHidden = true;
}
};
const handleScroll = () => {
const currentY = window.scrollY || window.pageYOffset || 0;
if (!shouldEnable() || currentY <= 0) {
showHeader(true);
lastScrollY = currentY;
return;
}
const delta = currentY - lastScrollY;
if (Math.abs(delta) <= SCROLL_THRESHOLD) {
lastScrollY = currentY;
return;
}
if (delta > 0) {
hideHeader();
} else {
showHeader();
}
lastScrollY = currentY;
};
const handleResize = () => {
lastScrollY = window.scrollY || window.pageYOffset || 0;
if (!shouldEnable()) {
showHeader(true);
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleResize);
if (typeof prefersReducedMotion.addEventListener === 'function') {
prefersReducedMotion.addEventListener('change', handleResize);
} else if (typeof prefersReducedMotion.addListener === 'function') {
prefersReducedMotion.addListener(handleResize);
}
handleResize();
})();

261
assets/js/refresh.js Normal file
View File

@@ -0,0 +1,261 @@
class LivePanel {
constructor(element) {
this.element = element;
this.url = element.dataset.refreshUrl;
const requestedInterval = Number(element.dataset.refreshInterval || 2000);
this.baseInterval = Number.isFinite(requestedInterval) && requestedInterval > 0 ? requestedInterval : 2000;
const minIntervalAttr = Number(element.dataset.refreshMinInterval);
const maxIntervalAttr = Number(element.dataset.refreshMaxInterval);
this.minInterval = Number.isFinite(minIntervalAttr) && minIntervalAttr > 0 ? minIntervalAttr : this.baseInterval;
this.maxInterval = Number.isFinite(maxIntervalAttr) && maxIntervalAttr > 0 ? Math.max(maxIntervalAttr, this.minInterval) : this.baseInterval * 4;
this.currentInterval = this.baseInterval;
this.lastEtag = null;
this.lastModified = null;
this.timeoutId = null;
this.isFetching = false;
if (!this.url) {
console.warn('LivePanel requires a data-refresh-url attribute.');
return;
}
this.renderLoadingState();
this.start();
LivePanel.instances.push(this);
}
renderLoadingState() {
if (!this.element.querySelector('.panel-loading')) {
this.element.innerHTML = `
<div class="panel-loading" role="status" aria-live="polite">
<span class="spinner" aria-hidden="true"></span>
<span>Loading latest data&hellip;</span>
</div>
`;
}
}
start() {
this.stop();
this.currentInterval = this.baseInterval;
this.fetchData();
}
stop() {
if (this.timeoutId) {
window.clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
scheduleNextFetch(delay) {
if (this.baseInterval <= 0) {
return;
}
if (this.timeoutId) {
window.clearTimeout(this.timeoutId);
}
let nextDelay = typeof delay === 'number' ? delay : this.currentInterval;
if (nextDelay !== 0) {
nextDelay = Math.min(Math.max(nextDelay, this.minInterval), this.maxInterval);
}
this.timeoutId = window.setTimeout(() => this.fetchData(), nextDelay);
}
async fetchData() {
if (this.isFetching) {
return;
}
this.isFetching = true;
try {
const headers = {
'X-Requested-With': 'XMLHttpRequest'
};
if (this.lastEtag) {
headers['If-None-Match'] = this.lastEtag;
}
if (this.lastModified) {
headers['If-Modified-Since'] = this.lastModified;
}
const response = await fetch(this.url, {
headers,
cache: 'no-cache'
});
if (response.status === 304) {
this.currentInterval = Math.min(this.currentInterval * 1.25, this.maxInterval);
return;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const etag = response.headers.get('ETag');
if (etag) {
this.lastEtag = etag;
}
const lastModified = response.headers.get('Last-Modified');
if (lastModified) {
this.lastModified = lastModified;
}
const html = await response.text();
if (html.trim().length) {
this.element.innerHTML = html;
}
const updateEvent = new CustomEvent('livepanel:updated', {
detail: {
panel: this,
html
}
});
this.element.dispatchEvent(updateEvent);
this.currentInterval = this.baseInterval;
} catch (error) {
console.error('Failed to refresh panel', error);
this.element.innerHTML = `
<div class="panel-error" role="alert">
<strong>Unable to load data.</strong>
<span>Please check your connection.</span>
</div>
`;
this.lastEtag = null;
this.lastModified = null;
this.currentInterval = Math.min(
Math.max(this.currentInterval * 1.5, this.minInterval),
this.maxInterval
);
} finally {
this.isFetching = false;
if (this.baseInterval > 0) {
this.scheduleNextFetch(this.currentInterval);
}
}
}
static pauseAll() {
LivePanel.instances.forEach((panel) => panel.stop());
}
static resumeAll() {
LivePanel.instances.forEach((panel) => panel.start());
}
}
LivePanel.instances = [];
function initialiseLivePanels() {
const panels = document.querySelectorAll('[data-refresh-url]');
panels.forEach((panel) => {
if (!panel.dataset.livePanelInitialised) {
panel.dataset.livePanelInitialised = 'true';
new LivePanel(panel);
}
});
}
function setupBackToTop() {
const backToTop = document.querySelector('[data-back-to-top]');
if (!backToTop) {
return;
}
const toggleVisibility = () => {
if (window.scrollY > 320) {
backToTop.classList.add('is-visible');
} else {
backToTop.classList.remove('is-visible');
}
};
window.addEventListener('scroll', toggleVisibility, { passive: true });
backToTop.addEventListener('click', (event) => {
event.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
function setupMobileNav() {
const toggle = document.querySelector('[data-menu-toggle]');
const sidebar = document.querySelector('.app-sidebar');
if (!toggle || !sidebar) {
return;
}
toggle.addEventListener('click', () => {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
sidebar.classList.toggle('is-open');
});
}
window.popupCenter = function popupCenter(pageURL, title = 'Details', width = 900, height = 640) {
const screenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX;
const screenTop = window.screenTop !== undefined ? window.screenTop : window.screenY;
const dualScreenWidth = window.innerWidth || document.documentElement.clientWidth || screen.width;
const dualScreenHeight = window.innerHeight || document.documentElement.clientHeight || screen.height;
const left = screenLeft + Math.max(0, (dualScreenWidth - width) / 2);
const top = screenTop + Math.max(0, (dualScreenHeight - height) / 2);
const features = [
'toolbar=no',
'location=no',
'directories=no',
'status=no',
'menubar=no',
'scrollbars=yes',
'resizable=yes',
`width=${width}`,
`height=${height}`,
`top=${top}`,
`left=${left}`
].join(',');
const newWindow = window.open(pageURL, title, features);
if (newWindow && newWindow.focus) {
newWindow.focus();
}
return newWindow;
};
window.popitup = function popitup(url, title = 'Details') {
const popup = window.open(url, title, 'width=420,height=520,scrollbars=yes');
if (popup && popup.focus) {
popup.focus();
}
return false;
};
window.addEventListener('DOMContentLoaded', () => {
initialiseLivePanels();
setupBackToTop();
setupMobileNav();
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
LivePanel.pauseAll();
} else {
LivePanel.resumeAll();
}
});
});

920
assets/js/tag-controls.js Normal file
View File

@@ -0,0 +1,920 @@
(() => {
const formatValue = (value) => {
if (value === null || value === undefined || Number.isNaN(Number(value))) {
return '—';
}
const numeric = Number(value);
const absValue = Math.abs(numeric);
let maximumFractionDigits = 0;
if (absValue !== Math.trunc(absValue)) {
maximumFractionDigits = absValue < 1 ? 3 : absValue < 10 ? 2 : 1;
}
return numeric.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits
});
};
document.addEventListener('DOMContentLoaded', () => {
const app = document.querySelector('[data-control-app]');
if (!app) {
return;
}
const select = app.querySelector('[data-control-select]');
const typeFilter = app.querySelector('[data-control-type-filter]');
const valueDisplay = app.querySelector('[data-current-value]');
const timestampDisplay = app.querySelector('[data-current-timestamp]');
const valueLabel = app.querySelector('[data-value-label]');
const pendingIndicator = app.querySelector('[data-pending-indicator]');
const numericContainer = app.querySelector('[data-numeric-controls]');
const booleanContainer = app.querySelector('[data-boolean-controls]');
const stepInput = app.querySelector('[data-step-input]');
const setContainer = app.querySelector('[data-set-form]');
const setInput = app.querySelector('[data-set-input]');
const applyButton = app.querySelector('[data-action="apply"]');
const decreaseBtn = app.querySelector('[data-action="decrease"]');
const increaseBtn = app.querySelector('[data-action="increase"]');
const booleanOffBtn = app.querySelector('[data-boolean="off"]');
const booleanOnBtn = app.querySelector('[data-boolean="on"]');
const refreshBtn = app.querySelector('[data-action="refresh"]');
const status = app.querySelector('[data-status]');
const defaultNumericSettings = {
stepValue: stepInput ? Number(stepInput.value) || 1 : 1,
stepAttr: stepInput ? (stepInput.getAttribute('step') || '0.1') : '0.1',
stepMin: stepInput ? (stepInput.getAttribute('min') || '') : '',
setStepAttr: setInput ? (setInput.getAttribute('step') || '0.1') : '0.1',
placeholder: setInput ? (setInput.getAttribute('placeholder') || '') : ''
};
const getUrl = app.dataset.getUrl;
const updateUrl = app.dataset.updateUrl;
const csrfToken = app.dataset.csrf;
if (!select || !getUrl || !updateUrl) {
return;
}
const placeholderOption = select.options.length > 0 ? select.options[0] : null;
const placeholderDefaultText = placeholderOption ? placeholderOption.textContent : '';
const state = {
busy: false,
selectedId: select ? (select.value || null) : null,
statusTimeout: null,
selectedMode: 'numeric',
currentValue: null,
stagedValue: null,
numericConfig: null,
datasetUnits: null,
units: null,
description: null
};
const parseDatasetNumber = (value) => {
if (value === undefined || value === null || value === '') {
return null;
}
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const extractNumericConfig = (option) => {
if (!option) {
return null;
}
const step = parseDatasetNumber(option.dataset.stepSize);
const min = parseDatasetNumber(option.dataset.minValue);
const max = parseDatasetNumber(option.dataset.maxValue);
const rawPrecision = option.dataset.precision;
const precisionNumber =
rawPrecision === undefined || rawPrecision === null || rawPrecision === ''
? null
: Number(rawPrecision);
const precision =
Number.isFinite(precisionNumber) && precisionNumber >= 0
? Math.round(precisionNumber)
: null;
const units = option.dataset.units && option.dataset.units !== '' ? option.dataset.units : null;
const description =
option.dataset.description && option.dataset.description !== ''
? option.dataset.description
: null;
return {
step: typeof step === 'number' && step > 0 ? step : null,
min: typeof min === 'number' ? min : null,
max: typeof max === 'number' ? max : null,
precision,
units,
description
};
};
const delay = (ms) => new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
const applyNumericConstraints = (value) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return value;
}
const config = state.numericConfig;
if (!config) {
return value;
}
let result = value;
if (typeof config.min === 'number' && result < config.min) {
result = config.min;
}
if (typeof config.max === 'number' && result > config.max) {
result = config.max;
}
if (typeof config.precision === 'number' && config.precision >= 0) {
result = Number(result.toFixed(config.precision));
}
return result;
};
const getUnitsLabel = () => state.units || state.datasetUnits || null;
const applyNumericConfig = (option) => {
const config = extractNumericConfig(option);
state.numericConfig = config;
state.datasetUnits = config ? config.units : null;
state.description = config ? config.description : null;
state.units = null;
if (stepInput) {
const stepValue =
config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepValue;
stepInput.value = stepValue;
stepInput.step =
config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepAttr;
if (config && typeof config.min === 'number') {
stepInput.min = config.min;
} else if (defaultNumericSettings.stepMin !== '') {
stepInput.min = defaultNumericSettings.stepMin;
} else {
stepInput.removeAttribute('min');
}
}
if (setInput) {
if (config && typeof config.min === 'number') {
setInput.min = config.min;
} else {
setInput.removeAttribute('min');
}
if (config && typeof config.max === 'number') {
setInput.max = config.max;
} else {
setInput.removeAttribute('max');
}
if (config && typeof config.step === 'number') {
setInput.step = config.step;
} else {
setInput.step = defaultNumericSettings.setStepAttr;
}
if (config && config.units) {
setInput.placeholder = config.units;
} else {
setInput.placeholder = defaultNumericSettings.placeholder;
}
}
};
const getActionableElements = () => [
stepInput,
setInput,
applyButton,
decreaseBtn,
increaseBtn,
refreshBtn,
booleanOffBtn,
booleanOnBtn
].filter(Boolean);
const resolveMode = (hint) => (hint === 'boolean' ? 'boolean' : 'numeric');
const isBooleanMode = () => state.selectedMode === 'boolean';
const valuesEqual = (left, right) => {
if (left === null || left === undefined) {
return right === null || right === undefined;
}
if (right === null || right === undefined) {
return false;
}
const leftNumber = Number(left);
const rightNumber = Number(right);
if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) {
return left === right;
}
return Math.abs(leftNumber - rightNumber) < 1e-9;
};
const parseNumericValue = (value) => {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
};
const formatBooleanDisplay = (value) => {
if (value === null || value === undefined) {
return '—';
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return String(value);
}
return numeric >= 0.5 ? 'ON' : 'OFF';
};
const updateBooleanButtons = (value) => {
if (!booleanOnBtn || !booleanOffBtn) {
return;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
booleanOnBtn.classList.remove('button--toggle-active');
booleanOffBtn.classList.remove('button--toggle-active');
booleanOnBtn.setAttribute('aria-pressed', 'false');
booleanOffBtn.setAttribute('aria-pressed', 'false');
return;
}
const isOn = numeric >= 0.5;
booleanOnBtn.classList.toggle('button--toggle-active', isOn);
booleanOffBtn.classList.toggle('button--toggle-active', !isOn);
booleanOnBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false');
booleanOffBtn.setAttribute('aria-pressed', !isOn ? 'true' : 'false');
};
const getVisualValue = () => {
if (isBooleanMode()) {
return state.currentValue;
}
if (Number.isFinite(state.stagedValue)) {
return state.stagedValue;
}
return state.currentValue;
};
const updatePendingIndicator = () => {
if (!pendingIndicator) {
return;
}
const pending = Boolean(
state.selectedId &&
!isBooleanMode() &&
Number.isFinite(state.stagedValue) &&
!valuesEqual(state.stagedValue, state.currentValue)
);
pendingIndicator.hidden = !pending;
};
const refreshValueDisplay = () => {
if (valueDisplay) {
const visualValue = getVisualValue();
valueDisplay.textContent = isBooleanMode()
? formatBooleanDisplay(visualValue)
: formatValue(visualValue);
}
updatePendingIndicator();
};
const isApplyReady = () => Boolean(
state.selectedId &&
!isBooleanMode() &&
Number.isFinite(state.stagedValue) &&
!valuesEqual(state.stagedValue, state.currentValue)
);
const updateApplyAvailability = () => {
if (!applyButton) {
return;
}
applyButton.disabled =
state.busy ||
!isApplyReady();
};
const setMode = (modeHint) => {
const mode = resolveMode(modeHint);
state.selectedMode = mode;
if (numericContainer) {
numericContainer.hidden = mode !== 'numeric';
}
if (booleanContainer) {
booleanContainer.hidden = mode !== 'boolean';
}
if (valueLabel) {
const unitsLabel = getUnitsLabel();
valueLabel.textContent = mode === 'boolean'
? 'Current state'
: `Current value${unitsLabel ? ` (${unitsLabel})` : ''}`;
}
if (setInput) {
if (mode === 'numeric') {
const unitsLabel = getUnitsLabel();
setInput.placeholder = unitsLabel || defaultNumericSettings.placeholder;
} else {
setInput.placeholder = '';
}
}
if (!isBooleanMode() && setInput && !Number.isFinite(state.stagedValue) && Number.isFinite(state.currentValue)) {
const constrainedValue = applyNumericConstraints(state.currentValue);
setInput.value = Number.isFinite(constrainedValue) ? constrainedValue : state.currentValue;
}
if (isBooleanMode()) {
updateBooleanButtons(state.currentValue);
}
refreshValueDisplay();
updateApplyAvailability();
};
const getSelectedOption = () => {
if (!state.selectedId) {
return null;
}
return Array.from(select.options).find((option) => option.value === state.selectedId) || null;
};
const updateModeFromSelection = () => {
const option = getSelectedOption();
applyNumericConfig(option);
const modeHint = option ? option.dataset.controlMode : 'numeric';
setMode(modeHint);
};
const applyTypeFilter = () => {
if (!select) {
return;
}
const filterValue = typeFilter ? typeFilter.value : '';
let visibleOptionExists = false;
Array.from(select.options).forEach((option, index) => {
if (index === 0) {
option.hidden = false;
option.disabled = false;
return;
}
const optionType = option.dataset.typeKey || '';
const isVisible = !filterValue || optionType === filterValue;
option.hidden = !isVisible;
option.disabled = !isVisible;
if (isVisible) {
visibleOptionExists = true;
}
});
if (select.value) {
const selected = select.options[select.selectedIndex];
if (!selected || selected.hidden || selected.disabled) {
select.value = '';
state.selectedId = null;
clearDisplay();
}
}
if (typeFilter) {
if (visibleOptionExists) {
typeFilter.removeAttribute('data-filter-empty');
} else {
typeFilter.setAttribute('data-filter-empty', 'true');
}
}
if (placeholderOption) {
placeholderOption.textContent = visibleOptionExists ? placeholderDefaultText : 'No tags for this type';
}
updateModeFromSelection();
updateControlAvailability();
};
const setStatus = (message, type = 'info') => {
if (!status) {
return;
}
if (state.statusTimeout) {
window.clearTimeout(state.statusTimeout);
state.statusTimeout = null;
}
if (!message) {
status.hidden = true;
status.textContent = '';
status.removeAttribute('data-status-type');
return;
}
status.hidden = false;
status.textContent = message;
status.dataset.statusType = type;
const timeout = type === 'error' ? 6000 : 2400;
state.statusTimeout = window.setTimeout(() => {
status.hidden = true;
status.textContent = '';
status.removeAttribute('data-status-type');
}, timeout);
};
const setBusy = (busy) => {
state.busy = busy;
select.disabled = busy;
updateControlAvailability();
};
const updateControlAvailability = () => {
const hasSelection = Boolean(state.selectedId);
const numericDisabled = state.busy || !hasSelection || isBooleanMode();
const booleanDisabled = state.busy || !hasSelection || !isBooleanMode();
if (stepInput) {
stepInput.disabled = numericDisabled;
}
if (setInput) {
setInput.disabled = numericDisabled;
}
if (decreaseBtn) {
decreaseBtn.disabled = numericDisabled;
}
if (increaseBtn) {
increaseBtn.disabled = numericDisabled;
}
if (booleanOffBtn) {
booleanOffBtn.disabled = booleanDisabled;
}
if (booleanOnBtn) {
booleanOnBtn.disabled = booleanDisabled;
}
if (refreshBtn) {
refreshBtn.disabled = state.busy || !hasSelection;
}
if (typeFilter) {
typeFilter.disabled = state.busy;
}
updateApplyAvailability();
};
const clearDisplay = () => {
state.currentValue = null;
state.stagedValue = null;
applyNumericConfig(null);
if (timestampDisplay) {
timestampDisplay.textContent = '';
}
if (setInput) {
setInput.value = '';
}
updateBooleanButtons(null);
setMode('numeric');
if (valueDisplay) {
valueDisplay.textContent = '—';
}
if (pendingIndicator) {
pendingIndicator.hidden = true;
}
};
const applyData = (data) => {
if (!data) {
clearDisplay();
return;
}
state.units = data.units || null;
if (!state.units && state.datasetUnits) {
state.units = state.datasetUnits;
}
if (data.description) {
state.description = data.description;
}
const resolvedMode = resolveMode(data.control_mode);
setMode(resolvedMode);
if (timestampDisplay) {
timestampDisplay.textContent = data.displayTimestamp || data.timestamp || '';
}
if (resolvedMode === 'boolean') {
const booleanValue = Number(data.value);
state.currentValue = Number.isFinite(booleanValue) ? booleanValue : null;
state.stagedValue = null;
updateBooleanButtons(state.currentValue);
refreshValueDisplay();
updateApplyAvailability();
} else {
const numericValue = parseNumericValue(data.value);
state.currentValue = numericValue;
stageNumericValue(numericValue);
}
updateControlAvailability();
};
const stageNumericValue = (value, { updateInput = true } = {}) => {
if (isBooleanMode()) {
return;
}
if (value === null || value === undefined || value === '') {
state.stagedValue = null;
if (setInput && updateInput) {
setInput.value = '';
}
refreshValueDisplay();
updateApplyAvailability();
return;
}
let numericValue = parseNumericValue(value);
if (!Number.isFinite(numericValue)) {
state.stagedValue = null;
if (setInput && updateInput) {
setInput.value = '';
}
refreshValueDisplay();
updateApplyAvailability();
return;
}
numericValue = applyNumericConstraints(numericValue);
state.stagedValue = numericValue;
if (setInput && updateInput) {
setInput.value = Number.isFinite(numericValue) ? numericValue : '';
}
refreshValueDisplay();
updateApplyAvailability();
};
const adjustStagedValue = (direction) => {
if (!state.selectedId || isBooleanMode() || !stepInput) {
return;
}
const step = parseNumericValue(stepInput.value);
if (step === null || step <= 0) {
setStatus('Step size must be greater than zero.', 'error');
return;
}
const base = Number.isFinite(state.stagedValue)
? state.stagedValue
: (Number.isFinite(state.currentValue) ? state.currentValue : 0);
const newValue = base + direction * Math.abs(step);
const constrainedValue = applyNumericConstraints(newValue);
stageNumericValue(constrainedValue);
if (!state.busy) {
setStatus('Adjustments pending. Press Apply to commit.', 'info');
}
updateControlAvailability();
};
const buildUrlWithId = (baseUrl, id) => {
const url = new URL(baseUrl, window.location.href);
url.searchParams.set('id', id);
return url.toString();
};
const fetchJson = async (input, init = {}) => {
const response = await fetch(input, init);
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload.success) {
const message = payload.message || `Request failed (HTTP ${response.status})`;
if (payload && payload.details) {
console.error('Request failed with details:', payload.details);
}
throw new Error(payload && payload.details ? `${message}${payload.details}` : message);
}
return payload;
};
const loadTagValue = async (silent = false) => {
if (!state.selectedId) {
clearDisplay();
return;
}
const maxAttempts = 2;
const retryDelayMs = 600;
setBusy(true);
try {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
if (!silent) {
const message = attempt === 1
? 'Loading current value&hellip;'
: 'Retrying load after a transient error&hellip;';
const tone = attempt === 1 ? 'info' : 'warning';
setStatus(message, tone);
}
try {
const payload = await fetchJson(
buildUrlWithId(getUrl, state.selectedId),
{
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
cache: 'no-cache'
}
);
applyData(payload.data);
if (!silent) {
setStatus('Latest value loaded.', 'success');
}
return;
} catch (error) {
console.warn(`Attempt ${attempt} to load tag value failed.`, error);
if (attempt < maxAttempts) {
await delay(retryDelayMs);
continue;
}
console.error(error);
throw error;
}
}
} catch (error) {
const message = error && error.message ? error.message : 'Failed to read tag value.';
setStatus(message, 'error');
clearDisplay();
} finally {
setBusy(false);
}
};
const sendUpdate = async (body, successMessage = 'Value updated successfully.') => {
if (!state.selectedId) {
return null;
}
try {
setBusy(true);
setStatus('Applying update&hellip;', 'info');
const payload = await fetchJson(updateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken || ''
},
body: JSON.stringify({
id: state.selectedId,
...body
})
});
applyData(payload.data);
let statusTone = 'success';
let statusMessage = successMessage;
if (payload.log) {
const { success, driver, error, path } = payload.log;
if (!success) {
statusTone = 'warning';
statusMessage = 'Value updated, but audit log failed. Check with an administrator.';
console.error('Control write audit log failed to persist.', payload.log);
} else if (driver && driver !== 'database') {
statusTone = 'warning';
statusMessage = 'Value updated. Audit log stored in fallback location.';
console.warn('Control write audit log fallback engaged.', payload.log);
if (path) {
console.info('Fallback log path:', path);
}
} else {
console.debug('Control write audit logged successfully.');
}
if (error) {
console.debug('Audit log diagnostics:', error);
}
}
setStatus(statusMessage, statusTone);
return payload;
} catch (error) {
console.error(error);
setStatus(error.message, 'error');
return null;
} finally {
setBusy(false);
}
};
if (select) {
select.addEventListener('change', () => {
state.selectedId = select.value || null;
if (!state.selectedId) {
clearDisplay();
updateControlAvailability();
return;
}
updateModeFromSelection();
state.currentValue = null;
state.stagedValue = null;
if (setInput) {
setInput.value = '';
}
refreshValueDisplay();
updateControlAvailability();
loadTagValue(true);
});
}
if (typeFilter) {
typeFilter.addEventListener('change', () => {
applyTypeFilter();
const filterEmpty = typeFilter.getAttribute('data-filter-empty') === 'true';
if (!select.value) {
state.selectedId = null;
clearDisplay();
if (filterEmpty) {
setStatus('No tags available for this type.', 'warning');
} else {
setStatus('');
}
} else {
state.selectedId = select.value;
setStatus('');
}
updateControlAvailability();
});
}
if (decreaseBtn) {
decreaseBtn.addEventListener('click', () => {
adjustStagedValue(-1);
});
}
if (increaseBtn) {
increaseBtn.addEventListener('click', () => {
adjustStagedValue(1);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadTagValue();
});
}
if (setInput) {
setInput.addEventListener('input', () => {
if (!state.selectedId || isBooleanMode()) {
return;
}
const raw = setInput.value;
if (raw === '') {
stageNumericValue(null, { updateInput: false });
updateControlAvailability();
return;
}
const numeric = parseNumericValue(raw);
if (numeric === null) {
state.stagedValue = null;
refreshValueDisplay();
updateApplyAvailability();
updateControlAvailability();
return;
}
stageNumericValue(numeric, { updateInput: false });
updateControlAvailability();
});
}
if (applyButton) {
applyButton.addEventListener('click', () => {
if (!isApplyReady()) {
setStatus('Provide a numeric value to apply.', 'error');
return;
}
const target = state.stagedValue;
sendUpdate({ value: target }, 'Value applied successfully.');
});
}
if (booleanOffBtn) {
booleanOffBtn.addEventListener('click', () => {
if (!state.selectedId || !isBooleanMode()) {
return;
}
if (valuesEqual(state.currentValue, 0)) {
setStatus('Switch is already Off.', 'info');
return;
}
sendUpdate({ value: 0 }, 'Switch turned off.');
});
}
if (booleanOnBtn) {
booleanOnBtn.addEventListener('click', () => {
if (!state.selectedId || !isBooleanMode()) {
return;
}
if (valuesEqual(state.currentValue, 1)) {
setStatus('Switch is already On.', 'info');
return;
}
sendUpdate({ value: 1 }, 'Switch turned on.');
});
}
applyTypeFilter();
updateControlAvailability();
if (state.selectedId) {
updateModeFromSelection();
loadTagValue(true);
}
});
})();