/** * QueueManager * Uses DataStore for persistent storage */ class QueueManager { constructor(config = {}) { this.canUpdateUI = true; this.config = { apiBase: jvbSettings.api, maxRetries: 3, pollInterval: 5000, activityDelay: 2000, //2 seconds autosync: true, endpoint: 'queue', ...config }; // Queue state this.isProcessing = false; this.isPolling = false; this.queue = new Map(); this.subscribers = new Set(); // Status definitions this.statuses = [ 'queued', 'localProcessing', 'uploading', 'pending', 'processing', 'completed', 'failed', 'failed_permanent' ]; this.user = window.auth.getUser(); if (!this.user) { console.log('Queue: User not logged in, queue disabled'); this.store = null; this.canUpdateUI = false; return; } this.headers = { 'X-WP-Nonce': window.auth.getNonce(), ...config.headers }; this.a11y = window.jvbA11y; this.errors = window.jvbError; // Initialize DataStore for queue persistence const store = window.jvbStore.register('queue', { storeName: 'queue', keyPath: 'id', endpoint: this.config.endpoint, TTL: Infinity, indexes: [ {name: 'status', keyPath: 'status'}, {name: 'type', keyPath: 'type'}, ], showLoading: false, delayFetch: false, // Queue should fetch immediately }); this.store = store.queue; this.classes = [ 'offline', 'synced', 'pending' ]; // Initialize this.initUI(); this.initListeners(); if (this.ui.panel) { this.popup = window.jvbPopup.registerPopup({ popup: this.ui.panel, toggle: this.ui.toggle, name: 'Queue Panel', }); } this.updateUI = () => window.debouncer.schedule('queue-ui-update', this._updateUI.bind(this), 100); this.initQueue(); } async initQueue() { let polling = this.maybeStartPolling(); if (!polling) { this.updateStatusPanel('synced'); } this.store.subscribe((event, data) => { switch (event) { case 'data-loaded': case 'items-saved': this.maybeStartPolling(); this.updateUI(); break; case 'item-saved': console.log(data,'Item saved data'); if (data.previousItem && data.previousItem.status !== data.item.status) { this.handleOperationStatusChange(data.item, data.previousItem.status); } this.maybeStartPolling(); break; default: this.updateUI(); break; } }); } maybeStartPolling() { const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false); if (incomplete.length > 0) { this.startPolling(); return true; } return false; } /** * Handle operation status changes and notify subscribers */ handleOperationStatusChange(operation) { // Notify based on new status switch(operation.status) { case 'completed': console.log(operation); this.notify('operation-completed', operation); break; case 'failed': this.notify('operation-failed', operation); break; case 'failed_permanent': this.notify('operation-failed-permanent', operation); break; } } /** * * @param {object} operation * @param {string} operation.endpoint The endpoint, excluding the apiBase * @param {object} operation.data The data to save * @param {boolean} operation.canMerge Whether data can merge * @param {string} operation.title The title of the operation for the Queue Panel * @param {string} operation.popup The string to show in the popup * @param {object} operation.headers Optional additional headers. Defaults to the API nonce * * @returns {string|null} Returns the operation id, for reference */ addToQueue(operation) { const item = { id: `u${this.user}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`, endpoint: null, method: 'POST', headers: {}, data: {}, sendNow: false, // true = process immediately canMerge: true, popup: 'Saving changes...', title: 'Operation', status: 'queued', timestamp: Date.now(), retries: 0, user: this.user, ... operation }; item.headers = { ...this.headers, ...item.headers }; if (!item.endpoint || !item.data) { console.error('Invalid operation queued: missing endpoint or data'); return null; } if (item.sendNow) { this.processOperation(item).then(()=> {}); this.store.clearCache(); window.debouncer.schedule('fastQueue', this.startPolling.bind(this), 200); this.showQueue(); return item.id; } const existingOps = Array.from(this.store.data.values()).filter(op=> 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.updateOperationStatus(existing.id, existing.status); this.updateUI(); this.startActivityTracking(); return existing.id; } this.store.clearCache(); //Add new operation to DataStore this.setQueue(item); this.updateOperationStatus(item.id, item.status); this.updateUI(); this.startActivityTracking(); return item.id; } setQueue(item) { this.store.save(item); } updateOperationStatus(itemID, status) { let item = this.store.get(itemID); if (!item) return; // Update status item.status = status; this.notify('operation-status', item); this.updateOperationUI(item); } getQueue(itemID) { return this.store.get(itemID); } clearQueue(itemID) { this.store.delete(itemID); } startActivityTracking() { if (!this.activityListeners) { const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart']; this.activityListeners = activityEvents.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(); }, this.config.activityDelay); } 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; } } hideQueue(){ this.ui.panel.hidden = true; this.ui.toggle.hidden = true; } showQueue() { this.ui.panel.hidden = false; this.ui.toggle.hidden = false; } setProcessing(on) { this.isProcessing = on; this.ui.toggle.classList.toggle('saving', on); } /** * Send any queued operations to the server * @returns {Promise} */ async processQueue() { if (this.isProcessing) return; const queue = this.getOperationsByStatus('queued'); if (queue.length === 0) { this.stopActivityTracking(); return; } this.setProcessing(true); for (const operation of queue) { await this.processOperation(operation); } this.setProcessing(false); this.stopActivityTracking(); this.maybeStartPolling() ? this.showQueue() : this.hideQueue(); } async processOperation(operation, skip = false) { try { if (!skip) { this.updateOperationStatus(operation.id, 'uploading'); if (operation.data?._isFormData) { operation.data = await this.store.objectToFormData(operation.data); } } const url = `${this.config.apiBase}${operation.endpoint}`; let requestBody; if (operation.data instanceof FormData) { operation.data.append('id', operation.id); operation.data.append('user', this.user); requestBody = operation.data; } else { requestBody = JSON.stringify({ ...operation.data, id: operation.id, user: this.user }); operation.headers['Content-Type'] = 'application/json'; } const response = await fetch(url, { method: operation.method, headers: operation.headers, body: requestBody }); const result = await response.json(); if (skip) { operation.data = {}; } if (response.ok && result.success !== false) { // Handle server-side merge 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}`); } } catch (error) { console.error('Operation failed:', error); operation.retries++; operation.lastError = error.message; if (operation.retries >= this.config.maxRetries) { operation.status = 'failed_permanent'; } else { operation.status = 'failed'; operation.nextRetry = Date.now() + (Math.pow(2, operation.retries) * 1000); } this.updateOperationStatus(operation.id, operation.status); this.setQueue(operation); } } async handleServerMerge(operation, result) { const existingOp = this.getQueue(result.id); if (existingOp) { // Merge with existing local operation existingOp.data = window.deepMerge(existingOp.data, operation.data); existingOp.status = result.status || 'pending'; existingOp.serverData = result; this.updateOperationStatus(existingOp.id, existingOp.status); this.removeOperationFromUI(operation.id); this.clearQueue(operation.id); return existingOp; } else { // Server merged with unknown operation this.clearQueue(operation.id); operation.id = result.id; operation.status = result.status || 'pending'; operation.serverData = result; this.updateOperationStatus(operation.id, operation.status); return operation; } } startPolling() { if (this.isPolling) return; this.isPolling = true; this.updateStatusPanel('pending'); this.pollTimer = setInterval(async () => { try { this.store.clearCache(); await this.store.fetch(); // Fetches from server, updates store.data if (!this.maybeStartPolling()) { this.stopPolling(); this.updateStatusPanel('synced'); } } catch (error) { console.error('Polling error:', error); } }, this.config.pollInterval); } stopPolling() { if (!this.isPolling) return; this.isPolling = false; if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } } getOperationIds(operations) { return operations.map(op => op.id); } /*********************************************************** USER ACTIONS ***********************************************************/ /** * * @param {array} ids * @param {string }action * @returns {Promise} */ async updateServerOperations(ids, action) { ids = Array.isArray(ids) ? ids : (ids.includes(',') ? ids.split(',') : [ids]); ids = ids.filter(id => { let item = this.getQueue(id); return this.getAllowedActions(item.status).includes(action); }); if (ids.length === 0) return; // SINGLE place to handle UI removal const shouldRemove = ['cancel', 'dismiss'].includes(action); if (shouldRemove) { ids.forEach(id => this.removeOperationFromUI(id)); } try { const response = await fetch(`${this.config.apiBase}${this.config.endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.headers }, body: JSON.stringify({ ids, action, user: window.auth.getUser() }) }); 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`); } // SINGLE place to handle store updates ids.forEach(id => { let item = this.getQueue(id); this.notify(`${action}-operation`, item); if (shouldRemove) { this.clearQueue(id); } else { item.status = 'queued'; item.retries = 0; this.setQueue(item); this.updateOperationStatus(item.id, item.status); } }); if (action === 'retry') { this.startActivityTracking(); } this.updateUI(); return result; } catch (error) { // Log and let jvbError handle retry await window.jvbError.log(error, { component: 'QueueManager', operation: 'performQueueAction', action: action, operationIds: ids, itemCount: ids.length }, () => this.updateServerOperations(ids, action)); // Don't re-throw - error is logged and handled return { success: false, error: error.message }; } } getAllowedActions(status) { const actionMap = { 'queued': ['cancel'], 'localProcessing': ['cancel'], 'pending': ['cancel'], 'processing': [], 'completed': ['dismiss'], 'failed': ['retry', 'dismiss'], 'failed_permanent': ['dismiss'] }; return actionMap[status] || []; } /********************************************* LISTENERS *********************************************/ initListeners() { this.clickHandler = this.handleClick.bind(this); document.addEventListener('click', this.clickHandler); this.handleOnline = () => { this.updateStatusPanel(); if (this.hasQueuedOperations()) { this.processQueue(); } }; this.handleOffline = () => this.updateStatusPanel('offline'); this.handleBeforeUnload = (e) => { if (this.isPolling || this.isProcessing) { e.preventDefault(); return 'You have unsaved changes in the queue. Proceed?'; } }; window.addEventListener('online', this.handleOnline); window.addEventListener('offline', this.handleOffline); window.addEventListener('beforeunload', this.handleBeforeUnload); } handleClick(e) { if (!e.target.closest(this.selectors.panel, this.selectors.toggle)) { return; } if (e.target.closest(this.selectors.refreshButton)) { this.store.clearCache(); this.store.clearCache(); // Clear cached headers first this.store.fetch(); } else if (e.target.closest(this.selectors.clearButton)) { const completedOps = this.getOperationIds(this.getOperationsByStatus('completed')); if (completedOps.length > 0) { this.updateServerOperations(completedOps, 'dismiss'); } } else if (e.target.closest(this.selectors.retryButton)) { const failedOps = this.getOperationIds(this.getOperationsByStatus('failed')); if (failedOps.length > 0) { this.updateServerOperations(failedOps, 'retry'); } } else if (e.target.closest('[data-action]')) { const button = e.target.closest('[data-action]'); const operationId = button.closest('[data-id]')?.dataset.id; if (operationId) { this.updateServerOperations(operationId, button.dataset.action); } } else if (e.target.closest('.filters [data-filter]')) { const filter = e.target.closest('[data-filter]').dataset.filter; this.setFilter(filter); } } /********************************************* UI *********************************************/ initUI() { 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.qtoggle', refreshButton: 'button.refreshNow', countdown: '.countdown', indicator: '.qtoggle .indicator', count: '.qtoggle .count', popup: '.popup', itemsContainer: '.qitems', clearButton: '.dismiss-all', retryButton: '.retry-all', filters: { all: '.filters [data-filter="all"]', received: '.filters [data-filter="queued"]', localProcessing: '.filters [data-filter="localProcessing"]', uploading: '.filters [data-filter="uploading"]', pending: '.filters [data-filter="pending"]', processing: '.filters [data-filter="processing"]', completed: '.filters [data-filter="completed"]', failed: '.filters [data-filter="failed"]', } }; this.ui = window.uiFromSelectors(this.selectors); if (!this.ui.panel) { this.canUpdateUI = false; } } _updateUI() { if (!this.canUpdateUI) { return; } // Get current operations from store const operations = Array.from(this.store.data.values()); // Get stats from last fetch response (server-provided) const stats = this.store.lastResponse?.queue_stats || { queued: 0, localProcessing: 0, uploading: 0, pending: 0, processing: 0, completed: 0, failed: 0, failed_permanent: 0 }; // Update count badge if (this.ui.count) { const activeCount = operations.length - stats.completed; this.ui.count.textContent = activeCount > 0 ? activeCount : ''; this.ui.count.style.display = activeCount > 0 ? '' : 'none'; } // Update indicator if (this.ui.indicator) { const hasActive = stats.queued > 0 || stats.uploading > 0 || stats.pending > 0 || stats.processing > 0; this.ui.indicator.classList.toggle('active', hasActive); } // Update button states this.ui.clearButton.disabled = this.getOperationsByStatus('completed').length === 0; this.ui.retryButton.disabled = this.getOperationsByStatus('failed').length === 0 && this.getOperationsByStatus('failed_permanent').length === 0; // Update filter counts (from server stats) Object.entries(this.ui.filters).forEach(([status, button]) => { const count = status === 'all' ? operations.length : stats[status] || 0; const countEl = button.querySelector('.count'); if (countEl) { countEl.textContent = count > 0 ? count : ''; } button.setAttribute('data-count', count); }); // Render current operations this.renderOperations(); } getStatusLabel(status) { const labels = { 'queued': 'Queued', 'localProcessing': 'Processing locally', 'uploading': 'Uploading', 'pending': 'Waiting on server', 'processing': 'Processing', 'completed': 'Completed', 'failed': 'Failed (will retry)', 'failed_permanent': 'Failed permanently' }; return labels[status] || status; } getItemMessage(item) { if (item.message) return item.message; if (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'; case 'failed': return `Failed: ${item.lastError || 'Unknown error'} (Retry ${item.retries}/${this.config.maxRetries})`; case 'failed_permanent': return `Failed: ${item.lastError || 'Unknown error'}`; default: return ''; } } calculateProgress(item) { if (item.progress) return item.progress; // Estimate progress based on status const statusProgress = { 'queued': 10, 'uploading': 25, 'pending': 40, 'processing': 70, 'completed': 100, 'failed': 0, 'failed_permanent': 0 }; return statusProgress[item.status] || 0; } renderOperations() { if (!this.ui.itemsContainer) return; const operations = this.store.getFiltered(); // Clear container window.removeChildren(this.ui.itemsContainer); // Render operations or empty state if (operations.length === 0) { let empty = window.getTemplate('emptyQueue'); this.ui.itemsContainer.append(empty); this.a11y.announce('Nothing queued.'); } else { operations.forEach(op => { const element = this.createOperationUI(op); this.ui.itemsContainer.prepend(element); }); } } createOperationUI(operation) { const listItem = window.getTemplate('queueItem'); listItem.dataset.id = operation.id; this.updateOperationUI(operation, listItem); return listItem; } updateOperationUI(item, element = null) { if (!element) { element = this.ui.itemsContainer?.querySelector(`[data-id="${item.id}"]`); } if (!element) { element = this.createOperationUI(item); } // Remove old status classes this.statuses.forEach(status => element.classList.remove(status)); element.classList.add(item.status); // Update content let timeDisplay = ''; if (item.updated_at) { // Server now sends ISO format timestamps - much more reliable! timeDisplay = window.formatTimeAgo(new Date(item.updated_at)); } else if (item.created_at) { timeDisplay = window.formatTimeAgo(new Date(item.created_at)); } const progressPercent = this.calculateProgress(item); // Update text content safely const typeEl = element.querySelector('.type'); const statusEl = element.querySelector('.status'); const detailsEl = element.querySelector('.info .details'); const timeEl = element.querySelector('.info .time'); const progressFill = element.querySelector('.progress .fill'); if (typeEl) typeEl.textContent = item.title; if (statusEl) { statusEl.querySelector('.icon')?.remove(); let status = this.getStatusLabel(item.status); statusEl.title = status; statusEl.prepend(window.getIcon(this.icons[item.status])); statusEl.querySelector('span').textContent = status; } if (detailsEl) detailsEl.textContent = this.getItemMessage(item); if (timeEl) timeEl.textContent = timeDisplay; if (progressFill) progressFill.style.width = `${progressPercent}%`; // Update action buttons const actionsContainer = element.querySelector('.actions'); if (actionsContainer) { this.updateActionButtons(item, actionsContainer); } } updateActionButtons(item, container) { window.removeChildren(container); switch (item.status) { case 'queued': case 'localProcessing': case 'pending': // Show cancel button for in-progress items const cancelBtn = window.getTemplate('button'); cancelBtn.classList.add('cancel'); cancelBtn.dataset.action = 'cancel'; cancelBtn.textContent = 'Cancel'; container.appendChild(cancelBtn); break; case 'failed': case 'failed_permanent': // Show retry and dismiss buttons const retryBtn = window.getTemplate('button'); const dismissBtn = window.getTemplate('button'); retryBtn.classList.add('retry'); retryBtn.textContent = 'Retry'; retryBtn.disabled = item.retries >= this.maxRetries; retryBtn.dataset.action = 'retry'; dismissBtn.classList.add('dismiss'); dismissBtn.textContent = 'Dismiss'; dismissBtn.dataset.action = 'dismiss'; container.appendChild(retryBtn); container.appendChild(dismissBtn); break; case 'completed': // Show dismiss button only const dismissCompletedBtn = window.getTemplate('button'); dismissCompletedBtn.dataset.action = 'dismiss'; dismissCompletedBtn.classList.add('dismiss'); dismissCompletedBtn.textContent = 'Dismiss'; container.appendChild(dismissCompletedBtn); break; } } removeOperationFromUI(operationId) { const element = this.ui.itemsContainer?.querySelector(`[data-id="${operationId}"]`); if (element) { element.style.opacity = '0'; element.style.transform = 'scale(0.9)'; setTimeout(() => element.remove(), 300); } } updateStatusPanel(status) { this.ui.panel?.classList.remove(...this.classes); if (!this.classes.includes(status)) { return; } this.ui.panel?.classList.add(status); } /*************************************************** FILTERS **************************************************/ setFilter(filter) { // Update active button Object.values(this.ui.filters).forEach(button => { if (button) { button.classList.toggle('active', button.dataset.filter === filter); } }); if (filter === 'all') { this.store.clearFilters(); } else { this.store.setFilter('status', filter); } } /************************************************************************** HELPERS **************************************************************************/ getOperationsByStatus(status, include = true) { if (!Array.isArray(status) && typeof status === 'string') { status = [status]; } return (include) ? Array.from(this.store.data.values()).filter((item) => status.includes(item.status)) : Array.from(this.store.data.values()).filter((item) => !status.includes(item.status)); } hasQueuedOperations() { return this.getOperationsByStatus('queued').length > 0; } 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() { this.stopPolling(); this.stopActivityTracking(); if (this.clickHandler) { document.removeEventListener('click', this.clickHandler); } if (this.keyHandler) { document.removeEventListener('keydown', this.keyHandler); } this.subscribers.clear(); } } document.addEventListener('DOMContentLoaded', async function() { window.auth.subscribe((event) => { if (event === 'auth-loaded') { window.jvbQueue = new QueueManager(); } }); });