// Location search functionality for the map import { applyThemeToButton } from "./theme_utils"; class LocationSearch { constructor(map, apiKey, userTheme = 'dark') { this.map = map; this.apiKey = apiKey; this.userTheme = userTheme; this.searchResults = []; this.searchMarkersLayer = null; this.currentSearchQuery = ''; this.searchTimeout = null; this.suggestionsVisible = false; this.currentSuggestionIndex = -1; // Make instance globally accessible for popup buttons window.locationSearchInstance = this; this.initializeSearchBar(); } initializeSearchBar() { // Create search toggle button using Leaflet control (positioned below settings button) const SearchToggleControl = L.Control.extend({ onAdd: function(map) { const button = L.DomUtil.create('button', 'location-search-toggle'); button.innerHTML = '🔍'; // Style the button with theme-aware styling applyThemeToButton(button, this.userTheme); button.style.width = '48px'; button.style.height = '48px'; button.style.borderRadius = '4px'; button.style.padding = '0'; button.style.fontSize = '18px'; button.style.marginTop = '10px'; // Space below settings button button.title = 'Search locations'; button.id = 'location-search-toggle'; return button; } }); // Add the search toggle control to the map this.map.addControl(new SearchToggleControl({ position: 'topleft' })); // Use setTimeout to ensure the DOM element is available setTimeout(() => { // Get reference to the created button const toggleButton = document.getElementById('location-search-toggle'); if (toggleButton) { // Create inline search bar this.createInlineSearchBar(); // Store references this.toggleButton = toggleButton; this.searchVisible = false; // Bind events this.bindSearchEvents(); console.log('LocationSearch: Search button initialized successfully'); } else { console.error('LocationSearch: Could not find search toggle button'); } }, 100); } createInlineSearchBar() { // Create inline search bar that appears next to the search button const searchBar = document.createElement('div'); searchBar.className = 'location-search-bar absolute bg-white border border-gray-300 rounded-lg shadow-lg hidden'; searchBar.id = 'location-search-container'; // Use container ID for test compatibility searchBar.style.width = '400px'; // Increased width for better usability searchBar.style.maxHeight = '600px'; // Set max height for the entire search bar searchBar.style.padding = '12px'; // Increased padding searchBar.style.zIndex = '9999'; // Very high z-index to ensure visibility searchBar.style.overflow = 'visible'; // Allow content to overflow but results area will scroll searchBar.innerHTML = `
`; // Add search bar to the map container this.map.getContainer().appendChild(searchBar); // Store references this.searchBar = searchBar; this.searchInput = document.getElementById('location-search-input'); this.closeButton = document.getElementById('location-search-close'); this.suggestionsContainer = document.getElementById('location-search-suggestions'); this.suggestionsPanel = document.getElementById('location-search-suggestions-panel'); this.resultsContainer = document.getElementById('location-search-results'); this.resultsPanel = document.getElementById('location-search-results-panel'); // Set scrolling properties immediately for results container with !important this.resultsContainer.style.setProperty('max-height', '400px', 'important'); this.resultsContainer.style.setProperty('overflow-y', 'scroll', 'important'); this.resultsContainer.style.setProperty('overflow-x', 'hidden', 'important'); this.resultsContainer.style.setProperty('min-height', '0', 'important'); this.resultsContainer.style.setProperty('display', 'block', 'important'); // Set scrolling properties for suggestions container with !important this.suggestionsContainer.style.setProperty('max-height', '200px', 'important'); this.suggestionsContainer.style.setProperty('overflow-y', 'scroll', 'important'); this.suggestionsContainer.style.setProperty('overflow-x', 'hidden', 'important'); this.suggestionsContainer.style.setProperty('min-height', '0', 'important'); this.suggestionsContainer.style.setProperty('display', 'block', 'important'); console.log('LocationSearch: Set scrolling properties on containers'); // Prevent map scroll events when scrolling inside the search containers this.preventMapScrollOnContainers(); // No clear button or default panel in inline mode this.clearButton = null; this.defaultPanel = null; } preventMapScrollOnContainers() { // Prevent scroll events from bubbling to the map when scrolling inside search containers const containers = [this.resultsContainer, this.suggestionsContainer, this.searchBar]; containers.forEach(container => { if (container) { // Prevent wheel events (scroll) from reaching the map container.addEventListener('wheel', (e) => { e.stopPropagation(); }, { passive: false }); // Prevent touch scroll events from reaching the map container.addEventListener('touchstart', (e) => { e.stopPropagation(); }, { passive: false }); container.addEventListener('touchmove', (e) => { e.stopPropagation(); }, { passive: false }); container.addEventListener('touchend', (e) => { e.stopPropagation(); }, { passive: false }); // Also prevent mousewheel for older browsers container.addEventListener('mousewheel', (e) => { e.stopPropagation(); }, { passive: false }); // Prevent DOMMouseScroll for Firefox container.addEventListener('DOMMouseScroll', (e) => { e.stopPropagation(); }, { passive: false }); console.log('LocationSearch: Added scroll prevention to container', container.id || 'search-bar'); } }); } bindSearchEvents() { // Toggle search bar visibility this.toggleButton.addEventListener('click', (e) => { console.log('Search button clicked!'); e.preventDefault(); e.stopPropagation(); this.showSearchBar(); }); // Close search bar this.closeButton.addEventListener('click', () => { this.hideSearchBar(); }); // Search on Enter key this.searchInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { if (this.suggestionsVisible && this.currentSuggestionIndex >= 0) { this.selectSuggestion(this.currentSuggestionIndex); } } }); // Clear search (no clear button in inline mode, handled by close button) // Handle real-time suggestions this.searchInput.addEventListener('input', (e) => { const query = e.target.value.trim(); if (query.length > 0) { this.debouncedSuggestionSearch(query); } else { this.hideSuggestions(); this.showDefaultState(); } }); // Handle keyboard navigation for suggestions this.searchInput.addEventListener('keydown', (e) => { if (this.suggestionsVisible) { switch (e.key) { case 'ArrowDown': e.preventDefault(); this.navigateSuggestions(1); break; case 'ArrowUp': e.preventDefault(); this.navigateSuggestions(-1); break; case 'Escape': this.hideSuggestions(); this.showDefaultState(); break; } } }); // Close sidepanel on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.searchVisible) { this.hideSearchBar(); } }); // Close search bar when clicking outside (but not on map interactions) document.addEventListener('click', (e) => { if (this.searchVisible && !e.target.closest('#location-search-container') && !e.target.closest('#location-search-toggle') && !e.target.closest('.leaflet-container')) { // Don't close on map interactions this.hideSearchBar(); } }); // Maintain search bar position during map movements this.map.on('movestart zoomstart', () => { if (this.searchVisible) { // Store current button position before map movement this.storedButtonPosition = this.toggleButton.getBoundingClientRect(); } }); // Reposition search bar after map movements to maintain relative position this.map.on('moveend zoomend', () => { if (this.searchVisible && this.storedButtonPosition) { // Recalculate position based on new button position this.repositionSearchBar(); } }); } showLoading() { // Hide other panels and show results with loading this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); this.resultsContainer.innerHTML = `
Searching for "${this.escapeHtml(this.currentSearchQuery)}"...
`; } showError(message) { // Hide other panels and show results with error this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); this.resultsContainer.innerHTML = `
⚠️
Search Failed
${this.escapeHtml(message)}
`; } displaySearchResults(data) { // Hide other panels and show results this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); if (!data.locations || data.locations.length === 0) { this.resultsContainer.innerHTML = `
📍
No visits found
No visits found for "${this.escapeHtml(this.currentSearchQuery)}"
`; return; } this.searchResults = data.locations; this.clearSearchMarkers(); let resultsHtml = `
Found ${data.total_locations} location(s)
for "${this.escapeHtml(this.currentSearchQuery)}"
`; data.locations.forEach((location, index) => { resultsHtml += this.buildLocationResultHtml(location, index); }); this.resultsContainer.innerHTML = resultsHtml; this.bindResultEvents(); } buildLocationResultHtml(location, index) { const firstVisit = location.visits[location.visits.length - 1]; const lastVisit = location.visits[0]; // Group visits by year const visitsByYear = this.groupVisitsByYear(location.visits); return `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
${location.total_visits} visit(s)
first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)}
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
${year} ${yearVisits.length} visits
`).join('')}
`; } groupVisitsByYear(visits) { const groups = {}; visits.forEach(visit => { const year = new Date(visit.date).getFullYear().toString(); if (!groups[year]) { groups[year] = []; } groups[year].push(visit); }); // Sort years descending (most recent first) const sortedGroups = {}; Object.keys(groups) .sort((a, b) => parseInt(b) - parseInt(a)) .forEach(year => { sortedGroups[year] = groups[year]; }); return sortedGroups; } formatDateShort(dateString) { const date = new Date(dateString); return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }); } bindResultEvents() { // Bind click events to year toggles const yearToggles = this.resultsContainer.querySelectorAll('.year-toggle'); yearToggles.forEach(toggle => { toggle.addEventListener('click', (e) => { e.stopPropagation(); const locationIndex = parseInt(toggle.dataset.locationIndex); const year = toggle.dataset.year; this.toggleYear(locationIndex, year, toggle); }); }); // Bind click events to individual visits const visitResults = this.resultsContainer.querySelectorAll('.visit-item'); visitResults.forEach(visit => { visit.addEventListener('click', (e) => { e.stopPropagation(); // Prevent triggering other clicks const locationIndex = parseInt(visit.dataset.locationIndex); const visitIndex = parseInt(visit.dataset.visitIndex); this.focusOnVisit(this.searchResults[locationIndex], visitIndex); }); }); } toggleYear(locationIndex, year, toggleElement) { const yearVisitsContainer = document.getElementById(`year-${locationIndex}-${year}`); const arrow = toggleElement.querySelector('.year-arrow'); if (yearVisitsContainer.classList.contains('hidden')) { // Show visits yearVisitsContainer.classList.remove('hidden'); arrow.style.transform = 'rotate(90deg)'; arrow.textContent = '▼'; } else { // Hide visits yearVisitsContainer.classList.add('hidden'); arrow.style.transform = 'rotate(0deg)'; arrow.textContent = '▶'; } } focusOnLocation(location) { const [lat, lon] = location.coordinates; this.map.setView([lat, lon], 16); // Flash the marker const markers = this.searchMarkersLayer.getLayers(); const targetMarker = markers.find(marker => { const latLng = marker.getLatLng(); return Math.abs(latLng.lat - lat) < 0.0001 && Math.abs(latLng.lng - lon) < 0.0001; }); if (targetMarker) { targetMarker.openPopup(); } this.hideResults(); } focusOnVisit(location, visitIndex) { const visit = location.visits[visitIndex]; if (!visit) return; // Navigate to the visit coordinates (more precise than location coordinates) const [lat, lon] = visit.coordinates || location.coordinates; this.map.setView([lat, lon], 18); // Higher zoom for individual visit // Parse the visit timestamp to create a time filter const visitDate = new Date(visit.date); const startTime = new Date(visitDate.getTime() - (2 * 60 * 60 * 1000)); // 2 hours before const endTime = new Date(visitDate.getTime() + (2 * 60 * 60 * 1000)); // 2 hours after // Emit custom event for time filtering that other parts of the app can listen to const timeFilterEvent = new CustomEvent('locationSearch:timeFilter', { detail: { startTime: startTime.toISOString(), endTime: endTime.toISOString(), visitDate: visit.date, location: location.place_name, coordinates: [lat, lon] } }); document.dispatchEvent(timeFilterEvent); // Create a special marker for the specific visit this.addVisitMarker(lat, lon, visit, location); // DON'T hide results - keep sidebar open // this.hideResults(); } addVisitMarker(lat, lon, visit, location) { // Remove existing visit marker if any if (this.visitMarker) { this.map.removeLayer(this.visitMarker); } // Create a highlighted marker for the specific visit this.visitMarker = L.circleMarker([lat, lon], { radius: 12, fillColor: '#22c55e', // Green color to distinguish from search results color: '#ffffff', weight: 3, opacity: 1, fillOpacity: 0.9 }); const popupContent = `
${this.escapeHtml(location.place_name)}
${this.escapeHtml(location.address || '')}
Visit Details:
${this.formatDateTime(visit.date)}
Duration: ${visit.duration_estimate}
`; this.visitMarker.bindPopup(popupContent, { closeButton: true, autoClose: false, // Don't auto-close when clicking elsewhere closeOnEscapeKey: true, // Allow closing with Escape key closeOnClick: false // Don't close when clicking on map }); this.visitMarker.addTo(this.map); this.visitMarker.openPopup(); // Add event listener to clean up when popup is closed this.visitMarker.on('popupclose', () => { if (this.visitMarker) { this.map.removeLayer(this.visitMarker); this.visitMarker = null; } }); // Store reference for manual cleanup if needed this.currentVisitMarker = this.visitMarker; } clearSearch() { this.searchInput.value = ''; this.hideResults(); this.clearSearchMarkers(); this.clearVisitMarker(); this.currentSearchQuery = ''; } clearVisitMarker() { if (this.visitMarker) { this.map.removeLayer(this.visitMarker); this.visitMarker = null; } if (this.currentVisitMarker) { this.map.removeLayer(this.currentVisitMarker); this.currentVisitMarker = null; } // Remove any visit notifications const existingNotification = document.querySelector('.visit-navigation-notification'); if (existingNotification) { existingNotification.remove(); } } showSearchBar() { console.log('showSearchBar called'); if (!this.searchBar) { console.error('Search bar element not found!'); return; } // Position the search bar to the right of the search button at same height const buttonRect = this.toggleButton.getBoundingClientRect(); const mapRect = this.map.getContainer().getBoundingClientRect(); // Calculate position relative to the map container const left = buttonRect.right - mapRect.left + 15; // 15px gap to the right of button const top = buttonRect.top - mapRect.top; // Same height as button console.log('Positioning search bar at:', { left, top }); // Position search bar next to the button this.searchBar.style.left = left + 'px'; this.searchBar.style.top = top + 'px'; this.searchBar.style.transform = 'none'; // Remove any transforms this.searchBar.style.position = 'absolute'; // Position relative to map container // Show the search bar this.searchBar.classList.remove('hidden'); this.searchBar.style.setProperty('display', 'block', 'important'); this.searchBar.style.visibility = 'visible'; this.searchBar.style.opacity = '1'; this.searchVisible = true; console.log('Search bar positioned next to button'); // Focus the search input for immediate typing setTimeout(() => { if (this.searchInput) { this.searchInput.focus(); } }, 100); } repositionSearchBar() { if (!this.searchBar || !this.searchVisible) return; // Get current button position after map movement const buttonRect = this.toggleButton.getBoundingClientRect(); const mapRect = this.map.getContainer().getBoundingClientRect(); // Calculate new position const left = buttonRect.right - mapRect.left + 15; const top = buttonRect.top - mapRect.top; // Update search bar position this.searchBar.style.left = left + 'px'; this.searchBar.style.top = top + 'px'; console.log('Search bar repositioned after map movement'); } hideSearchBar() { this.searchBar.classList.add('hidden'); this.searchBar.style.display = 'none'; this.searchVisible = false; this.clearSearch(); this.hideResults(); this.hideSuggestions(); } showDefaultState() { // No default panel in inline mode, just hide suggestions and results this.hideSuggestions(); this.hideResults(); } clearSearchMarkers() { // Note: No longer using search markers, but keeping method for compatibility // Only clear visit markers if they exist if (this.searchMarkersLayer) { this.map.removeLayer(this.searchMarkersLayer); this.searchMarkersLayer = null; } } hideResults() { if (this.resultsPanel) { this.resultsPanel.classList.add('hidden'); } } // Suggestion-related methods debouncedSuggestionSearch(query) { // Clear existing timeout if (this.searchTimeout) { clearTimeout(this.searchTimeout); } // Set new timeout for debounced search this.searchTimeout = setTimeout(() => { this.performSuggestionSearch(query); }, 300); // 300ms debounce delay } async performSuggestionSearch(query) { if (query.length < 2) { this.hideSuggestions(); return; } // Show loading state for suggestions this.showSuggestionsLoading(); try { const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Suggestions failed: ${response.status}`); } const data = await response.json(); this.displaySuggestions(data.suggestions || []); } catch (error) { console.error('Suggestion search error:', error); this.hideSuggestions(); } } showSuggestionsLoading() { // Hide other panels and show suggestions with loading this.resultsPanel.classList.add('hidden'); this.suggestionsPanel.classList.remove('hidden'); this.suggestionsContainer.innerHTML = `
Finding suggestions...
`; } displaySuggestions(suggestions) { if (!suggestions.length) { this.hideSuggestions(); return; } // Hide other panels and show suggestions this.resultsPanel.classList.add('hidden'); this.suggestionsPanel.classList.remove('hidden'); // Build suggestions HTML let suggestionsHtml = ''; suggestions.forEach((suggestion, index) => { const isActive = index === this.currentSuggestionIndex; suggestionsHtml += `
${this.escapeHtml(suggestion.name)}
${this.escapeHtml(suggestion.address || '')}
`; }); this.suggestionsContainer.innerHTML = suggestionsHtml; this.suggestionsVisible = true; this.suggestions = suggestions; // Bind click events to suggestions this.bindSuggestionEvents(); } bindSuggestionEvents() { const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); suggestionItems.forEach(item => { item.addEventListener('click', (e) => { const index = parseInt(e.currentTarget.dataset.suggestionIndex); this.selectSuggestion(index); }); }); } navigateSuggestions(direction) { if (!this.suggestions || !this.suggestions.length) return; const maxIndex = this.suggestions.length - 1; if (direction > 0) { // Arrow down this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex ? this.currentSuggestionIndex + 1 : 0; } else { // Arrow up this.currentSuggestionIndex = this.currentSuggestionIndex > 0 ? this.currentSuggestionIndex - 1 : maxIndex; } this.highlightActiveSuggestion(); } highlightActiveSuggestion() { const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item'); suggestionItems.forEach((item, index) => { if (index === this.currentSuggestionIndex) { item.classList.add('bg-blue-50', 'text-blue-700'); item.classList.remove('bg-gray-50'); } else { item.classList.remove('bg-blue-50', 'text-blue-700'); item.classList.add('bg-gray-50'); } }); } selectSuggestion(index) { if (!this.suggestions || index < 0 || index >= this.suggestions.length) return; const suggestion = this.suggestions[index]; this.searchInput.value = suggestion.name; this.hideSuggestions(); this.showSearchLoading(suggestion.name); this.performCoordinateSearch(suggestion); // Use coordinate-based search for selected suggestion } showSearchLoading(locationName) { // Hide other panels and show loading for search results this.suggestionsPanel.classList.add('hidden'); this.resultsPanel.classList.remove('hidden'); this.resultsContainer.innerHTML = `
Searching visits to
${this.escapeHtml(locationName)}
`; } async performCoordinateSearch(suggestion) { this.currentSearchQuery = suggestion.name; // Loading state already shown by showSearchLoading try { const params = new URLSearchParams({ lat: suggestion.coordinates[0], lon: suggestion.coordinates[1], name: suggestion.name, address: suggestion.address || '' }); const response = await fetch(`/api/v1/locations?${params}`, { method: 'GET', headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Coordinate search failed: ${response.status} ${response.statusText}`); } const data = await response.json(); this.displaySearchResults(data); } catch (error) { console.error('Coordinate search error:', error); this.showError('Failed to search locations. Please try again.'); } } hideSuggestions() { this.suggestionsPanel.classList.add('hidden'); this.suggestionsVisible = false; this.currentSuggestionIndex = -1; this.suggestions = []; if (this.searchTimeout) { clearTimeout(this.searchTimeout); this.searchTimeout = null; } } createVisitAt(lat, lon, placeName, visitDate, durationEstimate) { console.log(`Creating visit at ${lat}, ${lon} for ${placeName} at ${visitDate} (duration: ${durationEstimate})`); // Close the current visit popup if (this.visitMarker) { this.visitMarker.closePopup(); } // Calculate start and end times from the original visit const { startTime, endTime } = this.calculateVisitTimes(visitDate, durationEstimate); this.showBasicVisitForm(lat, lon, placeName, startTime, endTime); } showBasicVisitForm(lat, lon, placeName, presetStartTime, presetEndTime) { // Close any existing visit form popups first const existingPopups = document.querySelectorAll('.basic-visit-form-popup'); existingPopups.forEach(popup => { const leafletPopup = popup.closest('.leaflet-popup'); if (leafletPopup) { const closeButton = leafletPopup.querySelector('.leaflet-popup-close-button'); if (closeButton) closeButton.click(); } }); // Use preset times if available, otherwise use current time defaults let startTime, endTime; if (presetStartTime && presetEndTime) { startTime = presetStartTime; endTime = presetEndTime; console.log('Using preset times:', { startTime, endTime }); } else { console.log('No preset times provided, using defaults'); // Get current date/time for default values const now = new Date(); const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000)); // Format dates for datetime-local input const formatDateTime = (date) => { return date.toISOString().slice(0, 16); }; startTime = formatDateTime(now); endTime = formatDateTime(oneHourLater); } // Create form HTML const formHTML = `

Add New Visit

`; // Create popup at the location const basicVisitPopup = L.popup({ closeOnClick: false, autoClose: false, maxWidth: 300, className: 'basic-visit-form-popup' }) .setLatLng([lat, lon]) .setContent(formHTML) .openOn(this.map); // Add event listeners after the popup is added to DOM setTimeout(() => { const form = document.getElementById('basic-add-visit-form'); const cancelButton = document.getElementById('basic-cancel-visit'); const nameInput = document.getElementById('basic-visit-name'); if (form) { form.addEventListener('submit', (e) => this.handleBasicFormSubmit(e, basicVisitPopup)); } if (cancelButton) { cancelButton.addEventListener('click', () => { this.map.closePopup(basicVisitPopup); }); } // Focus and select the name input if (nameInput) { nameInput.focus(); nameInput.select(); } }, 100); } async handleBasicFormSubmit(event, popup) { event.preventDefault(); const form = event.target; const formData = new FormData(form); // Get form values const visitData = { visit: { name: formData.get('name'), started_at: formData.get('started_at'), ended_at: formData.get('ended_at'), latitude: formData.get('latitude'), longitude: formData.get('longitude') } }; // Validate that end time is after start time const startTime = new Date(visitData.visit.started_at); const endTime = new Date(visitData.visit.ended_at); if (endTime <= startTime) { alert('End time must be after start time'); return; } // Disable form while submitting const submitButton = form.querySelector('button[type="submit"]'); const originalText = submitButton.textContent; submitButton.disabled = true; submitButton.textContent = 'Creating...'; try { const response = await fetch(`/api/v1/visits`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${this.apiKey}` }, body: JSON.stringify(visitData) }); const data = await response.json(); if (response.ok) { alert(`Visit "${visitData.visit.name}" created successfully!`); this.map.closePopup(popup); // Try to refresh visits layer if available this.refreshVisitsIfAvailable(); } else { const errorMessage = data.error || data.message || 'Failed to create visit'; alert(errorMessage); } } catch (error) { console.error('Error creating visit:', error); alert('Network error: Failed to create visit'); } finally { // Re-enable form submitButton.disabled = false; submitButton.textContent = originalText; } } refreshVisitsIfAvailable() { // Try to refresh visits layer if available const mapsController = document.querySelector('[data-controller*="maps"]'); if (mapsController) { const stimulusApp = window.Stimulus || window.stimulus; if (stimulusApp) { const controller = stimulusApp.getControllerForElementAndIdentifier(mapsController, 'maps'); if (controller && controller.visitsManager && controller.visitsManager.fetchAndDisplayVisits) { console.log('Refreshing visits layer after creating visit'); controller.visitsManager.fetchAndDisplayVisits(); } } } } calculateVisitTimes(visitDate, durationEstimate) { if (!visitDate) { return { startTime: null, endTime: null }; } try { // Parse the visit date (e.g., "2022-12-27T18:01:00.000Z") const visitDateTime = new Date(visitDate); // Parse duration estimate (e.g., "~15m", "~1h 44m", "~2h 30m") let durationMinutes = 15; // Default to 15 minutes if parsing fails if (durationEstimate) { const durationStr = durationEstimate.replace('~', '').trim(); // Match patterns like "15m", "1h 44m", "2h", etc. const hoursMatch = durationStr.match(/(\d+)h/); const minutesMatch = durationStr.match(/(\d+)m/); let hours = 0; let minutes = 0; if (hoursMatch) { hours = parseInt(hoursMatch[1]); } if (minutesMatch) { minutes = parseInt(minutesMatch[1]); } durationMinutes = (hours * 60) + minutes; // If no matches found, try to parse as pure minutes if (durationMinutes === 0) { const pureMinutes = parseInt(durationStr); if (!isNaN(pureMinutes)) { durationMinutes = pureMinutes; } } } // Calculate start time (visit time) and end time (visit time + duration) const startTime = visitDateTime.toISOString().slice(0, 16); // Format for datetime-local const endDateTime = new Date(visitDateTime.getTime() + (durationMinutes * 60 * 1000)); const endTime = endDateTime.toISOString().slice(0, 16); console.log(`Calculated visit times: ${startTime} to ${endTime} (duration: ${durationMinutes} minutes)`); return { startTime, endTime }; } catch (error) { console.error('Error calculating visit times:', error); return { startTime: null, endTime: null }; } } // Utility methods escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text ? text.replace(/[&<>"']/g, m => map[m]) : ''; } formatDate(dateString) { return new Date(dateString).toLocaleDateString(); } formatDateTime(dateString) { return new Date(dateString).toLocaleDateString() + ' ' + new Date(dateString).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); } } export { LocationSearch };