class QueueManager { constructor() { this.a11y = window.jvbA11y; this.error = window.jvbError; this.user = window.auth.getUser(); this.canUpdateUI = true; this.isProcessing = false; this.isPolling = false; this.queue = new Map(); this.items = new Map(); this.subscribers = new Set(); this.api = jvbSettings.api; this.endpoint = 'queue'; this.queueItems = new Map(); this.init(); } init() { this.headers = { 'X-WP-Nonce': window.auth.getNonce(), }; this.initElements(); this.initListeners(); this.initStore(); if (this.canUpdateUI && this.ui.panel) { this.popup = new window.jvbPopup({ popup: this.ui.panel, toggle: this.ui.toggle.button, name: 'Queue Panel', }); } this.defineTemplates(); } initElements() { this.panelStatuses = ['syncing', 'synced', 'pending', 'offline']; this.statuses = ['queued', 'localProcessing', 'uploading', 'pending', 'processing', 'completed', 'failed', 'failed_permanent']; this.pendingStatuses = ['queued', 'localProcessing', 'uploading']; this.workingStatuses = ['pending','processing']; this.completedStatuses = ['completed', 'failed', 'failed_permanent']; this.icons = { queued: 'arrows-clockwise', localProcessing: 'arrows-clockwise', uploading: 'syncing', pending: 'cloud', processing: 'syncing', completed: 'cloud-check', failed: 'cloud-warning', failed_permanent: 'cloud-warning' }; this.selectors = { panel: 'aside#queue', toggle: { button: 'button.qtoggle', indicator: '.qtoggle .indicator', count: '.qtoggle .count' }, refresh: { button: '#queue .refresh .refreshNow', countdown: '#queue .refresh .countdown' }, popup: { popup: '#queue .popup', message: '#queue .popup span' }, items: { container: '#queue .qitems', }, actions: { retry: '#queue .retry-all', clear: '#queue .dismiss-all' }, filters: { filter: '#queue [data-filter]', all: { label: '#queue [for="qfilter-all"]', radio: '#queue [data-filter="all"]', count: '#queue [data-filter="all"] .count' }, queued: { label: '#queue [for="qfilter-queued"]', input: '#queue [data-filter="queued"]', count: '#queue [for="qfilter-queued"] .count' }, localProcessing: { label: '#queue [for="qfilter-localProcessing"]', input: '#queue [data-filter="localProcessing"]', count: '#queue [for="qfilter-localProcessing"] .count', }, uploading: { label: '#queue [for="qfilter-uploading"]', input: '#queue [data-filter="uploading"]', count: '#queue [for="qfilter-uploading"] .count', }, pending: { label: '#queue [for="qfilter-pending"]', input: '#queue [data-filter="pending"]', count: '#queue [for="qfilter-pending"] .count', }, processing: { label: '#queue [for="qfilter-processing"]', input: '#queue [data-filter="processing"]', count: '#queue [for="qfilter-processing"] .count', }, completed: { label: '#queue [for="qfilter-completed"]', input: '#queue [data-filter="completed"]', count: '#queue [for="qfilter-completed"] .count', }, failed: { label: '#queue [for="qfilter-failed"]', input: '#queue [data-filter="failed"]', count: '#queue [for="qfilter-failed"] .count', }, }, item: { type: '.type', status: '.status', details: '.info .details', icon: '.status .icon', startedAt: '.started time', completed: { wrap: '.completed', label: '.completed span', time: '.completed time', }, progress: { progress: '.progress', fill: '.progress .fill', details: '.progress .details', icon: '.progress .icon' }, actions: { cancel: 'button.cancel', retry: 'button.retry', refresh: 'button.refresh', dismiss: 'button.dismiss', } }, }; this.ui = window.uiFromSelectors(this.selectors); if (!this.ui.panel) this.canUpdateUI = false; } defineTemplates() { const T = window.jvbTemplates; T.define('emptyState'); T.define('queueItem', { setup({el, refs, manyRefs, data}) { el.dataset.id = data.id; } }); } initListeners() { this.activityListeners = null; this.clickHandler = this.handleClick.bind(this); this.onlineHandler = this.handleOnline.bind(this); this.offlineHandler = this.handleOffline.bind(this); this.unloadHandler = this.handleBeforeUnload.bind(this); document.addEventListener('click', this.clickHandler); window.addEventListener('online', this.onlineHandler); window.addEventListener('offline', this.offlineHandler); window.addEventListener('beforeunload', this.unloadHandler); } handleOnline() { this.updatePanel('synced'); if (this.getQueueByStatus(this.pendingStatuses).length > 0) { this.processQueue(); } } handleOffline() { this.updatePanel('offline'); } handleBeforeUnload(e) { if (!this.ui.panel) return; const total = this.getQueueByStatus(this.pendingStatuses).length; if (total > 0) { // Modern browsers ignore custom messages, but this triggers the native dialog e.preventDefault(); e.returnValue = ''; // Required for Chrome return ''; // Required for some older browsers } } handleClick(e) { if (!window.targetCheck(e, this.selectors.panel+', '+this.selectors.toggle.button)) return; const refresh = window.targetCheck(e, this.selectors.refresh.button); if (refresh) { this.ui.refresh.button.classList.add('fetching'); this.store.clearCache(); this.store.clearFilters(); this.store.fetch().finally(() => { this.ui.refresh.button.classList.remove('fetching'); }); return; } const refreshPage = window.targetCheck(e, this.selectors.actions.refresh); if (refreshPage) { this.handleRefresh(opId); return; } const clear = window.targetCheck(e, this.selectors.actions.clear); if (clear) { this.opActions('completed', 'dismiss').then(()=>{}); return; } const retry = window.targetCheck(e, this.selectors.actions.retry); if (retry) { this.opActions('failed', 'retry').then(()=>{}); return; } const action = window.targetCheck(e, '[data-action]'); if (action) { const opId = action.closest('[data-id]')?.dataset.id; if (opId) { this.opActions(opId, action.dataset.action); } return; } const filter = window.targetCheck(e, this.selectors.filters.filter); if (filter) { this.setFilter(filter.dataset.filter); } } setFilter(filter) { // Update active button Object.values(this.ui.filters).forEach(filterObj => { if (filterObj.input?.dataset.filter === filter) { filterObj.input.checked = true; } }); if (filter === 'all') { this.store.clearFilters(); } else { this.store.setFilter('status', filter); } } trackActivity() { if (!this.activityListeners) { const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; this.activityListeners = events.map(event => { const handler = () => this.resetActivityTimer(); document.addEventListener(event, handler, {passive: true}); return {event, handler}; }); } this.resetActivityTimer(); } resetActivityTimer() { if (this.activityTimer) { clearTimeout(this.activityTimer); } this.activityTimer = setTimeout(() => { this.processQueue(); }, 1750); } stopActivityTracking() { if (this.activityTimer) { clearTimeout(this.activityTimer); this.activityTimer = null; } if (this.activityListeners) { this.activityListeners.forEach(({event, handler}) => { document.removeEventListener(event, handler); }); this.activityListeners = null; } } initStore() { if (!this.user) return; const store = window.jvbStore.register( 'queue', { storeName: 'queue', keyPath: 'id', endpoint: this.endpoint, TTL: Infinity, indexes: [ {name: 'status', keyPath: 'status'}, {name: 'type', keyPath: 'type'}, ], filters: { user: window.auth.getUser() }, showLoading: false, } ) this.store = store.queue; this.store.subscribe((event, data) => { switch (event) { case 'data-loaded': const serverOps = this.store.getAll(); serverOps.forEach(serverOp => { const localOp = this.queue.get(serverOp.id); const mapped = this.mapServerOperation(serverOp); this.queue.set(mapped.id, mapped); // Notify if changed if (localOp && localOp.status !== mapped.status) { this.notify('operation-status', mapped); } }); this.maybeStartPolling(); this.updateUI(); break; case 'items-save': this.maybeStartPolling(); this.updateUI(); break; case 'item-saved': if (data.item) { this.queue.set(data.item.id, data.item); if (data.previousItem?.status !== data.item.status) { this.notify('operation-status', data.item); } } this.maybeStartPolling(); break; } }); } /** * Handle refresh button click - clears cache for the relevant store */ handleRefresh(opId) { const op = this.getQueue(opId); if (!op) return; // Determine which store to refresh based on operation type let storeName = null; // Map operation types to store names const typeToStore = { 'content_update': op.data?.posts ? Object.values(op.data.posts)[0]?.content : null, 'batch_creation': op.data?.content, 'image_upload': 'uploads', 'video_upload': 'uploads', 'document_upload': 'uploads', }; storeName = typeToStore[op.type]; // If we found a store name, clear its cache if (storeName && window.jvbStore) { const store = window.jvbStore.stores.get(storeName); if (store) { window.jvbStore.clearCache(storeName); window.jvbStore.fetch(storeName); // Give visual feedback const button = this.items.get(opId)?.ui?.actions?.refresh; if (button) { const originalText = button.querySelector('span').textContent; button.querySelector('span').textContent = 'Refreshed!'; button.disabled = true; setTimeout(() => { button.querySelector('span').textContent = originalText; button.disabled = false; }, 2000); } } } else { // Fallback: just reload the page if we can't determine the store if (confirm('Refresh the page to see changes?')) { window.location.reload(); } } } /**************************************************************************** OPERATIONS ****************************************************************************/ addToQueue(operation) { const item = { id: `u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, endpoint: null, method: 'POST', headers: {}, data: {}, delay: false, canMerge: true, popup: 'Saving changes...', title: 'Operation', status: 'queued', timestamp: Date.now(), created_at: new Date().toISOString(), retries: 0, user: this.user, ... operation }; item.headers = { ... this.headers, ... item.headers } if (!item.endpoint || !item.data) return null; if (item.popup && this.ui.popup?.message) { // Add popup support this.ui.popup.message.textContent = item.popup; this.ui.popup.popup.hidden = false; setTimeout(() => this.ui.popup.popup.hidden = true, 2000); } if (!item.delay) { this.queue.set(item.id, item); this.processOperation(item).then(()=> {}); this.store.clearCache(); this.maybeStartPolling(); this.toggleQueue(); return item.id; } const existingOps = Array.from(this.getAllQueue()).filter(op=> { return op.status === 'queued' && op.endpoint === item.endpoint && op.canMerge }); if (existingOps.length > 0) { const existing = existingOps[0]; existing.data = window.deepMerge(existing.data, item.data); existing.timestamp = Date.now(); this.setQueue(existing); this.updateOperationStatus(existing.id, existing.status); this.updateUI(); this.trackActivity(); return existing.id; } this.store.clearCache(); this.setQueue(item); this.updateOperationStatus(item.id, item.status); this.updateUI(); this.trackActivity(); return item.id; } async opActions(statusOrId, action) { //Extract ids based on status, if it exists if (this.statuses.includes(statusOrId)) { statusOrId = this.getQueueByStatus(statusOrId).map(op => op.id); } else if (typeof statusOrId === 'string') { //If it's still a string, wrap the id inside an array statusOrId = [statusOrId]; } if (statusOrId.length ===0) return; if (!['cancel', 'dismiss', 'retry'].includes(action)) return; const shouldRemove = ['cancel', 'dismiss'].includes(action); if (shouldRemove) { statusOrId.forEach(id => { this.removeOperationUI(id) }); } try { const response = await fetch( `${this.api}${this.endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ... this.headers }, body: JSON.stringify({ action, ids: statusOrId, user: this.user }) } ); if (!response.ok) { throw new Error(`${action} failed: ${response.status}`); } const result = await response.json(); if (!result.success) { throw new Error(result.message || `${action} operation failed`); } statusOrId.forEach(id => { let item = this.getQueue(id); if (item) { this.notify(`${action}-operation`, item); } if (shouldRemove) { this.clearQueue(id); } else { let item = this.getQueue(id); item.status = 'queued'; this.setQueue(item); this.updateOperationStatus(item.id, item.status); } }); if (action === 'retry') { this.trackActivity(); } this.updateUI(); return result; } catch (error) { await window.jvbError.log(error, { component: 'Queue', operation: 'performQueueAction', action: action, operationIds: statusOrId, itemCount: statusOrId.length }, () => this.opActions(statusOrId, action)); return {success: false, error: error.message}; } } async processQueue() { if (this.isProcessing) return; const queue = this.getQueueByStatus('queued'); if (queue.length === 0) { this.stopActivityTracking(); return; } this.setProcessing(); for (const operation of queue) { await this.processOperation(operation); } this.setProcessing(false); this.stopActivityTracking(); this.toggleQueue(this.maybeStartPolling()); } async processOperation(operation) { try { //Add it to memory if it isn't already there if (!this.queue.has(operation.id)) { this.queue.set(operation.id, operation); } let skip = false; if (operation.data?._isFormData && !operation.data instanceof FormData) { skip = true; operation.data = await this.store.objectToFormData(operation.data); } this.updateOperationStatus(operation.id, 'uploading'); let requestBody; if (operation.data instanceof FormData) { operation.data.append('id', operation.id); operation.data.append('user', window.auth.getUser()); requestBody = operation.data; } else { requestBody = JSON.stringify({ ...operation.data, id: operation.id, user: window.auth.getUser() }); operation.headers['Content-Type'] = 'application/json'; } if (requestBody === undefined || requestBody === null) return; const response = await fetch( `${this.api}${operation.endpoint}`, { method: operation.method, headers: operation.headers, body: requestBody } ); const result = await response.json(); if (skip) { operation.data = {}; } if (response.ok && result.success) { if (result.id && operation.id !== result.id) { operation = await this.handleServerMerge(operation, result); } else { operation.status = result.status??'pending'; operation.serverData = result; this.updateOperationStatus(operation.id, operation.status); } this.a11y.announce(`${operation.title} sent to server for processing`); } else { throw new Error(result.message || `HTTP ${response.status}`); } this.setQueue(operation); } catch (error) { console.error('Operation failed: ', error); operation.retries++; operation.lastError = error.message; if (operation.retries >= 3) { operation.status = 'failed_permanent'; } else { operation.status = 'failed'; } this.updateOperationStatus(operation.id, operation.status); this.setQueue(operation); } } async handleServerMerge(operation, result) { const existingOp = this.getQueue(result.id); if (existingOp) { operation.status = result.status||'pending'; operation.serverData = result; return this.mergeOp(existingOp, operation); } else { this.clearQueue(operation.id); this.setQueue(result); return result; } } mergeOp(oldOp, newOp) { oldOp.data = window.deepMerge(oldOp.data, newOp.data); oldOp.status = newOp.status; if (Object.hasOwn(newOp, 'serverData')) { oldOp.serverData = newOp.serverData; } this.updateOperationStatus(oldOp.id, oldOp.status); this.removeOperationUI(newOp.id); this.clearQueue(newOp.id); return oldOp; } sortByDate(ops) { return ops.sort((a, b) => { const aTime = a.updated_at ?? a.timestamp ?? 0; const bTime = b.updated_at ?? b.timestamp ?? 0; return aTime - bTime; }); } sortOperations(ops) { const statusPriority = { 'processing': 0, 'uploading': 1, 'pending': 2, 'queued': 3, 'localProcessing': 4, 'failed': 5, 'completed': 6, 'failed_permanent': 7 }; return ops.sort((a, b) => { // First by status priority const priorityDiff = (statusPriority[a.status] ?? 99) - (statusPriority[b.status] ?? 99); if (priorityDiff !== 0) return priorityDiff; // Then by updated_at (most recent first) const aTime = a.updated_at ?? a.timestamp ?? 0; const bTime = b.updated_at ?? b.timestamp ?? 0; return new Date(bTime) - new Date(aTime); }); } getAllQueue() { let ops = [... new Set([ ...Array.from(this.store.data.values()), ... Array.from(this.queue.values()) ])]; //Sort operations by operation updated_at return this.sortOperations(ops); } getQueueByStatus(status) { if (typeof status === 'string') { status = [status]; } let ops = [...new Set([ ...Array.from(this.store.filterByIndex({status: status})), ...Array.from(this.queue.values()).filter(op => status.includes(op.status)) ])]; return this.sortOperations(ops); } updateOperationStatus(itemID, status) { let item = this.getQueue(itemID); if (!item || !this.statuses.includes(status)) return; item.status = status; this.notify('operation-status', item); this.setQueue(item); } setQueue(item) { this.store.save(item); this.queue.set(item.id, item); } getQueue(itemID) { return this.queue.has(itemID) ? this.queue.get(itemID) : this.store.get(itemID); } clearQueue(itemID) { this.queue.delete(itemID); this.store.delete(itemID); } /**************************************************************************** POLLING ****************************************************************************/ maybeStartPolling() { const incomplete = this.getQueueByStatus([...this.pendingStatuses, ...this.workingStatuses]); if (incomplete.length > 0) { this.startPolling(); return true; } this.updatePanel('synced'); return false; } startPolling() { if (this.isPolling) return; this.isPolling = true; this.updatePanel('pending'); this.runPollCycle(); } async runPollCycle() { if (!this.isPolling) return; try { this.ui.refresh.button.classList.add('fetching'); this.store.clearCache(); await this.store.fetch(); this.ui.refresh.button.classList.remove('fetching'); if (!this.maybeStartPolling()) { this.stopPolling(); this.updatePanel('synced'); return; } } catch (error) { console.error('Polling error:', error); } // Schedule next poll with countdown this.startCountdown(5, () => this.runPollCycle()); } startCountdown(count, onComplete) { if (!this.ui.refresh.countdown) { console.warn('Countdown element not found'); return; } this.ui.refresh.countdown.classList.add('counting'); this.ui.refresh.countdown.textContent = count; this.countdownTimer = setInterval(() => { count--; if (count > 0) { this.ui.refresh.countdown.textContent = count; } else { this.stopCountdown(); if (onComplete) onComplete(); } }, 1000); } stopPolling() { if (!this.isPolling) return; this.isPolling = false; if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } this.stopCountdown(); } stopCountdown() { if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } this.ui.refresh.countdown.classList.remove('counting'); this.ui.refresh.countdown.textContent = ''; } /**************************************************************************** UI ****************************************************************************/ updateUI() { if (!this.canUpdateUI) return; window.debouncer.schedule( 'queue-ui', this.handleUpdateUI.bind(this) ) } handleUpdateUI() { const operations = this.getAllQueue(); this.ui.actions.retry.disabled = operations.filter(op => op.status === 'failed').length === 0; this.ui.actions.clear.disabled = operations.filter(op => op.status === 'completed').length ===0; const activeCount = operations.filter(op => [...this.pendingStatuses, ...this.workingStatuses].includes(op.status) ).length; this.ui.toggle.count.hidden = activeCount === 0; this.ui.toggle.count.textContent = activeCount; for (let status of this.statuses) { if (status === 'failed_permanent') continue; let total = operations.filter(op => op.status === status).length; this.ui.filters[status].label.hidden = total === 0; this.ui.filters[status].input.dataset.count = `${total}`; if (total > 0) { this.ui.filters[status].count.textContent = total; } else { this.ui.filters[status].count.textContent = ''; } } this.renderOperations(); } renderOperations() { if (!this.ui.items.container) return; const status = this.store.filters?.status ?? 'all'; const operations = (status === 'all') ? this.getAllQueue() : this.getQueueByStatus(status); const sortedOps = this.sortOperations(operations); if (sortedOps.length === 0) { window.removeChildren(this.ui.items.container); const empty = window.jvbTemplates.create('emptyQueue'); this.ui.items.container.append(empty); this.a11y.announce('No items in queue'); return; } else { this.ui.items.container.querySelector('.empty-group')?.remove(); } // Track which items should exist const expectedIds = new Set(sortedOps.map(op => op.id)); // Remove items that shouldn't exist this.items.forEach((item, id) => { if (!expectedIds.has(id)) { item.element?.remove(); this.items.delete(id); } }); // Update/add items in order sortedOps.forEach((op, index) => { let item = this.items.get(op.id); if (!item) { item = this.createOperationElement(op); } if (item?.element) { this.updateOperationUI(op.id); // Reorder by re-appending (moves to end in correct order) this.ui.items.container.append(item.element); } }); } createOperationElement(op) { const el = window.jvbTemplates.create('queueItem', op); const item = { element: el, ui: window.uiFromSelectors(this.selectors.item, el) }; this.items.set(op.id, item); return item; } updateOperationUI(opId) { let item = (this.items.has(opId)) ? this.items.get(opId) : this.createOperationElement(opId); if (!item) return; let op = this.getQueue(opId); let element = item.element; element.classList.remove(this.statuses); element.classList.add(op.status); let progress = this.getProgress(op); if (item.ui.type && item.ui.type.textContent !== op.title) item.ui.type.textContent = op.title; if (item.ui.status) { item.ui.status.title = this.statusLabel(op.status); } if (item.ui.icon) { item.ui.icon.className = `icon icon-${this.icons[op.status]}`; } if (item.ui.details) item.ui.details.textContent = this.itemMessage(op); if (item.ui.startedAt) { item.ui.startedAt.setAttribute('datetime', op.created_at); item.ui.startedAt.textContent = window.formatTimeAgo(op.created_at); } let text = op.status === 'completed' ? 'Completed: ' : 'Last updated: '; const shouldShowCompleted = op.status === 'completed' && (op.completed_at || op.updated_at); item.ui.completed.wrap.hidden = !shouldShowCompleted; if (shouldShowCompleted) { const completedTime = op.completed_at ?? op.updated_at; item.ui.completed.label.textContent = 'Completed: '; item.ui.completed.time.setAttribute('datetime', completedTime); item.ui.completed.time.textContent = window.formatTimeAgo(completedTime); } window.showProgress(item.ui.progress, progress, 100, this.statusLabel(op.status)); if (item.ui.actions.cancel) item.ui.actions.cancel.hidden = this.completedStatuses.includes(op.status); if (item.ui.actions['retry']) { if (op.retries >= 3) item.ui.actions['retry'].disabled = true; item.ui.actions['retry'].hidden = op.status !=='failed'; } if (item.ui.actions.dismiss) item.ui.actions.dismiss.hidden = this.pendingStatuses.includes(op.status); if (item.ui.actions.refresh) { item.ui.actions.refresh.hidden = op.status !== 'completed'; } } getProgress(op) { if (op.progress) return op.progress; if (!this.statuses.includes(op.status)) return 0; let statusProgress = { 'queued': 10, 'uploading': 25, 'pending': 40, 'processing':70, 'completed':100, 'failed':0, 'failed_permanent':0 }; return statusProgress[op.status]??0; } removeOperationUI(opId) { let op = this.items.get(opId); if (!op) return; window.fade(op.element, false); } updatePanel(status = 'syncing') { if (!this.ui.panel || !this.panelStatuses.includes(status)) return; this.ui.panel.classList.remove(...this.panelStatuses); this.ui.panel.classList.add(status); } /**************************************************************************** UTILITY ****************************************************************************/ statusLabel(status) { if (!this.statuses.includes(status)) return''; const labels = { 'queued': 'Queued', 'localProcessing': 'Processing locally', 'uploading': 'Uploading', 'pending': 'Waiting on server', 'processing': 'Processing', 'completed': 'Completed', 'failed': 'Failed', 'failed_permanent': 'Failed permanently' }; return labels[status]; } itemMessage(item) { if (Object.hasOwn(item, 'message') && item.message !== '') return item.message; if (Object.hasOwn(item, 'error_message') && item.error_message) return item.error_message; switch(item.status) { case 'queued': return 'Waiting to send...'; case 'uploading': return 'Sending to server...'; case 'pending': return item.position ? `Position ${item.position} in queue` : 'In server queue'; case 'processing': return item.progress ? `${item.progress}% complete` : 'Processing...'; case 'completed': return 'Successfully completed. Refresh to see changes.'; case 'failed': return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${2})`; case 'failed_permanent': return `Failed: ${item.lastError || 'Unknown error'}`; default: return ''; } } toggleQueue(on = true) { if (!this.ui.panel) return; this.ui.panel.hidden = !on; this.ui.toggle.button.hidden = !on; } setProcessing(on = true) { this.isProcessing = on; this.ui.toggle.button.classList.toggle('saving', on); } /** * Map server operation format to frontend format * Server uses: type, data (requestData), status (from state/outcome) * Frontend uses: endpoint, data, status, headers, method, etc. */ mapServerOperation(serverOp) { const localOp = this.queue.get(serverOp.id); // If we have local operation data, preserve it if (localOp && localOp.endpoint) { return { ...localOp, ...serverOp, endpoint: localOp.endpoint, method: localOp.method, headers: localOp.headers, }; } // Minimal mapping for server-only operations // Extract endpoint from type if possible, otherwise use type const endpoint = serverOp.type ? serverOp.type.replace('_update', '').replace('_', '/') : 'unknown'; return { ...serverOp, endpoint: endpoint, method: 'POST', headers: { ...this.headers }, }; } /**************************************************************************** SUBSCRIPTION ****************************************************************************/ subscribe(callback) { if (!this.subscribers) { return; } this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /**************************************************************************** CLEANUP ****************************************************************************/ destroy() { if (this.isPolling) { this.stopPolling(); } this.stopActivityTracking(); document.removeEventListener('click', this.clickHandler); this.subscribers.clear(); } } document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbQueue = new QueueManager(); } }); });