mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01:39 -05:00
Make search look nicer
This commit is contained in:
parent
5eb3eb0024
commit
4f402a0c2a
12 changed files with 446 additions and 198 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -6,6 +6,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||
|
||||
# [UNRELEASED]
|
||||
|
||||
The Search release
|
||||
|
||||
In this release we're introducing a new search feature that allows users to search for places and see when they visited them. On the map page, click on Search icon, enter a place name (e.g. "Alexanderplatz"), wait for suggestions to load, and click on the suggestion you want to search for. You then will see a list of years you visited that place. Click on the year to unfold list of visits for that year. Then click on the visit you want to see on the map and you will be moved to that visit on the map. From the opened visit popup you can create a new visit to save it in the database.
|
||||
|
||||
Important: This feature relies on reverse geocoding. Without reverse geocoding, the search feature will not work.
|
||||
|
||||
## Added
|
||||
|
||||
- User can now search for places and see when they visited them.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Default value for `points_count` attribute is now set to 0 in the User model.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -10,6 +10,9 @@ class LocationSearch {
|
|||
this.suggestionsVisible = false;
|
||||
this.currentSuggestionIndex = -1;
|
||||
|
||||
// Make instance globally accessible for popup buttons
|
||||
window.locationSearchInstance = this;
|
||||
|
||||
this.initializeSearchBar();
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +45,7 @@ class LocationSearch {
|
|||
setTimeout(() => {
|
||||
// Get reference to the created button
|
||||
const toggleButton = document.getElementById('location-search-toggle');
|
||||
|
||||
|
||||
if (toggleButton) {
|
||||
// Create inline search bar
|
||||
this.createInlineSearchBar();
|
||||
|
|
@ -53,7 +56,7 @@ class LocationSearch {
|
|||
|
||||
// Bind events
|
||||
this.bindSearchEvents();
|
||||
|
||||
|
||||
console.log('LocationSearch: Search button initialized successfully');
|
||||
} else {
|
||||
console.error('LocationSearch: Could not find search toggle button');
|
||||
|
|
@ -64,45 +67,46 @@ class LocationSearch {
|
|||
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';
|
||||
searchBar.id = 'location-search-bar';
|
||||
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.display = 'none'; // Start hidden with inline style instead of class
|
||||
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 = `
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search locations..."
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search locations..."
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
id="location-search-input"
|
||||
/>
|
||||
<button
|
||||
id="location-search-submit"
|
||||
<button
|
||||
id="location-search-submit"
|
||||
class="px-3 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
id="location-search-close"
|
||||
<button
|
||||
id="location-search-close"
|
||||
class="px-2 py-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Suggestions dropdown -->
|
||||
<div id="location-search-suggestions-panel" class="hidden mt-2">
|
||||
<div id="location-search-suggestions-panel" class="hidden mt-2 border-t border-gray-200">
|
||||
<div class="bg-gray-50 px-3 py-2 border-b text-xs font-medium text-gray-700">Suggestions</div>
|
||||
<div id="location-search-suggestions" class="max-h-48 overflow-y-auto"></div>
|
||||
<div id="location-search-suggestions" class="max-h-48 overflow-y-auto border border-gray-200 rounded-b"></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Results dropdown -->
|
||||
<div id="location-search-results-panel" class="hidden mt-2">
|
||||
<div id="location-search-results-panel" class="hidden mt-2 border-t border-gray-200">
|
||||
<div class="bg-gray-50 px-3 py-2 border-b text-xs font-medium text-gray-700">Results</div>
|
||||
<div id="location-search-results" class="max-h-64 overflow-y-auto"></div>
|
||||
<div id="location-search-results" class="border border-gray-200 rounded-b"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -118,12 +122,69 @@ class LocationSearch {
|
|||
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
|
||||
|
|
@ -160,7 +221,7 @@ class LocationSearch {
|
|||
// Handle real-time suggestions
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
const query = e.target.value.trim();
|
||||
|
||||
|
||||
if (query.length > 0) {
|
||||
this.debouncedSuggestionSearch(query);
|
||||
} else {
|
||||
|
|
@ -198,8 +259,8 @@ class LocationSearch {
|
|||
|
||||
// Close search bar when clicking outside (but not on map interactions)
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.searchVisible &&
|
||||
!e.target.closest('.location-search-bar') &&
|
||||
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();
|
||||
|
|
@ -311,20 +372,16 @@ class LocationSearch {
|
|||
|
||||
this.resultsContainer.innerHTML = resultsHtml;
|
||||
|
||||
// Add markers to map
|
||||
this.addSearchMarkersToMap(data.locations);
|
||||
|
||||
// Bind result interaction events
|
||||
this.bindResultEvents();
|
||||
}
|
||||
|
||||
buildLocationResultHtml(location, index) {
|
||||
const firstVisit = location.visits[0];
|
||||
const lastVisit = location.visits[location.visits.length - 1];
|
||||
|
||||
const firstVisit = location.visits[location.visits.length - 1];
|
||||
const lastVisit = location.visits[0];
|
||||
|
||||
// Group visits by year
|
||||
const visitsByYear = this.groupVisitsByYear(location.visits);
|
||||
|
||||
|
||||
return `
|
||||
<div class="location-result border-b" data-location-index="${index}">
|
||||
<div class="p-4">
|
||||
|
|
@ -337,30 +394,27 @@ class LocationSearch {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Years Section -->
|
||||
<div class="border-t bg-gray-50">
|
||||
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
|
||||
<div class="year-section">
|
||||
<div class="year-toggle p-3 hover:bg-gray-100 cursor-pointer border-b border-gray-200 flex justify-between items-center"
|
||||
<div class="year-toggle p-3 hover:bg-gray-100 cursor-pointer border-b border-gray-200 flex justify-between items-center"
|
||||
data-location-index="${index}" data-year="${year}">
|
||||
<span class="text-sm font-medium text-gray-700">${year}</span>
|
||||
<span class="text-xs text-blue-600">${yearVisits.length} visits</span>
|
||||
<span class="year-arrow text-gray-400 transition-transform">▶</span>
|
||||
</div>
|
||||
<div class="year-visits hidden" id="year-${index}-${year}">
|
||||
${yearVisits.map((visit, visitIndex) => `
|
||||
<div class="visit-item text-xs text-gray-700 py-2 px-4 border-b border-gray-100 hover:bg-blue-50 cursor-pointer"
|
||||
${yearVisits.map((visit) => `
|
||||
<div class="visit-item text-xs text-gray-700 py-2 px-4 border-b border-gray-100 hover:bg-blue-50 cursor-pointer"
|
||||
data-location-index="${index}" data-visit-index="${location.visits.indexOf(visit)}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
📍 ${this.formatDateTime(visit.date)}
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
${visit.duration_estimate}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">
|
||||
${visit.distance_meters}m
|
||||
<div class="text-xs text-gray-500">
|
||||
${visit.duration_estimate}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -382,7 +436,7 @@ class LocationSearch {
|
|||
}
|
||||
groups[year].push(visit);
|
||||
});
|
||||
|
||||
|
||||
// Sort years descending (most recent first)
|
||||
const sortedGroups = {};
|
||||
Object.keys(groups)
|
||||
|
|
@ -390,7 +444,7 @@ class LocationSearch {
|
|||
.forEach(year => {
|
||||
sortedGroups[year] = groups[year];
|
||||
});
|
||||
|
||||
|
||||
return sortedGroups;
|
||||
}
|
||||
|
||||
|
|
@ -398,7 +452,7 @@ class LocationSearch {
|
|||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
|
@ -430,7 +484,7 @@ class LocationSearch {
|
|||
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');
|
||||
|
|
@ -444,43 +498,6 @@ class LocationSearch {
|
|||
}
|
||||
}
|
||||
|
||||
addSearchMarkersToMap(locations) {
|
||||
if (this.searchMarkersLayer) {
|
||||
this.map.removeLayer(this.searchMarkersLayer);
|
||||
}
|
||||
|
||||
this.searchMarkersLayer = L.layerGroup();
|
||||
|
||||
locations.forEach(location => {
|
||||
const [lat, lon] = location.coordinates;
|
||||
|
||||
// Create custom search result marker
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: '#ff6b35',
|
||||
color: '#ffffff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
|
||||
// Add popup with location info
|
||||
const popupContent = `
|
||||
<div class="text-sm">
|
||||
<div class="font-semibold">${this.escapeHtml(location.place_name)}</div>
|
||||
<div class="text-gray-600 mt-1">${this.escapeHtml(location.address || '')}</div>
|
||||
<div class="mt-2">
|
||||
<span class="text-blue-600">${location.total_visits} visit(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
marker.bindPopup(popupContent);
|
||||
this.searchMarkersLayer.addLayer(marker);
|
||||
});
|
||||
|
||||
this.searchMarkersLayer.addTo(this.map);
|
||||
}
|
||||
|
||||
focusOnLocation(location) {
|
||||
const [lat, lon] = location.coordinates;
|
||||
|
|
@ -523,15 +540,12 @@ class LocationSearch {
|
|||
coordinates: [lat, lon]
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
document.dispatchEvent(timeFilterEvent);
|
||||
|
||||
// Create a special marker for the specific visit
|
||||
this.addVisitMarker(lat, lon, visit, location);
|
||||
|
||||
// Show visit details in a popup or notification
|
||||
this.showVisitDetails(visit, location);
|
||||
|
||||
// DON'T hide results - keep sidebar open
|
||||
// this.hideResults();
|
||||
}
|
||||
|
|
@ -561,9 +575,13 @@ class LocationSearch {
|
|||
<div class="text-sm">${this.formatDateTime(visit.date)}</div>
|
||||
<div class="text-xs text-gray-500">Duration: ${visit.duration_estimate}</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-2 border-t border-gray-200">
|
||||
<button onclick="this.getRootNode().host?.closePopup?.() || this.closest('.leaflet-popup').querySelector('.leaflet-popup-close-button')?.click()"
|
||||
class="text-xs text-blue-600 hover:text-blue-800">
|
||||
<div class="mt-3 pt-2 border-t border-gray-200 flex gap-2">
|
||||
<button onclick="window.locationSearchInstance?.createVisitAt?.(${lat}, ${lon}, '${this.escapeHtml(location.place_name)}', '${visit.date}', '${visit.duration_estimate}')"
|
||||
class="text-xs bg-blue-600 text-white px-3 py-1 rounded hover:bg-blue-700 flex-1">
|
||||
Create Visit
|
||||
</button>
|
||||
<button onclick="this.getRootNode().host?.closePopup?.() || this.closest('.leaflet-popup').querySelector('.leaflet-popup-close-button')?.click()"
|
||||
class="text-xs text-blue-600 hover:text-blue-800 px-2">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -576,7 +594,7 @@ class LocationSearch {
|
|||
closeOnEscapeKey: true, // Allow closing with Escape key
|
||||
closeOnClick: false // Don't close when clicking on map
|
||||
});
|
||||
|
||||
|
||||
this.visitMarker.addTo(this.map);
|
||||
this.visitMarker.openPopup();
|
||||
|
||||
|
|
@ -592,50 +610,6 @@ class LocationSearch {
|
|||
this.currentVisitMarker = this.visitMarker;
|
||||
}
|
||||
|
||||
showVisitDetails(visit, location) {
|
||||
// Remove any existing notification
|
||||
const existingNotification = document.querySelector('.visit-navigation-notification');
|
||||
if (existingNotification) {
|
||||
existingNotification.remove();
|
||||
}
|
||||
|
||||
// Create a persistent notification showing visit details
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'visit-navigation-notification fixed top-4 right-4 z-40 bg-green-50 border border-green-200 rounded-lg p-4 shadow-lg max-w-sm';
|
||||
notification.innerHTML = `
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
|
||||
📍
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 flex-1">
|
||||
<div class="text-sm font-medium text-green-800">
|
||||
Viewing visit
|
||||
</div>
|
||||
<div class="text-sm text-green-600 mt-1">
|
||||
${this.escapeHtml(location.place_name)}
|
||||
</div>
|
||||
<div class="text-xs text-green-500 mt-1">
|
||||
${this.formatDateTime(visit.date)} • ${visit.duration_estimate}
|
||||
</div>
|
||||
</div>
|
||||
<button class="flex-shrink-0 ml-3 text-green-400 hover:text-green-500" onclick="this.parentElement.parentElement.remove()">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove notification after 10 seconds (longer duration)
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.searchInput.value = '';
|
||||
this.hideResults();
|
||||
|
|
@ -653,7 +627,7 @@ class LocationSearch {
|
|||
this.map.removeLayer(this.currentVisitMarker);
|
||||
this.currentVisitMarker = null;
|
||||
}
|
||||
|
||||
|
||||
// Remove any visit notifications
|
||||
const existingNotification = document.querySelector('.visit-navigation-notification');
|
||||
if (existingNotification) {
|
||||
|
|
@ -663,34 +637,35 @@ class LocationSearch {
|
|||
|
||||
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
|
||||
|
|
@ -703,23 +678,24 @@ class LocationSearch {
|
|||
|
||||
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();
|
||||
|
|
@ -734,6 +710,8 @@ class LocationSearch {
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
@ -818,11 +796,10 @@ class LocationSearch {
|
|||
suggestions.forEach((suggestion, index) => {
|
||||
const isActive = index === this.currentSuggestionIndex;
|
||||
suggestionsHtml += `
|
||||
<div class="suggestion-item p-4 border-b border-gray-100 hover:bg-blue-50 cursor-pointer ${isActive ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
<div class="suggestion-item p-4 border-b border-gray-100 hover:bg-blue-50 cursor-pointer ${isActive ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
data-suggestion-index="${index}">
|
||||
<div class="font-medium text-sm">${this.escapeHtml(suggestion.name)}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">${this.escapeHtml(suggestion.address || '')}</div>
|
||||
<div class="text-xs text-gray-400 mt-1">${suggestion.type}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
|
@ -849,16 +826,16 @@ class LocationSearch {
|
|||
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
|
||||
this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex
|
||||
? this.currentSuggestionIndex + 1
|
||||
: 0;
|
||||
} else {
|
||||
// Arrow up
|
||||
this.currentSuggestionIndex = this.currentSuggestionIndex > 0
|
||||
? this.currentSuggestionIndex - 1
|
||||
this.currentSuggestionIndex = this.currentSuggestionIndex > 0
|
||||
? this.currentSuggestionIndex - 1
|
||||
: maxIndex;
|
||||
}
|
||||
|
||||
|
|
@ -867,7 +844,7 @@ class LocationSearch {
|
|||
|
||||
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');
|
||||
|
|
@ -941,13 +918,269 @@ class LocationSearch {
|
|||
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 = `
|
||||
<div class="visit-form" style="min-width: 280px;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Add New Visit</h3>
|
||||
|
||||
<form id="basic-add-visit-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div>
|
||||
<label for="basic-visit-name" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Name:</label>
|
||||
<input type="text" id="basic-visit-name" name="name" required value="${this.escapeHtml(placeName)}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;"
|
||||
placeholder="Enter visit name">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="basic-visit-start" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">Start Time:</label>
|
||||
<input type="datetime-local" id="basic-visit-start" name="started_at" required value="${startTime}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="basic-visit-end" style="display: block; margin-bottom: 5px; font-weight: bold; font-size: 14px;">End Time:</label>
|
||||
<input type="datetime-local" id="basic-visit-end" name="ended_at" required value="${endTime}"
|
||||
style="width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="latitude" value="${lat}">
|
||||
<input type="hidden" name="longitude" value="${lon}">
|
||||
|
||||
<div style="display: flex; gap: 10px; margin-top: 15px;">
|
||||
<button type="submit" style="flex: 1; background: #28a745; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
Create Visit
|
||||
</button>
|
||||
<button type="button" id="basic-cancel-visit" style="flex: 1; background: #dc3545; color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 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 = {
|
||||
|
|
|
|||
|
|
@ -44,4 +44,4 @@ class LocationSearchResultSerializer
|
|||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ module LocationSearch
|
|||
|
||||
def coordinate_based_search
|
||||
Rails.logger.info "LocationSearch: Coordinate-based search at [#{@latitude}, #{@longitude}] for '#{@name}'"
|
||||
|
||||
|
||||
# Create a single location object with the provided coordinates
|
||||
location = {
|
||||
lat: @latitude,
|
||||
|
|
@ -42,7 +42,7 @@ module LocationSearch
|
|||
address: @address,
|
||||
type: 'coordinate_search'
|
||||
}
|
||||
|
||||
|
||||
find_matching_points([location])
|
||||
end
|
||||
|
||||
|
|
@ -50,13 +50,13 @@ module LocationSearch
|
|||
return empty_result if @query.blank?
|
||||
|
||||
geocoded_locations = geocoding_service.search(@query)
|
||||
|
||||
|
||||
# Debug: Log geocoding results
|
||||
Rails.logger.info "LocationSearch: Geocoding '#{@query}' returned #{geocoded_locations.length} locations"
|
||||
geocoded_locations.each_with_index do |loc, idx|
|
||||
Rails.logger.info "LocationSearch: [#{idx}] #{loc[:name]} at [#{loc[:lat]}, #{loc[:lon]}] - #{loc[:address]}"
|
||||
end
|
||||
|
||||
|
||||
return empty_result if geocoded_locations.empty?
|
||||
|
||||
find_matching_points(geocoded_locations)
|
||||
|
|
@ -72,7 +72,7 @@ module LocationSearch
|
|||
geocoded_locations.each do |location|
|
||||
# Debug: Log the geocoded location
|
||||
Rails.logger.info "LocationSearch: Searching for points near #{location[:name]} at [#{location[:lat]}, #{location[:lon]}]"
|
||||
|
||||
|
||||
matching_points = spatial_matcher.find_points_near(
|
||||
@user,
|
||||
location[:lat],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
module LocationSearch
|
||||
class ResultAggregator
|
||||
include ActionView::Helpers::TextHelper
|
||||
|
||||
# Time threshold for grouping consecutive points into visits (minutes)
|
||||
VISIT_TIME_THRESHOLD = 30
|
||||
|
||||
|
|
@ -10,7 +12,7 @@ module LocationSearch
|
|||
|
||||
visits = []
|
||||
current_visit_points = []
|
||||
|
||||
|
||||
points.each do |point|
|
||||
if current_visit_points.empty? || within_visit_threshold?(current_visit_points.last, point)
|
||||
current_visit_points << point
|
||||
|
|
@ -20,10 +22,10 @@ module LocationSearch
|
|||
current_visit_points = [point]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# Don't forget the last visit
|
||||
visits << create_visit_from_points(current_visit_points) if current_visit_points.any?
|
||||
|
||||
|
||||
visits.sort_by { |visit| -visit[:timestamp] } # Most recent first
|
||||
end
|
||||
|
||||
|
|
@ -41,7 +43,7 @@ module LocationSearch
|
|||
sorted_points = points.sort_by { |p| p[:timestamp] }
|
||||
first_point = sorted_points.first
|
||||
last_point = sorted_points.last
|
||||
|
||||
|
||||
# Calculate visit duration
|
||||
duration_minutes = if sorted_points.length > 1
|
||||
((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round
|
||||
|
|
@ -52,7 +54,7 @@ module LocationSearch
|
|||
|
||||
# Find the most accurate point (lowest accuracy value means higher precision)
|
||||
most_accurate_point = points.min_by { |p| p[:accuracy] || 999999 }
|
||||
|
||||
|
||||
# Calculate average distance from search center
|
||||
average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2)
|
||||
|
||||
|
|
@ -76,25 +78,25 @@ module LocationSearch
|
|||
end
|
||||
|
||||
def format_duration(minutes)
|
||||
return "~#{minutes}m" if minutes < 60
|
||||
return "~#{pluralize(minutes, 'minute')}" if minutes < 60
|
||||
|
||||
hours = minutes / 60
|
||||
remaining_minutes = minutes % 60
|
||||
|
||||
|
||||
if remaining_minutes == 0
|
||||
"~#{hours}h"
|
||||
"~#{pluralize(hours, 'hour')}"
|
||||
else
|
||||
"~#{hours}h #{remaining_minutes}m"
|
||||
"~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}"
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_altitude_range(points)
|
||||
altitudes = points.map { |p| p[:altitude] }.compact
|
||||
return nil if altitudes.empty?
|
||||
|
||||
|
||||
min_altitude = altitudes.min
|
||||
max_altitude = altitudes.max
|
||||
|
||||
|
||||
if min_altitude == max_altitude
|
||||
"#{min_altitude}m"
|
||||
else
|
||||
|
|
@ -102,4 +104,4 @@ module LocationSearch
|
|||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ module LocationSearch
|
|||
# Debug method to test spatial queries directly
|
||||
def debug_points_near(user, latitude, longitude, radius_meters = 1000)
|
||||
query = <<~SQL
|
||||
SELECT
|
||||
SELECT
|
||||
p.id,
|
||||
p.timestamp,
|
||||
ST_Y(p.lonlat::geometry) as latitude,
|
||||
|
|
@ -23,24 +23,24 @@ module LocationSearch
|
|||
ORDER BY distance_meters ASC
|
||||
LIMIT 10;
|
||||
SQL
|
||||
|
||||
|
||||
puts "=== DEBUG SPATIAL QUERY ==="
|
||||
puts "Searching for user #{user.id} near [#{latitude}, #{longitude}] within #{radius_meters}m"
|
||||
puts "Query: #{query}"
|
||||
|
||||
|
||||
results = ActiveRecord::Base.connection.exec_query(query)
|
||||
puts "Found #{results.count} points:"
|
||||
|
||||
|
||||
results.each do |row|
|
||||
puts "- Point #{row['id']}: [#{row['latitude']}, #{row['longitude']}] - #{row['distance_meters'].to_f.round(2)}m away"
|
||||
end
|
||||
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def find_points_near(user, latitude, longitude, radius_meters, date_options = {})
|
||||
points_query = build_spatial_query(user, latitude, longitude, radius_meters, date_options)
|
||||
|
||||
|
||||
# Execute query and return results with calculated distance
|
||||
ActiveRecord::Base.connection.exec_query(points_query)
|
||||
.map { |row| format_point_result(row) }
|
||||
|
|
@ -52,9 +52,9 @@ module LocationSearch
|
|||
|
||||
def build_spatial_query(user, latitude, longitude, radius_meters, date_options = {})
|
||||
date_filter = build_date_filter(date_options)
|
||||
|
||||
|
||||
<<~SQL
|
||||
SELECT
|
||||
SELECT
|
||||
p.id,
|
||||
p.timestamp,
|
||||
ST_Y(p.lonlat::geometry) as latitude,
|
||||
|
|
@ -75,22 +75,22 @@ module LocationSearch
|
|||
|
||||
def build_date_filter(date_options)
|
||||
return '' unless date_options[:date_from] || date_options[:date_to]
|
||||
|
||||
|
||||
filters = []
|
||||
|
||||
|
||||
if date_options[:date_from]
|
||||
timestamp_from = date_options[:date_from].to_time.to_i
|
||||
filters << "p.timestamp >= #{timestamp_from}"
|
||||
end
|
||||
|
||||
|
||||
if date_options[:date_to]
|
||||
# Add one day to include the entire end date
|
||||
timestamp_to = (date_options[:date_to] + 1.day).to_time.to_i
|
||||
filters << "p.timestamp < #{timestamp_to}"
|
||||
end
|
||||
|
||||
|
||||
return '' if filters.empty?
|
||||
|
||||
|
||||
"AND #{filters.join(' AND ')}"
|
||||
end
|
||||
|
||||
|
|
@ -109,4 +109,4 @@ module LocationSearch
|
|||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -36,3 +36,7 @@ MANAGER_URL = SELF_HOSTED ? nil : ENV.fetch('MANAGER_URL', nil)
|
|||
METRICS_USERNAME = ENV.fetch('METRICS_USERNAME', 'prometheus')
|
||||
METRICS_PASSWORD = ENV.fetch('METRICS_PASSWORD', 'prometheus')
|
||||
# /Prometheus metrics
|
||||
|
||||
FEATURES = {
|
||||
location_search: !!ENV.fetch('PHOTON_API_HOST', nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ RSpec.describe Api::V1::LocationsController, type: :request do
|
|||
)
|
||||
end
|
||||
|
||||
it 'limits suggestions to 5 results' do
|
||||
it 'limits suggestions to 10 results' do
|
||||
large_suggestions = Array.new(10) do |i|
|
||||
{
|
||||
lat: 52.5000 + i * 0.001,
|
||||
|
|
@ -314,7 +314,7 @@ RSpec.describe Api::V1::LocationsController, type: :request do
|
|||
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['suggestions'].length).to eq(5)
|
||||
expect(json_response['suggestions'].length).to eq(10)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ RSpec.describe LocationSearch::GeocodingService do
|
|||
end
|
||||
|
||||
it 'limits results to MAX_RESULTS' do
|
||||
expect(Geocoder).to receive(:search).with(query, limit: 5)
|
||||
expect(Geocoder).to receive(:search).with(query, limit: 10)
|
||||
|
||||
service.search(query)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ RSpec.describe LocationSearch::ResultAggregator do
|
|||
result = service.group_points_into_visits([single_point])
|
||||
|
||||
visit = result.first
|
||||
expect(visit[:duration_estimate]).to eq('~15m')
|
||||
expect(visit[:duration_estimate]).to eq('~15 minutes')
|
||||
expect(visit[:visit_details][:duration_minutes]).to eq(15)
|
||||
end
|
||||
end
|
||||
|
|
@ -95,7 +95,7 @@ RSpec.describe LocationSearch::ResultAggregator do
|
|||
result = service.group_points_into_visits(consecutive_points)
|
||||
|
||||
visit = result.first
|
||||
expect(visit[:duration_estimate]).to eq('~45m')
|
||||
expect(visit[:duration_estimate]).to eq('~45 minutes')
|
||||
expect(visit[:visit_details][:duration_minutes]).to eq(45)
|
||||
end
|
||||
|
||||
|
|
@ -196,14 +196,14 @@ RSpec.describe LocationSearch::ResultAggregator do
|
|||
short_visit_points = points_with_various_durations.take(2)
|
||||
result = service.group_points_into_visits(short_visit_points)
|
||||
|
||||
expect(result.first[:duration_estimate]).to eq('~25m')
|
||||
expect(result.first[:duration_estimate]).to eq('~25 minutes')
|
||||
end
|
||||
|
||||
it 'formats duration correctly for hours and minutes' do
|
||||
long_visit_points = points_with_various_durations.drop(2)
|
||||
result = service.group_points_into_visits(long_visit_points)
|
||||
|
||||
expect(result.first[:duration_estimate]).to eq('~2h 15m')
|
||||
expect(result.first[:duration_estimate]).to eq('~2 hours 15 minutes')
|
||||
end
|
||||
|
||||
it 'formats duration correctly for hours only' do
|
||||
|
|
@ -215,7 +215,7 @@ RSpec.describe LocationSearch::ResultAggregator do
|
|||
|
||||
result = service.group_points_into_visits(exact_hour_points)
|
||||
|
||||
expect(result.first[:duration_estimate]).to eq('~2h')
|
||||
expect(result.first[:duration_estimate]).to eq('~2 hours')
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ RSpec.describe 'Location Search Feature', type: :system, js: true do
|
|||
find('#location-search-toggle').click
|
||||
end
|
||||
|
||||
it 'adds search markers to the map' do
|
||||
it 'completes search and shows results without location markers' do
|
||||
fill_in 'location-search-input', with: 'Kaufland'
|
||||
within('#location-search-container') do
|
||||
click_button '🔍'
|
||||
|
|
@ -233,8 +233,7 @@ RSpec.describe 'Location Search Feature', type: :system, js: true do
|
|||
# Wait for search to complete
|
||||
expect(page).to have_content('Kaufland Mitte')
|
||||
|
||||
# Check that markers are added (this would require inspecting the map object)
|
||||
# For now, we'll verify the search completed successfully
|
||||
# Verify search results are displayed (no location markers are added to keep map clean)
|
||||
expect(page).to have_content('Found 1 location(s)')
|
||||
end
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue