/* Mobile JavaScript for Seismo Fleet Manager */ /* Handles hamburger menu, modals, offline sync, and mobile interactions */ // ===== GLOBAL STATE ===== let currentUnitData = null; let isOnline = navigator.onLine; // ===== HAMBURGER MENU TOGGLE ===== function toggleMenu() { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('backdrop'); const hamburgerBtn = document.getElementById('hamburgerBtn'); if (sidebar && backdrop) { const isOpen = sidebar.classList.contains('open'); if (isOpen) { // Close menu sidebar.classList.remove('open'); backdrop.classList.remove('show'); hamburgerBtn?.classList.remove('menu-open'); document.body.style.overflow = ''; } else { // Open menu sidebar.classList.add('open'); backdrop.classList.add('show'); hamburgerBtn?.classList.add('menu-open'); document.body.style.overflow = 'hidden'; } } } // Close menu when clicking backdrop function closeMenuFromBackdrop() { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('backdrop'); const hamburgerBtn = document.getElementById('hamburgerBtn'); if (sidebar && backdrop) { sidebar.classList.remove('open'); backdrop.classList.remove('show'); hamburgerBtn?.classList.remove('menu-open'); document.body.style.overflow = ''; } } // Close menu when window is resized to desktop function handleResize() { if (window.innerWidth >= 768) { const sidebar = document.getElementById('sidebar'); const backdrop = document.getElementById('backdrop'); const hamburgerBtn = document.getElementById('hamburgerBtn'); if (sidebar && backdrop) { sidebar.classList.remove('open'); backdrop.classList.remove('show'); hamburgerBtn?.classList.remove('menu-open'); document.body.style.overflow = ''; } } } // ===== UNIT DETAIL MODAL ===== function openUnitModal(unitId, status = null, age = null) { const modal = document.getElementById('unitModal'); if (!modal) return; // Store the status info passed from the card // Accept status if it's a non-empty string, use age if provided or default to '--' const cardStatusInfo = (status && status !== '') ? { status: status, age: age || '--' } : null; console.log('openUnitModal:', { unitId, status, age, cardStatusInfo }); // Fetch unit data and populate modal fetchUnitDetails(unitId).then(unit => { if (unit) { currentUnitData = unit; // Pass the card status info to the populate function populateUnitModal(unit, cardStatusInfo); modal.classList.add('show'); document.body.style.overflow = 'hidden'; } }); } function closeUnitModal(event) { // Only close if clicking backdrop or close button if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) { return; } const modal = document.getElementById('unitModal'); if (modal) { modal.classList.remove('show'); document.body.style.overflow = ''; currentUnitData = null; } } async function fetchUnitDetails(unitId) { try { // Try to fetch from network first const response = await fetch(`/api/roster/${unitId}`); if (response.ok) { const unit = await response.json(); // Save to IndexedDB if offline support is available if (window.offlineDB) { await window.offlineDB.saveUnit(unit); } return unit; } } catch (error) { console.log('Network fetch failed, trying offline storage:', error); // Fall back to offline storage if (window.offlineDB) { return await window.offlineDB.getUnit(unitId); } } return null; } function populateUnitModal(unit, cardStatusInfo = null) { // Set unit ID in header const modalUnitId = document.getElementById('modalUnitId'); if (modalUnitId) { modalUnitId.textContent = unit.id; } // Populate modal content const modalContent = document.getElementById('modalContent'); if (!modalContent) return; // Use status from card if provided, otherwise get from snapshot or derive from unit let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit); console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo }); const statusColor = statusInfo.status === 'OK' ? 'green' : statusInfo.status === 'Pending' ? 'yellow' : statusInfo.status === 'Missing' ? 'red' : 'gray'; const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' : statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' : statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' : 'text-gray-600 dark:text-gray-400'; // Determine status label (show "Benched" instead of "Unknown" for non-deployed units) let statusLabel = statusInfo.status; if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) { statusLabel = 'Benched'; } // Create navigation URL for location const createNavUrl = (address, coordinates) => { if (address) { // Use address for navigation const encodedAddress = encodeURIComponent(address); // Universal link that works on iOS and Android return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`; } else if (coordinates) { // Use coordinates for navigation (format: lat,lon) const encodedCoords = encodeURIComponent(coordinates); return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`; } return null; }; const navUrl = createNavUrl(unit.address, unit.coordinates); modalContent.innerHTML = `
${statusLabel}
${statusInfo.age || '--'}

${unit.device_type || '--'}

${unit.unit_type ? `

${unit.unit_type}

` : ''} ${unit.project_id ? `

${unit.project_id}

` : ''} ${unit.address ? `
${navUrl ? ` ${unit.address} ` : `

${unit.address}

`}
` : ''} ${unit.coordinates && !unit.address ? `
${navUrl ? ` ${unit.coordinates} ` : `

${unit.coordinates}

`}
` : ''} ${unit.device_type === 'seismograph' ? ` ${unit.last_calibrated ? `

${unit.last_calibrated}

` : ''} ${unit.next_calibration_due ? `

${unit.next_calibration_due}

` : ''} ${unit.deployed_with_modem_id ? `

${unit.deployed_with_modem_id}

` : ''} ` : ''} ${unit.device_type === 'modem' ? ` ${unit.ip_address ? `

${unit.ip_address}

` : ''} ${unit.phone_number ? `

${unit.phone_number}

` : ''} ${unit.hardware_model ? `

${unit.hardware_model}

` : ''} ` : ''} ${unit.note ? `

${unit.note}

` : ''}

${unit.deployed ? 'Yes' : 'No'}

${unit.retired ? 'Yes' : 'No'}

`; // Update action buttons const editBtn = document.getElementById('modalEditBtn'); const deployBtn = document.getElementById('modalDeployBtn'); const deleteBtn = document.getElementById('modalDeleteBtn'); if (editBtn) { editBtn.onclick = () => { window.location.href = `/unit/${unit.id}`; }; } if (deployBtn) { deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit'; deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed); } if (deleteBtn) { deleteBtn.onclick = () => deleteUnit(unit.id); } } function getUnitStatus(unitId, unit = null) { // Try to get status from dashboard snapshot if it exists if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) { const unitStatus = window.lastStatusSnapshot.units[unitId]; return { status: unitStatus.status, age: unitStatus.age, last: unitStatus.last }; } // Fallback: if unit data is provided, derive status from deployment state if (unit) { if (unit.deployed) { // For deployed units without status data, default to "Unknown" return { status: 'Unknown', age: '--', last: '--' }; } else { // For benched units, use "N/A" which will be displayed as "Benched" return { status: 'N/A', age: '--', last: '--' }; } } return { status: 'Unknown', age: '--', last: '--' }; } async function toggleDeployStatus(unitId, deployed) { try { const formData = new FormData(); formData.append('deployed', deployed ? 'true' : 'false'); const response = await fetch(`/api/roster/edit/${unitId}`, { method: 'POST', body: formData }); if (response.ok) { showToast('✓ Unit updated successfully'); closeUnitModal(); // Trigger HTMX refresh if on roster page const rosterTable = document.querySelector('[hx-get*="roster"]'); if (rosterTable) { htmx.trigger(rosterTable, 'refresh'); } } else { showToast('❌ Failed to update unit', 'error'); } } catch (error) { console.error('Error toggling deploy status:', error); showToast('❌ Failed to update unit', 'error'); } } async function deleteUnit(unitId) { if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) { return; } try { const response = await fetch(`/api/roster/${unitId}`, { method: 'DELETE' }); if (response.ok) { showToast('✓ Unit deleted successfully'); closeUnitModal(); // Refresh roster page if present const rosterTable = document.querySelector('[hx-get*="roster"]'); if (rosterTable) { htmx.trigger(rosterTable, 'refresh'); } } else { showToast('❌ Failed to delete unit', 'error'); } } catch (error) { console.error('Error deleting unit:', error); showToast('❌ Failed to delete unit', 'error'); } } // ===== ONLINE/OFFLINE STATUS ===== function updateOnlineStatus() { isOnline = navigator.onLine; const offlineIndicator = document.getElementById('offlineIndicator'); if (offlineIndicator) { if (isOnline) { offlineIndicator.classList.remove('show'); // Trigger sync when coming back online if (window.offlineDB) { syncPendingEdits(); } } else { offlineIndicator.classList.add('show'); } } } window.addEventListener('online', updateOnlineStatus); window.addEventListener('offline', updateOnlineStatus); // ===== SYNC FUNCTIONALITY ===== async function syncPendingEdits() { if (!window.offlineDB) return; try { const pendingEdits = await window.offlineDB.getPendingEdits(); if (pendingEdits.length === 0) return; console.log(`Syncing ${pendingEdits.length} pending edits...`); for (const edit of pendingEdits) { try { const formData = new FormData(); for (const [key, value] of Object.entries(edit.changes)) { formData.append(key, value); } const response = await fetch(`/api/roster/edit/${edit.unitId}`, { method: 'POST', body: formData }); if (response.ok) { await window.offlineDB.clearEdit(edit.id); console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`); } else { console.error(`Failed to sync edit ${edit.id}`); } } catch (error) { console.error(`Error syncing edit ${edit.id}:`, error); // Keep in queue for next sync attempt } } // Show success toast showToast('✓ Synced successfully'); } catch (error) { console.error('Error in syncPendingEdits:', error); } } // Manual sync button function manualSync() { if (!isOnline) { showToast('⚠️ Cannot sync while offline', 'warning'); return; } syncPendingEdits(); } // ===== TOAST NOTIFICATIONS ===== function showToast(message, type = 'success') { const toast = document.getElementById('syncToast'); if (!toast) return; // Update toast appearance based on type toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500'); if (type === 'success') { toast.classList.add('bg-green-500'); } else if (type === 'error') { toast.classList.add('bg-red-500'); } else if (type === 'warning') { toast.classList.add('bg-yellow-500'); } toast.textContent = message; toast.classList.add('show'); // Auto-hide after 3 seconds setTimeout(() => { toast.classList.remove('show'); }, 3000); } // ===== BOTTOM NAV ACTIVE STATE ===== function updateBottomNavActiveState() { const currentPath = window.location.pathname; const navButtons = document.querySelectorAll('.bottom-nav-btn'); navButtons.forEach(btn => { const href = btn.getAttribute('data-href'); if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) { btn.classList.add('active'); } else { btn.classList.remove('active'); } }); } // ===== INITIALIZATION ===== document.addEventListener('DOMContentLoaded', () => { // Initialize online/offline status updateOnlineStatus(); // Update bottom nav active state updateBottomNavActiveState(); // Add resize listener window.addEventListener('resize', handleResize); // Close menu on navigation (for mobile) document.addEventListener('click', (e) => { const link = e.target.closest('a'); if (link && link.closest('#sidebar')) { // Delay to allow navigation to start setTimeout(() => { if (window.innerWidth < 768) { closeMenuFromBackdrop(); } }, 100); } }); // Prevent scroll when modals are open (iOS fix) document.addEventListener('touchmove', (e) => { const modal = document.querySelector('.unit-modal.show, #sidebar.open'); if (modal && !e.target.closest('.unit-modal-content, #sidebar')) { e.preventDefault(); } }, { passive: false }); console.log('Mobile.js initialized'); }); // ===== SERVICE WORKER REGISTRATION ===== if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('Service Worker registered:', registration); // Check for updates periodically setInterval(() => { registration.update(); }, 60 * 60 * 1000); // Check every hour }) .catch(error => { console.error('Service Worker registration failed:', error); }); // Listen for service worker updates navigator.serviceWorker.addEventListener('controllerchange', () => { console.log('Service Worker updated, reloading page...'); window.location.reload(); }); } // Export functions for global use window.toggleMenu = toggleMenu; window.closeMenuFromBackdrop = closeMenuFromBackdrop; window.openUnitModal = openUnitModal; window.closeUnitModal = closeUnitModal; window.manualSync = manualSync; window.showToast = showToast;