import { Controller } from "@hotwired/stimulus"; import L from "leaflet"; import { showFlashMessage } from "../maps/helpers"; export default class extends Controller { static targets = []; static values = { features: Object, userTheme: String, timezone: String } connect() { console.log("Family members controller connected"); // Wait for maps controller to be ready this.waitForMap(); } disconnect() { this.cleanup(); console.log("Family members controller disconnected"); } waitForMap() { // Find the maps controller element const mapElement = document.querySelector('[data-controller*="maps"]'); if (!mapElement) { console.warn('Maps controller element not found'); return; } // Wait for the maps controller to be initialized const checkMapReady = () => { if (window.mapsController && window.mapsController.map) { this.initializeFamilyFeatures(); } else { setTimeout(checkMapReady, 100); } }; checkMapReady(); } initializeFamilyFeatures() { this.map = window.mapsController.map; if (!this.map) { console.warn('Map not available for family members controller'); return; } // Initialize family member markers layer this.familyMarkersLayer = L.layerGroup(); this.familyMemberLocations = {}; // Object keyed by user_id for efficient updates this.familyMarkers = {}; // Store marker references by user_id // Expose controller globally for ActionCable channel window.familyMembersController = this; // Add to layer control immediately (layer will be empty until data is fetched) this.addToLayerControl(); // Listen for family data updates this.setupEventListeners(); } createFamilyMarkers() { // Clear existing family markers if (this.familyMarkersLayer) { this.familyMarkersLayer.clearLayers(); } // Clear marker references this.familyMarkers = {}; // Only proceed if family feature is enabled and we have family member locations if (!this.featuresValue.family || !this.familyMemberLocations || Object.keys(this.familyMemberLocations).length === 0) { return; } const bounds = []; Object.values(this.familyMemberLocations).forEach((location) => { if (!location || !location.latitude || !location.longitude) { return; } // Get the first letter of the email or use '?' as fallback const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; // Check if this is a recent update (within last 5 minutes) const isRecent = this.isRecentUpdate(location.updated_at); const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; // Create a distinct marker for family members with email initial const familyMarker = L.marker([location.latitude, location.longitude], { icon: L.divIcon({ html: `
${emailInitial}
`, iconSize: [24, 24], iconAnchor: [12, 12], className: markerClass }) }); // Format timestamp for display const timezone = this.timezoneValue || 'UTC'; const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone }); // Create small tooltip that shows automatically const tooltipContent = this.createTooltipContent(lastSeen, location.battery); const tooltip = familyMarker.bindTooltip(tooltipContent, { permanent: true, direction: 'top', offset: [0, -12], className: 'family-member-tooltip' }); // Create detailed popup that shows on click const popupContent = this.createPopupContent(location, lastSeen); familyMarker.bindPopup(popupContent); // Hide tooltip when popup opens, show when popup closes familyMarker.on('popupopen', () => { familyMarker.closeTooltip(); }); familyMarker.on('popupclose', () => { familyMarker.openTooltip(); }); this.familyMarkersLayer.addLayer(familyMarker); // Store marker reference by user_id for efficient updates this.familyMarkers[location.user_id] = familyMarker; // Add to bounds array for auto-zoom bounds.push([location.latitude, location.longitude]); }); // Store bounds for later use this.familyMemberBounds = bounds; } // Update a single family member's location in real-time updateSingleMemberLocation(locationData) { if (!this.featuresValue.family) return; if (!locationData || !locationData.user_id) return; // Update stored location data this.familyMemberLocations[locationData.user_id] = locationData; // If the Family Members layer is not currently visible, just store the data if (!this.map.hasLayer(this.familyMarkersLayer)) { return; } // Get existing marker for this user const existingMarker = this.familyMarkers[locationData.user_id]; if (existingMarker) { // Update existing marker position and content existingMarker.setLatLng([locationData.latitude, locationData.longitude]); // Update marker icon with pulse animation for recent updates const emailInitial = locationData.email_initial || locationData.email?.charAt(0)?.toUpperCase() || '?'; const isRecent = this.isRecentUpdate(locationData.updated_at); const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; const newIcon = L.divIcon({ html: `
${emailInitial}
`, iconSize: [24, 24], iconAnchor: [12, 12], className: markerClass }); existingMarker.setIcon(newIcon); // Update tooltip content const timezone = this.timezoneValue || 'UTC'; const lastSeen = new Date(locationData.updated_at).toLocaleString('en-US', { timeZone: timezone }); const tooltipContent = this.createTooltipContent(lastSeen, locationData.battery); existingMarker.setTooltipContent(tooltipContent); // Update popup content const popupContent = this.createPopupContent(locationData, lastSeen); existingMarker.setPopupContent(popupContent); } else { // Create new marker for this user this.createSingleFamilyMarker(locationData); } } // Check if location was updated within the last 5 minutes isRecentUpdate(updatedAt) { const updateTime = new Date(updatedAt); const now = new Date(); const diffMinutes = (now - updateTime) / 1000 / 60; return diffMinutes < 5; } // Create a marker for a single family member createSingleFamilyMarker(location) { if (!location || !location.latitude || !location.longitude) return; const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; const isRecent = this.isRecentUpdate(location.updated_at); const markerClass = isRecent ? 'family-member-marker family-member-marker-recent' : 'family-member-marker'; const familyMarker = L.marker([location.latitude, location.longitude], { icon: L.divIcon({ html: `
${emailInitial}
`, iconSize: [24, 24], iconAnchor: [12, 12], className: markerClass }) }); const timezone = this.timezoneValue || 'UTC'; const lastSeen = new Date(location.updated_at).toLocaleString('en-US', { timeZone: timezone }); const tooltipContent = this.createTooltipContent(lastSeen, location.battery); familyMarker.bindTooltip(tooltipContent, { permanent: true, direction: 'top', offset: [0, -12], className: 'family-member-tooltip' }); const popupContent = this.createPopupContent(location, lastSeen); familyMarker.bindPopup(popupContent); familyMarker.on('popupopen', () => { familyMarker.closeTooltip(); }); familyMarker.on('popupclose', () => { familyMarker.openTooltip(); }); this.familyMarkersLayer.addLayer(familyMarker); this.familyMarkers[location.user_id] = familyMarker; } createTooltipContent(lastSeen, battery) { const batteryInfo = battery !== null && battery !== undefined ? ` | Battery: ${battery}%` : ''; return `Last seen: ${lastSeen}${batteryInfo}`; } createPopupContent(location, lastSeen) { const isDark = this.userThemeValue === 'dark'; const bgColor = isDark ? '#1f2937' : '#ffffff'; const textColor = isDark ? '#f9fafb' : '#111827'; const mutedColor = isDark ? '#9ca3af' : '#6b7280'; const emailInitial = location.email_initial || location.email?.charAt(0)?.toUpperCase() || '?'; // Battery display with icon const battery = location.battery; const batteryStatus = location.battery_status; let batteryDisplay = ''; if (battery !== null && battery !== undefined) { // Determine battery color based on level and status let batteryColor = '#10B981'; // green if (batteryStatus === 'charging') { batteryColor = battery <= 50 ? '#F59E0B' : '#10B981'; // orange if low, green if high } else if (battery <= 20) { batteryColor = '#EF4444'; // red } else if (battery <= 50) { batteryColor = '#F59E0B'; // orange } // Helper function to get appropriate Lucide battery icon const getBatteryIcon = (battery, batteryStatus, batteryColor) => { const baseAttrs = `width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${batteryColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align: middle; margin-right: 4px;"`; // Charging icon if (batteryStatus === 'charging') { return ``; } // Full battery if (battery === 100 || batteryStatus === 'full') { return ``; } // Low battery (≤20%) if (battery <= 20) { return ``; } // Medium battery (21-50%) if (battery <= 50) { return ``; } // High battery (>50%, default to full) return ``; }; const batteryIcon = getBatteryIcon(battery, batteryStatus, batteryColor); batteryDisplay = `

${batteryIcon}Battery: ${battery}%${batteryStatus ? ` (${batteryStatus})` : ''}

`; } return `

${emailInitial} Family Member

Email: ${location.email || 'Unknown'}

Coordinates:
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}

${batteryDisplay}

Last seen: ${lastSeen}

`; } addToLayerControl() { // Add family markers layer to the maps controller's layer control if (window.mapsController && window.mapsController.layerControl && this.familyMarkersLayer) { // We need to recreate the layer control to include our new layer this.updateMapsControllerLayerControl(); } } updateMapsControllerLayerControl() { const mapsController = window.mapsController; if (!mapsController || typeof mapsController.updateLayerControl !== 'function') return; // Use the maps controller's helper method to update layer control mapsController.updateLayerControl({ "Family Members": this.familyMarkersLayer }); // Dispatch event to notify that Family Members layer is now available document.dispatchEvent(new CustomEvent('family:layer:ready', { detail: { layer: this.familyMarkersLayer } })); } setupEventListeners() { // Listen for family data updates (for real-time updates in the future) document.addEventListener('family:locations:updated', (event) => { this.familyMemberLocations = event.detail.locations; this.createFamilyMarkers(); }); // Listen for theme changes document.addEventListener('theme:changed', (event) => { this.userThemeValue = event.detail.theme; // Recreate popups with new theme this.createFamilyMarkers(); }); // Listen for layer control events this.setupLayerControlEvents(); } setupLayerControlEvents() { if (!this.map) return; // Listen for when the Family Members layer is added this.map.on('overlayadd', (event) => { if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) { // Refresh locations and zoom after data is loaded this.refreshFamilyLocations().then(() => { this.zoomToFitAllMembers(); }); // Set up periodic refresh while layer is active this.startPeriodicRefresh(); } }); // Listen for when the Family Members layer is removed this.map.on('overlayremove', (event) => { if (event.name === 'Family Members' && event.layer === this.familyMarkersLayer) { // Stop periodic refresh when layer is disabled this.stopPeriodicRefresh(); } }); } zoomToFitAllMembers() { if (!this.familyMemberBounds || this.familyMemberBounds.length === 0) { return; } // If there's only one member, center on them with a reasonable zoom if (this.familyMemberBounds.length === 1) { this.map.setView(this.familyMemberBounds[0], 13); return; } // For multiple members, fit bounds to show all of them const bounds = L.latLngBounds(this.familyMemberBounds); this.map.fitBounds(bounds, { padding: [50, 50], // Add padding around the edges maxZoom: 15 // Don't zoom in too close }); } startPeriodicRefresh() { // Clear any existing refresh interval this.stopPeriodicRefresh(); // Refresh family locations every 60 seconds while layer is active (as fallback to real-time) this.refreshInterval = setInterval(() => { if (this.map && this.map.hasLayer(this.familyMarkersLayer)) { this.refreshFamilyLocations(); } else { // Layer is no longer active, stop refreshing this.stopPeriodicRefresh(); } }, 60000); // 60 seconds (real-time updates via ActionCable are primary) } stopPeriodicRefresh() { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } // Method to manually update family member locations (for API calls) updateFamilyLocations(locations) { // Convert array to object keyed by user_id if (Array.isArray(locations)) { this.familyMemberLocations = {}; locations.forEach(location => { if (location.user_id) { this.familyMemberLocations[location.user_id] = location; } }); } else { this.familyMemberLocations = locations; } this.createFamilyMarkers(); // Dispatch event for other controllers that might be interested document.dispatchEvent(new CustomEvent('family:locations:updated', { detail: { locations: this.familyMemberLocations } })); } // Method to refresh family locations from API async refreshFamilyLocations() { if (!window.mapsController?.apiKey) { console.warn('API key not available for family locations refresh'); return; } try { const response = await fetch(`/api/v1/families/locations?api_key=${window.mapsController.apiKey}`, { headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', } }); if (!response.ok) { if (response.status === 403) { console.warn('Family feature not enabled or user not in family'); return; } throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); this.updateFamilyLocations(data.locations || []); // Show user feedback if this was a manual refresh if (this.showUserFeedback) { const count = data.locations?.length || 0; this.showFlashMessageToUser('notice', `Family locations updated (${count} members)`); this.showUserFeedback = false; // Reset flag } } catch (error) { console.error('Error refreshing family locations:', error); // Show error to user if this was a manual refresh if (this.showUserFeedback) { this.showFlashMessageToUser('error', 'Failed to refresh family locations'); this.showUserFeedback = false; // Reset flag } } } // Helper method to show flash messages using the imported helper showFlashMessageToUser(type, message) { showFlashMessage(type, message); } // Method for manual refresh with user feedback async manualRefreshFamilyLocations() { this.showUserFeedback = true; // Enable user feedback for this refresh await this.refreshFamilyLocations(); } cleanup() { // Stop periodic refresh this.stopPeriodicRefresh(); // Remove family markers layer from map if it exists if (this.familyMarkersLayer && this.map && this.map.hasLayer(this.familyMarkersLayer)) { this.map.removeLayer(this.familyMarkersLayer); } // Remove map event listeners if (this.map) { this.map.off('overlayadd'); this.map.off('overlayremove'); } // Remove document event listeners document.removeEventListener('family:locations:updated', this.handleLocationUpdates); document.removeEventListener('theme:changed', this.handleThemeChange); } // Expose layer for external access getFamilyMarkersLayer() { return this.familyMarkersLayer; } // Check if family features are enabled isFamilyFeatureEnabled() { return this.featuresValue.family === true; } // Get family marker count getFamilyMemberCount() { return this.familyMemberLocations ? Object.keys(this.familyMemberLocations).length : 0; } }