Make search look nicer

This commit is contained in:
Eugene Burmakin 2025-09-02 21:21:22 +02:00
parent 5eb3eb0024
commit 4f402a0c2a
12 changed files with 446 additions and 198 deletions

View file

@ -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

View file

@ -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 = {

View file

@ -44,4 +44,4 @@ class LocationSearchResultSerializer
}
end
end
end
end

View file

@ -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],

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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