mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
* fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements * Pull only necessary data for map v2 points * Feature/raw data archive (#2009) * 0.36.2 (#2007) * fix: move foreman to global gems to fix startup crash (#1971) * Update exporting code to stream points data to file in batches to red… (#1980) * Update exporting code to stream points data to file in batches to reduce memory usage * Update changelog * Update changelog * Feature/maplibre frontend (#1953) * Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode * Update maplibre controller * Update changelog * Remove some console.log statements --------- Co-authored-by: Robin Tuszik <mail@robin.gg> * Remove esbuild scripts from package.json * Remove sideEffects field from package.json * Raw data archivation * Add tests * Fix tests * Fix tests * Update ExceptionReporter * Add schedule to run raw data archival job monthly * Change file structure for raw data archival feature * Update changelog and version for raw data archival feature --------- Co-authored-by: Robin Tuszik <mail@robin.gg> * Set raw_data to an empty hash instead of nil when archiving * Fix storage configuration and file extraction * Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation (#2018) * Consider MIN_MINUTES_SPENT_IN_CITY during stats calculation * Remove raw data from visited cities api endpoint * Use user timezone to show dates on maps (#2020) * Fix/pre epoch time (#2019) * Use user timezone to show dates on maps * Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates. * Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates. * Fix tests failing due to new index on stats table * Fix failing specs * Update redis client configuration to support unix socket connection * Update changelog * Fix kml kmz import issues (#2023) * Fix kml kmz import issues * Refactor KML importer to improve readability and maintainability * Implement moving points in map v2 and fix route rendering logic to ma… (#2027) * Implement moving points in map v2 and fix route rendering logic to match map v1. * Fix route spec * fix(maplibre): update date format to ISO 8601 (#2029) * Add verification step to raw data archival process (#2028) * Add verification step to raw data archival process * Add actual verification of raw data archives after creation, and only clear raw_data for verified archives. * Fix failing specs * Eliminate zip-bomb risk * Fix potential memory leak in js * Return .keep files * Use Toast instead of alert for notifications * Add help section to navbar dropdown * Update changelog * Remove raw_data_archival_job * Ensure file is being closed properly after reading in Archivable concern --------- Co-authored-by: Robin Tuszik <mail@robin.gg>
550 lines
20 KiB
JavaScript
550 lines
20 KiB
JavaScript
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: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
|
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: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
|
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: `<div style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.2); font-size: 14px; font-weight: bold; font-family: system-ui, -apple-system, sans-serif;">${emailInitial}</div>`,
|
|
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 `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="m11 7-3 5h4l-3 5"/><path d="M14.856 6H16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.935"/><path d="M22 14v-4"/><path d="M5.14 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.936"/></svg>`;
|
|
}
|
|
|
|
// Full battery
|
|
if (battery === 100 || batteryStatus === 'full') {
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 10v4"/><path d="M14 10v4"/><path d="M22 14v-4"/><path d="M6 10v4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
|
}
|
|
|
|
// Low battery (≤20%)
|
|
if (battery <= 20) {
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M22 14v-4"/><path d="M6 14v-4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
|
}
|
|
|
|
// Medium battery (21-50%)
|
|
if (battery <= 50) {
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 14v-4"/><path d="M22 14v-4"/><path d="M6 14v-4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
|
}
|
|
|
|
// High battery (>50%, default to full)
|
|
return `<svg xmlns="http://www.w3.org/2000/svg" ${baseAttrs}><path d="M10 10v4"/><path d="M14 10v4"/><path d="M22 14v-4"/><path d="M6 10v4"/><rect x="2" y="6" width="16" height="12" rx="2"/></svg>`;
|
|
};
|
|
|
|
const batteryIcon = getBatteryIcon(battery, batteryStatus, batteryColor);
|
|
|
|
batteryDisplay = `
|
|
<p style="margin: 0 0 8px 0; font-size: 13px;">
|
|
${batteryIcon}<strong>Battery:</strong> ${battery}%${batteryStatus ? ` (${batteryStatus})` : ''}
|
|
</p>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="family-member-popup" style="background-color: ${bgColor}; color: ${textColor}; padding: 12px; border-radius: 8px; min-width: 220px;">
|
|
<h3 style="margin: 0 0 12px 0; color: #10B981; font-size: 15px; font-weight: bold; display: flex; align-items: center; gap: 8px;">
|
|
<span style="background-color: #10B981; color: white; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold;">${emailInitial}</span>
|
|
Family Member
|
|
</h3>
|
|
<p style="margin: 0 0 8px 0; font-size: 13px;">
|
|
<strong>Email:</strong> ${location.email || 'Unknown'}
|
|
</p>
|
|
<p style="margin: 0 0 8px 0; font-size: 13px;">
|
|
<strong>Coordinates:</strong><br/>
|
|
${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}
|
|
</p>
|
|
${batteryDisplay}
|
|
<p style="margin: 0; font-size: 12px; color: ${mutedColor}; padding-top: 8px; border-top: 1px solid ${isDark ? '#374151' : '#e5e7eb'};">
|
|
<strong>Last seen:</strong> ${lastSeen}
|
|
</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|