Jake Vanderwerf
4 hours ago 56a9a1ccf764ff7a6af8f8a2292cb07443cb4aa7
assets/js/concise/Queue.js
@@ -1,906 +1,258 @@
/**
 * 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.subscribers = new Set();
      // Status definitions
      this.statuses = [
         'queued',
         'localProcessing',
         'uploading',
         'pending',
         'processing',
         'completed',
         'failed',
         'failed_permanent'
      ];
   constructor() {
      this.a11y = window.jvbA11y;
      this.error = window.jvbError;
      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.canUpdateUI = true;
      this.isProcessing = false;
      this.isPolling = false;
      this.queue = new Map();
      this.items = new Map();
      this.subscribers = new Set();
      this.loadFromStorage = false;
      this.api = jvbSettings.api;
      this.endpoint = 'queue';
      this.init();
   }
   init() {
      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.initElements();
      this.initListeners();
      if (this.ui.panel) {
         this.popup = new window.jvbPopup({
      this.initStore();
      if (this.canUpdateUI && this.ui.panel) {
         this.popup = window.jvbPopup.registerPopup({
            popup: this.ui.panel,
            toggle: this.ui.toggle,
            toggle: this.ui.toggle.button,
            name: 'Queue Panel',
         });
      }
      this.updateUI = () => window.debouncer.schedule('queue-ui-update', this._updateUI.bind(this), 100);
      this.initQueue();
      this.defineTemplates();
   }
   async initQueue() {
      const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false)
      if (incomplete.length > 0) {
         this.startPolling();
      } else {
         this.updateStatusPanel('synced');
      }
      this.store.subscribe((event, data) => {
         switch (event) {
            case 'data-loaded':
            case 'items-saved':
               // Initial load from IndexedDB
               const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
               if (incomplete.length > 0) {
                  this.startPolling();
               }
               this.updateUI();
               break;
            case 'item-saved':
               // Check for status changes
               if (data.item) {
                  const oldItem = this.store.data.get(data.item.id);
                  if (oldItem && oldItem.status !== data.item.status) {
                     this.handleOperationStatusChange(data.item, oldItem.status);
                  }
               }
               if (this.hasQueuedOperations()) {
                  this.startPolling();
               }
               break;
            default:
               this.updateUI();
               break;
         }
      });
      this.notify('queue-initialized', {operations: incomplete});
   }
   /**
    * Handle operation status changes and notify subscribers
    */
   handleOperationStatusChange(operation, oldStatus) {
      if (!operation || oldStatus === operation.status) return;
      // Notify based on new status
      switch(operation.status) {
         case 'completed':
            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: {},
         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;
      }
      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;
      }
      console.log('Added to Queue: ', item);
      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) {
      const item = this.store.get(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() {
      this.lastActivity = Date.now();
      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<void>}
    */
   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();
      const pending = this.getOperationsByStatus(['queued', 'completed', 'failed_permanent'], false);
      if (pending.length > 0) {
         this.startPolling();
         this.showQueue();
      } else {
         this.hideQueue();
      }
   }
   async processOperation(operation) {
      try {
         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 (response.ok && result.success !== false) {
            // Handle server-side merge
            if (result.id && operation.id !== result.id) {
               // Check if the returned ID exists locally
               const existingOp = this.getQueue(result.id);
               if (existingOp) {
                  // Merge data from both operations
                  existingOp.data = window.deepMerge(existingOp.data, operation.data);
                  existingOp.status = result.status || 'pending';
                  existingOp.serverData = result;
                  this.updateOperationStatus(existingOp.id, existingOp.status);
                  // Update the existing operation
                  this.setQueue(existingOp);
                  this.removeOperationFromUI(operation.id);
                  // Switch reference to the merged operation
                  operation = existingOp;
               } else {
                  // Server merged with an operation we don't have locally
                  // Update the ID and continue
                  this.clearQueue(operation.id);
                  operation.id = result.id;
                  operation.status = result.status || 'pending';
                  operation.serverData = result;
                  this.updateOperationStatus(operation.id, operation.status);
                  this.setQueue(operation);
               }
            } else {
               // Normal processing - no merge
               operation.status = result.status || 'pending';
               operation.serverData = result;
               this.updateOperationStatus(operation.id, operation.status);
               this.setQueue(operation);
            }
            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);
      }
   }
   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
            const incomplete = this.getOperationsByStatus(['completed', 'failed_permanent'], false);
            if (incomplete.length === 0) {
               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;
      }
   }
   /***********************************************************
   USER ACTIONS
    ***********************************************************/
   /**
    *
    * @param {array} ids
    * @param {string }action
    * @returns {Promise<void>}
    */
   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) => {
         const hasPending = this.getOperationsByStatus(['queued', 'uploading']);
         if (hasPending.length > 0) {
            e.preventDefault();
            return 'You have unsaved changes in the queue.';
         }
      };
      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.clearHttpHeaders(); // Clear cached headers first
         this.store.fetch();
      } else if (e.target.closest(this.selectors.clearButton)) {
         const completedOps = this.getOperationsByStatus('completed');
         if (completedOps.length > 0) {
            const ids = completedOps.map(op => op.id);
            this.updateServerOperations(ids, 'dismiss');
         }
      } else if (e.target.closest(this.selectors.retryButton)) {
         const failedOps = this.getOperationsByStatus('failed');
         if (failedOps.length > 0) {
            const ids = failedOps.map(op => op.id);
            this.updateServerOperations(ids, '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);
      }
   }
   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'];
   /*********************************************
   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',
         toggle: {
            button: 'button.qtoggle',
            indicator: '.qtoggle .indicator',
            count: '.qtoggle .count'
         },
         refresh: {
            button: '#queue .m-actions .refresh',
            countdown: '#queue .m-actions .refresh .countdown'
         },
         popup: {
            popup: '#queue .popup',
            message: '#queue .popup span'
         },
         items: {
            container: '#queue .qitems',
         },
         actions: {
            retry: '#queue .retry-all',
            clear: '#queue .dismiss-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"]',
         }
            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;
      }
      if (!this.ui.panel) this.canUpdateUI = false;
   }
   _updateUI() {
      if (!this.canUpdateUI) {
         return;
      }
   defineTemplates() {
      const T = window.jvbTemplates;
      // 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 : '';
      T.define('emptyState');
      T.define('queueItem', {
         setup({el, refs, manyRefs, data}) {
            el.dataset.id = data.id;
         }
         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;
   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);
      this.visibilityHandler = this.handleVisibilityChange.bind(this);
      const operations = this.store.getFiltered();
      document.addEventListener('click', this.clickHandler);
      window.addEventListener('online', this.onlineHandler);
      window.addEventListener('offline', this.offlineHandler);
      // Clear container
      window.removeChildren(this.ui.itemsContainer);
      // window.addEventListener('beforeunload', this.unloadHandler);
      // 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.append(element);
         });
      document.addEventListener('visibilitychange', this.visibilityHandler);
   }
      handleOnline() {
         this.updatePanel('synced');
         if (this.getQueueByStatus(this.pendingStatuses).length > 0) {
            this.processQueue();
         }
      }
      handleOffline() {
         this.updatePanel('offline');
      }
      handleVisibilityChange(e) {
         if (this.isPolling && document.hidden) {
            this.stopPolling();
         } else {
            this.maybeStartPolling();
         }
      }
   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;
         }
   createOperationUI(operation) {
      const listItem = window.getTemplate('queueItem');
      listItem.dataset.id = operation.id;
      this.updateOperationUI(operation, listItem);
      return listItem;
   }
         const refreshPage = window.targetCheck(e, this.selectors.actions.refresh);
         if (refreshPage) {
            this.handleRefresh(opId);
            return;
         }
   updateOperationUI(item, element = null) {
      if (!element) {
         element = this.ui.itemsContainer?.querySelector(`[data-id="${item.id}"]`);
      }
      if (!element) {
         element = this.createOperationUI(item);
         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);
         }
      }
      // 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);
      Object.values(this.ui.filters).forEach(filterObj => {
         if (filterObj.input?.dataset.filter === filter) {
            filterObj.input.checked = true;
         }
      });
@@ -911,21 +263,886 @@
      }
   }
   /**************************************************************************
    HELPERS
   **************************************************************************/
   getOperationsByStatus(status, include = true) {
      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;
         }
      }
      if (!Array.isArray(status) && typeof status === 'string') {
   initStore() {
      if (!this.user) return;
      const store = window.jvbStore.register(
         'queue',
         {
            storeName: 'queue',
            keyPath: 'id',
            endpoint: this.endpoint,
            TTL: Infinity,
            isAuth: true,
            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 window.auth.fetch(
            `${this.api}${this.endpoint}`,
            {
               method: 'POST',
               headers: {
                  'Content-Type': 'application/json',
                  ... this.headers
               },
               body: JSON.stringify({
                  action,
                  ids: Array.isArray(statusOrId) ? statusOrId : [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);
      const remainingQueue = this.getQueueByStatus('queued');
      if (remainingQueue.length === 0) {
         this.stopActivityTracking();
      } else {
         // Still have queued items, restart activity tracking
         this.trackActivity();
      }
      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;
         let req;
         if (operation.data instanceof FormData) {
            operation.data.append('id', operation.id);
            operation.data.append('user', window.auth.getUser());
            requestBody = operation.data;
            req = operation.data;
         } else {
            req = {
               ...operation.data,
               id: operation.id,
               user: window.auth.getUser()
            };
            requestBody = JSON.stringify(req);
            operation.headers['Content-Type'] = 'application/json';
         }
         if (operation.endpoint === 'unknown' || requestBody === undefined || requestBody === null) return;
         const response = await window.auth.fetch(
            `${this.api}${operation.endpoint}`,
            {
               method: operation.method,
               headers: operation.headers,
               body: requestBody
            }
         );
         console.log('Sending request with data: ', req);
         const result = await response.json();
         if (skip) {
            operation.data = {};
         }
         console.log('Result: ', result);
         if (response.ok && result.success) {
            this.notify('sent-to-server', req);
            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 index = new Set();
      let ops = [
         ... Array.from(this.queue.values())
      ];
      if (!this.loadFromStorage) {
         this.loadFromStorage = true;
         ops = [
            ... ops,
            ...Array.from(this.store.data.values())
         ];
         ops = ops.filter(el => {
            const isAdded = index.has(el.id);
            index.add(el.id);
            return !isAdded;
         });
      }
      //Sort operations by operation updated_at
      return this.sortOperations(ops);
   }
   getQueueByStatus(status) {
      if (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));
      let ops = this.getAllQueue();
      return ops.filter(op => status.includes(op.status));
   }
   hasQueuedOperations() {
      return this.getOperationsByStatus('queued').length > 0;
   updateOperationStatus(itemID, status) {
      let item = this.getQueue(itemID);
      if (!item) return;
      if (!this.statuses.includes(status)) {
         console.log('Invalid status: ', 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();
         let response = await this.store.fetch();
         if (response.status === 429) {
            console.log('Too many requests. Waiting 30 seconds');
            this.stopPolling();
            this.startCountdown(30, () => this.runPollCycle());
            return;
         }
         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;
         let activeCount = operations.filter(op =>
            [...this.pendingStatuses, ...this.workingStatuses].includes(op.status)
         );
         activeCount = activeCount.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) {
      // Check server-provided percentage first
      if (op.progress_percentage !== undefined) {
         return op.progress_percentage;
      }
      // Legacy: check old 'progress' field
      if (op.progress !== undefined) {
         return op.progress;
      }
      // Fallback to status-based calculation
      if (!this.statuses.includes(op.status)) return 0;
      const 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',
         'merged': 'Merged'
      };
      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':
            // Show progress count if available
            if (item.count && item.progress_count !== undefined) {
               const processed = item.progress_count;
               const total = item.count;
               const percentage = Math.round((processed / total) * 100);
               return `Processing ${processed}/${total} items (${percentage}%)`;
            }
            // Fallback to percentage only
            if (item.progress_percentage !== undefined) {
               return `${item.progress_percentage}% complete`;
            }
            return 'Processing...';
         case 'completed':
            return 'Successfully completed. Refresh to see changes.';
         case 'merged':
            return item.merged_into
               ? `Merged with another operation (${item.merged_into.substring(0, 8)}...)`
               : 'Merged with another operation';
         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) {
         const mappedOp = {
            ...localOp,
            ...serverOp,
            endpoint: localOp.endpoint,
            method: localOp.method,
            headers: localOp.headers,
            progress_percentage: serverOp.progress_percentage,
            progress_count: serverOp.progress_count,
            count: serverOp.count
         };
         if (serverOp.merged_into) {
            this.handleMergedOperation(mappedOp);
         }
      }
      // 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';
      const mappedOp = {
         ...serverOp,
         endpoint: endpoint,
         method: 'POST',
         headers: { ...this.headers },
      };
      if (serverOp.merged_into) {
         this.handleMergedOperation(mappedOp);
      }
      return mappedOp
   }
   /**
    * Handle merged operations
    * The target operation already has merged data from server,
    * so we just need to clean up the merged operation locally
    */
   handleMergedOperation(operation) {
      if (!operation.merged_into) return;
      console.log(`[Queue] Operation ${operation.id} merged into ${operation.merged_into}`);
      // Auto-dismiss merged operation after brief display
      // The target operation already has all the merged data from server
      setTimeout(() => {
         this.clearQueue(operation.id);
         this.removeOperationFromUI(operation.id);
      }, 3000);
   }
   /****************************************************************************
    SUBSCRIPTION
    ****************************************************************************/
   subscribe(callback) {
      if (!this.subscribers) {
         return;
@@ -937,26 +1154,18 @@
   notify(event, data) {
      this.subscribers.forEach(cb => cb(event, data));
   }
   /**************************************************************************
   /****************************************************************************
    CLEANUP
   **************************************************************************/
    ****************************************************************************/
   destroy() {
      this.stopPolling();
      if (this.isPolling) {
         this.stopPolling();
      }
      this.stopActivityTracking();
      if (this.clickHandler) {
         document.removeEventListener('click', this.clickHandler);
      }
      if (this.keyHandler) {
         document.removeEventListener('keydown', this.keyHandler);
      }
      document.removeEventListener('click', this.clickHandler);
      this.subscribers.clear();
   }
}
document.addEventListener('DOMContentLoaded', async function() {
   window.auth.subscribe((event) => {
      if (event === 'auth-loaded') {
@@ -964,3 +1173,4 @@
      }
   });
});