Add search bar

This commit is contained in:
Eugene Burmakin 2025-08-30 23:18:16 +02:00
parent 5b9ed23cae
commit 1709aa612d
17 changed files with 3048 additions and 2 deletions

View file

@ -0,0 +1,406 @@
# Location Search Feature Implementation Plan
## Overview
Implement a location search feature allowing users to search for places (e.g., "Kaufland", "Schneller straße 130") and find when they visited those locations based on their recorded points data.
## Current System Analysis
### Existing Infrastructure
- **Database**: PostgreSQL with PostGIS extension
- **Geocoding**: Geocoder gem with multiple providers (Photon, Geoapify, Nominatim, LocationIQ)
- **Geographic Data**: Points with `lonlat` (PostGIS geometry), `latitude`, `longitude` columns
- **Indexes**: GIST spatial indexes on `lonlat` columns for efficient spatial queries
- **Places Model**: Stores geocoded places with `geodata` JSONB field (OSM metadata)
- **Points Model**: Basic location data with `city`, `country` text fields (geodata field exists but empty)
### Key Constraints
- Points table does **NOT** store geocoded metadata in `points.geodata` (confirmed empty)
- Must rely on coordinate-based spatial matching rather than text-based search within points
- Places table contains rich geodata for places, but points are coordinate-only
## Implementation Approach
### 1. Two-Stage Search Strategy
#### Stage 1: Forward Geocoding (Query → Coordinates)
```
User Query → Geocoding Service → Geographic Candidates
"Kaufland" → Photon API → [{lat: 52.5200, lon: 13.4050, name: "Kaufland Mitte"}, ...]
```
#### Stage 2: Spatial Point Matching (Coordinates → User Points)
```
Geographic Candidates → PostGIS Spatial Query → User's Historical Points
[{lat: 52.5200, lon: 13.4050}] → ST_DWithin(points.lonlat, candidate, radius) → Points with timestamps
```
### 2. Architecture Components
#### New Service Classes
```
app/services/location_search/
├── point_finder.rb # Main orchestration service
├── geocoding_service.rb # Forward geocoding via existing Geocoder
├── spatial_matcher.rb # PostGIS spatial queries
└── result_aggregator.rb # Group and format results
```
#### Controller Enhancement
```
app/controllers/api/v1/locations_controller.rb#index (enhanced with search functionality)
```
#### New Serializers
```
app/serializers/location_search_result_serializer.rb
```
### 3. Database Query Strategy
#### Primary Spatial Query
```sql
-- Find user points within radius of searched location
SELECT
p.id,
p.timestamp,
p.latitude,
p.longitude,
p.city,
p.country,
ST_Distance(p.lonlat, ST_Point(?, ?)::geography) as distance_meters
FROM points p
WHERE p.user_id = ?
AND ST_DWithin(p.lonlat, ST_Point(?, ?)::geography, ?)
ORDER BY p.timestamp DESC;
```
#### Smart Radius Selection
- **Specific businesses** (Kaufland, McDonald's): 50-100m radius
- **Street addresses**: 25-75m radius
- **Neighborhoods/Areas**: 200-500m radius
- **Cities/Towns**: 1000-2000m radius
### 4. API Design
#### Endpoint
```
GET /api/v1/locations (enhanced with search parameter)
```
#### Parameters
```json
{
"q": "Kaufland", // Search query (required)
"limit": 50, // Max results per location (default: 50)
"date_from": "2024-01-01", // Optional date filtering
"date_to": "2024-12-31", // Optional date filtering
"radius_override": 200 // Optional radius override in meters
}
```
#### Response Format
```json
{
"query": "Kaufland",
"locations": [
{
"place_name": "Kaufland Mitte",
"coordinates": [52.5200, 13.4050],
"address": "Alexanderplatz 1, Berlin",
"total_visits": 15,
"first_visit": "2024-01-15T09:30:00Z",
"last_visit": "2024-03-20T18:45:00Z",
"visits": [
{
"timestamp": 1640995200,
"date": "2024-03-20T18:45:00Z",
"coordinates": [52.5201, 13.4051],
"distance_meters": 45,
"duration_estimate": "~25 minutes",
"points_count": 8
}
]
}
],
"total_locations": 3,
"search_metadata": {
"geocoding_provider": "photon",
"candidates_found": 5,
"search_time_ms": 234
}
}
```
## Implementation Plan
### Phase 1: Core Search Infrastructure
1. **Service Layer**
- `LocationSearch::PointFinder` - Main orchestration
- `LocationSearch::GeocodingService` - Forward geocoding wrapper
- `LocationSearch::SpatialMatcher` - PostGIS queries
2. **API Layer**
- Enhanced `Api::V1::LocationsController#index` with search functionality
- Request validation and parameter handling
- Response serialization
3. **Database Optimizations**
- Verify spatial indexes are optimal
- Add composite indexes if needed
### Phase 2: Smart Features
1. **Visit Clustering**
- Group consecutive points into "visits"
- Estimate visit duration and patterns
- Detect multiple visits to same location
2. **Enhanced Geocoding**
- Multiple provider fallback
- Result caching and optimization
- Smart radius selection based on place type
3. **Result Filtering**
- Date range filtering
- Minimum visit duration filtering
- Relevance scoring
### Phase 3: Frontend Integration
1. **Map Integration**
- Search bar component on map page
- Auto-complete with suggestions
- Visual highlighting of found locations
2. **Results Display**
- Timeline view of visits
- Click to zoom/highlight on map
- Export functionality
## Test Coverage Requirements
### Unit Tests
#### LocationSearch::PointFinder
```ruby
describe LocationSearch::PointFinder do
describe '#call' do
context 'with valid business name query' do
it 'returns matching points within reasonable radius'
it 'handles multiple location candidates'
it 'respects user data isolation'
end
context 'with address query' do
it 'uses appropriate radius for address searches'
it 'handles partial address matches'
end
context 'with no geocoding results' do
it 'returns empty results gracefully'
end
context 'with no matching points' do
it 'returns location but no visits'
end
end
end
```
#### LocationSearch::SpatialMatcher
```ruby
describe LocationSearch::SpatialMatcher do
describe '#find_points_near' do
it 'finds points within specified radius using PostGIS'
it 'excludes points outside radius'
it 'orders results by timestamp'
it 'filters by user correctly'
it 'handles edge cases (poles, date line)'
end
describe '#cluster_visits' do
it 'groups consecutive points into visits'
it 'calculates visit duration correctly'
it 'handles single-point visits'
end
end
```
#### LocationSearch::GeocodingService
```ruby
describe LocationSearch::GeocodingService do
describe '#search' do
context 'when geocoding succeeds' do
it 'returns normalized location results'
it 'handles multiple providers (Photon, Nominatim)'
it 'caches results appropriately'
end
context 'when geocoding fails' do
it 'handles API timeouts gracefully'
it 'falls back to alternative providers'
it 'returns meaningful error messages'
end
end
end
```
### Integration Tests
#### API Controller Tests
```ruby
describe Api::V1::LocationsController do
describe 'GET #index' do
context 'with authenticated user' do
it 'returns search results for existing locations'
it 'respects date filtering parameters'
it 'handles pagination correctly'
it 'validates search parameters'
end
context 'with unauthenticated user' do
it 'returns 401 unauthorized'
end
context 'with cross-user data' do
it 'only returns current user points'
end
end
end
```
### System Tests
#### End-to-End Scenarios
```ruby
describe 'Location Search Feature' do
scenario 'User searches for known business' do
# Setup user with historical points near Kaufland
# Navigate to map page
# Enter "Kaufland" in search
# Verify results show historical visits
# Verify map highlights correct locations
end
scenario 'User searches with date filtering' do
# Test date range functionality
end
scenario 'User searches for location with no visits' do
# Verify graceful handling of no results
end
end
```
### Performance Tests
#### Database Query Performance
```ruby
describe 'Location Search Performance' do
context 'with large datasets' do
before { create_list(:point, 100_000, user: user) }
it 'completes spatial queries within 500ms'
it 'maintains performance with multiple concurrent searches'
it 'uses spatial indexes effectively'
end
end
```
### Edge Case Tests
#### Geographic Edge Cases
- Searches near poles (high latitude)
- Searches crossing date line (longitude ±180°)
- Searches in areas with dense point clusters
- Searches with very large/small radius values
#### Data Edge Cases
- Users with no points
- Points with invalid coordinates
- Geocoding service returning invalid data
- Malformed search queries
## Security Considerations
### Data Isolation
- Ensure users can only search their own location data
- Validate user authentication on all endpoints
- Prevent information leakage through error messages
### Rate Limiting
- Implement rate limiting for search API to prevent abuse
- Cache geocoding results to reduce external API calls
- Monitor and limit expensive spatial queries
### Input Validation
- Sanitize and validate all search inputs
- Prevent SQL injection via parameterized queries
- Limit search query length and complexity
## Performance Optimization
### Database Optimizations
- Ensure optimal GIST indexes on `points.lonlat`
- Consider partial indexes for active users
- Monitor query performance and add indexes as needed
### Caching Strategy
- Cache geocoding results (already implemented in Geocoder)
- Consider caching frequent location searches
- Use Redis for session-based search result caching
### Query Optimization
- Use spatial indexes for all PostGIS queries
- Implement pagination for large result sets
- Consider pre-computed search hints for popular locations
## Future Enhancements
### Advanced Search Features
- Fuzzy/typo-tolerant search
- Search by business type/category
- Search within custom drawn areas
- Historical search trends
### Machine Learning Integration
- Predict likely search locations for users
- Suggest places based on visit patterns
- Automatic place detection and naming
### Analytics and Insights
- Most visited places for user
- Time-based visitation patterns
- Location-based statistics and insights
## Risk Assessment
### High Risk
- **Performance**: Large spatial queries on million+ point datasets
- **Geocoding Costs**: External API usage costs and rate limits
- **Data Accuracy**: Matching accuracy with radius-based approach
### Medium Risk
- **User Experience**: Search relevance and result quality
- **Scalability**: Concurrent user search performance
- **Maintenance**: Multiple geocoding provider maintenance
### Low Risk
- **Security**: Standard API security with existing patterns
- **Integration**: Building on established PostGIS infrastructure
- **Testing**: Comprehensive test coverage achievable
## Success Metrics
### Functional Metrics
- Search result accuracy > 90% for known locations
- Average response time < 500ms for typical searches
- Support for 95% of common place name and address formats
### User Experience Metrics
- User engagement with search feature
- Search-to-map-interaction conversion rate
- User retention and feature adoption
### Technical Metrics
- API endpoint uptime > 99.9%
- Database query performance within SLA
- Geocoding provider reliability and failover success

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,56 @@
# frozen_string_literal: true
class Api::V1::LocationsController < ApiController
before_action :validate_search_params, only: [:index]
def index
if search_query.present?
search_results = LocationSearch::PointFinder.new(current_api_user, search_params).call
render json: LocationSearchResultSerializer.new(search_results).call
else
render json: { error: 'Search query parameter (q) is required' }, status: :bad_request
end
rescue StandardError => e
Rails.logger.error "Location search error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error
end
private
def search_query
params[:q]&.strip
end
def search_params
{
query: search_query,
limit: params[:limit]&.to_i || 50,
date_from: parse_date(params[:date_from]),
date_to: parse_date(params[:date_to]),
radius_override: params[:radius_override]&.to_i
}
end
def validate_search_params
if search_query.blank?
render json: { error: 'Search query parameter (q) is required' }, status: :bad_request
return false
end
if search_query.length > 200
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
return false
end
true
end
def parse_date(date_string)
return nil if date_string.blank?
Date.parse(date_string)
rescue ArgumentError
nil
end
end

View file

@ -36,6 +36,7 @@ import { fetchAndDisplayPhotos } from "../maps/photos";
import { countryCodesMap } from "../maps/country_codes";
import { VisitsManager } from "../maps/visits";
import { ScratchLayer } from "../maps/scratch_layer";
import { LocationSearch } from "../maps/location_search";
import "leaflet-draw";
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
@ -239,6 +240,9 @@ export default class extends BaseController {
// Initialize Live Map Handler
this.initializeLiveMapHandler();
// Initialize Location Search
this.initializeLocationSearch();
}
disconnect() {
@ -1824,4 +1828,10 @@ export default class extends BaseController {
toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible);
}
}
initializeLocationSearch() {
if (this.map && this.apiKey) {
this.locationSearch = new LocationSearch(this.map, this.apiKey);
}
}
}

View file

@ -0,0 +1,431 @@
// Location search functionality for the map
class LocationSearch {
constructor(map, apiKey) {
this.map = map;
this.apiKey = apiKey;
this.searchResults = [];
this.searchMarkersLayer = null;
this.currentSearchQuery = '';
this.initializeSearchBar();
this.initializeSearchResults();
}
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 = '🔍';
button.style.width = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
button.style.backgroundColor = 'white';
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: 'topright' }));
// Get reference to the created button
const toggleButton = document.getElementById('location-search-toggle');
// Create search container (initially hidden)
// Position it to the left of the search toggle button using fixed positioning
const searchContainer = document.createElement('div');
searchContainer.className = 'location-search-container fixed z-50 w-80 hidden bg-white rounded-lg shadow-xl border p-2';
searchContainer.id = 'location-search-container';
// Create search input
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search locations';
searchInput.className = 'input input-bordered w-full text-sm bg-white shadow-lg';
searchInput.id = 'location-search-input';
// Create search button
const searchButton = document.createElement('button');
searchButton.innerHTML = '🔍';
searchButton.className = 'btn btn-primary btn-sm absolute right-2 top-1/2 transform -translate-y-1/2';
searchButton.type = 'button';
// Create clear button
const clearButton = document.createElement('button');
clearButton.innerHTML = '✕';
clearButton.className = 'btn btn-ghost btn-xs absolute right-12 top-1/2 transform -translate-y-1/2 hidden';
clearButton.id = 'location-search-clear';
// Assemble search bar
const searchWrapper = document.createElement('div');
searchWrapper.className = 'relative';
searchWrapper.appendChild(searchInput);
searchWrapper.appendChild(clearButton);
searchWrapper.appendChild(searchButton);
searchContainer.appendChild(searchWrapper);
// Add search container to map container
const mapContainer = document.getElementById('map');
mapContainer.appendChild(searchContainer);
// Store references
this.toggleButton = toggleButton;
this.searchContainer = searchContainer;
this.searchInput = searchInput;
this.searchButton = searchButton;
this.clearButton = clearButton;
this.searchVisible = false;
// Bind events
this.bindSearchEvents();
}
initializeSearchResults() {
// Create results container (positioned below search container)
const resultsContainer = document.createElement('div');
resultsContainer.className = 'location-search-results fixed z-40 w-80 max-h-96 overflow-y-auto bg-white rounded-lg shadow-xl border hidden';
resultsContainer.id = 'location-search-results';
const mapContainer = document.getElementById('map');
mapContainer.appendChild(resultsContainer);
this.resultsContainer = resultsContainer;
}
bindSearchEvents() {
// Toggle search bar visibility
this.toggleButton.addEventListener('click', () => {
this.toggleSearchBar();
});
// Search on button click
this.searchButton.addEventListener('click', () => {
this.performSearch();
});
// Search on Enter key
this.searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.performSearch();
}
});
// Clear search
this.clearButton.addEventListener('click', () => {
this.clearSearch();
});
// Show clear button when input has content
this.searchInput.addEventListener('input', (e) => {
if (e.target.value.length > 0) {
this.clearButton.classList.remove('hidden');
} else {
this.clearButton.classList.add('hidden');
}
});
// Hide results and search bar when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.location-search-container') &&
!e.target.closest('.location-search-results') &&
!e.target.closest('#location-search-toggle')) {
this.hideResults();
if (this.searchVisible) {
this.hideSearchBar();
}
}
});
// Close search bar on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.searchVisible) {
this.hideSearchBar();
}
});
}
async performSearch() {
const query = this.searchInput.value.trim();
if (!query) return;
this.currentSearchQuery = query;
this.showLoading();
try {
const response = await fetch(`/api/v1/locations?q=${encodeURIComponent(query)}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.displaySearchResults(data);
} catch (error) {
console.error('Location search error:', error);
this.showError('Failed to search locations. Please try again.');
}
}
showLoading() {
this.resultsContainer.innerHTML = `
<div class="p-4 text-center">
<div class="loading loading-spinner loading-sm"></div>
<div class="text-sm text-gray-600 mt-2">Searching for "${this.currentSearchQuery}"...</div>
</div>
`;
this.resultsContainer.classList.remove('hidden');
}
showError(message) {
// Position results container below search container using viewport coordinates
const searchRect = this.searchContainer.getBoundingClientRect();
const resultsTop = searchRect.bottom + 5;
const resultsRight = window.innerWidth - searchRect.left;
this.resultsContainer.style.top = resultsTop + 'px';
this.resultsContainer.style.right = resultsRight + 'px';
this.resultsContainer.innerHTML = `
<div class="p-4 text-center">
<div class="text-error text-sm">${message}</div>
</div>
`;
this.resultsContainer.classList.remove('hidden');
}
displaySearchResults(data) {
// Position results container below search container using viewport coordinates
const searchRect = this.searchContainer.getBoundingClientRect();
const resultsTop = searchRect.bottom + 5; // 5px gap below search container
const resultsRight = window.innerWidth - searchRect.left; // Align with left edge of search container
this.resultsContainer.style.top = resultsTop + 'px';
this.resultsContainer.style.right = resultsRight + 'px';
if (!data.locations || data.locations.length === 0) {
this.resultsContainer.innerHTML = `
<div class="p-4 text-center">
<div class="text-sm text-gray-600">No visits found for "${this.currentSearchQuery}"</div>
</div>
`;
this.resultsContainer.classList.remove('hidden');
return;
}
this.searchResults = data.locations;
this.clearSearchMarkers();
let resultsHtml = `
<div class="p-3 border-b">
<div class="text-sm font-semibold">Found ${data.total_locations} location(s) for "${this.currentSearchQuery}"</div>
</div>
`;
data.locations.forEach((location, index) => {
resultsHtml += this.buildLocationResultHtml(location, index);
});
this.resultsContainer.innerHTML = resultsHtml;
this.resultsContainer.classList.remove('hidden');
// 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];
return `
<div class="location-result p-3 border-b hover:bg-gray-50 cursor-pointer" data-location-index="${index}">
<div class="font-medium text-sm">${this.escapeHtml(location.place_name)}</div>
<div class="text-xs text-gray-600 mt-1">${this.escapeHtml(location.address || '')}</div>
<div class="flex justify-between items-center mt-2">
<div class="text-xs text-blue-600">${location.total_visits} visit(s)</div>
<div class="text-xs text-gray-500">
${this.formatDate(firstVisit.date)} - ${this.formatDate(lastVisit.date)}
</div>
</div>
<div class="mt-2 max-h-32 overflow-y-auto">
${location.visits.slice(0, 5).map(visit => `
<div class="text-xs text-gray-700 py-1 border-t border-gray-100 first:border-t-0">
📍 ${this.formatDateTime(visit.date)} (${visit.distance_meters}m away)
</div>
`).join('')}
${location.visits.length > 5 ? `<div class="text-xs text-gray-500 mt-1">+ ${location.visits.length - 5} more visits</div>` : ''}
</div>
</div>
`;
}
bindResultEvents() {
const locationResults = this.resultsContainer.querySelectorAll('.location-result');
locationResults.forEach(result => {
result.addEventListener('click', (e) => {
const index = parseInt(e.currentTarget.dataset.locationIndex);
this.focusOnLocation(this.searchResults[index]);
});
});
}
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;
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();
}
clearSearch() {
this.searchInput.value = '';
this.clearButton.classList.add('hidden');
this.hideResults();
this.clearSearchMarkers();
this.currentSearchQuery = '';
}
toggleSearchBar() {
if (this.searchVisible) {
this.hideSearchBar();
} else {
this.showSearchBar();
}
}
showSearchBar() {
// Calculate position relative to the toggle button using viewport coordinates
const buttonRect = this.toggleButton.getBoundingClientRect();
// Position search container to the left of the button, aligned vertically
// Using fixed positioning relative to viewport
const searchTop = buttonRect.top; // Same vertical position as button (viewport coordinates)
const searchRight = window.innerWidth - buttonRect.left + 10; // 10px gap to the left of button
// Debug logging to see actual values
console.log('Button rect:', buttonRect);
console.log('Window width:', window.innerWidth);
console.log('Calculated top:', searchTop);
console.log('Calculated right:', searchRight);
this.searchContainer.style.top = searchTop + 'px';
this.searchContainer.style.right = searchRight + 'px';
this.searchContainer.classList.remove('hidden');
this.searchVisible = true;
// Focus the search input for immediate typing
setTimeout(() => {
this.searchInput.focus();
}, 100);
}
hideSearchBar() {
this.searchContainer.classList.add('hidden');
this.hideResults();
this.clearSearch();
this.searchVisible = false;
}
clearSearchMarkers() {
if (this.searchMarkersLayer) {
this.map.removeLayer(this.searchMarkersLayer);
this.searchMarkersLayer = null;
}
}
hideResults() {
this.resultsContainer.classList.add('hidden');
}
// Utility methods
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
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 };

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class LocationSearchResultSerializer
def initialize(search_result)
@search_result = search_result
end
def call
{
query: @search_result[:query],
locations: serialize_locations(@search_result[:locations]),
total_locations: @search_result[:total_locations],
search_metadata: @search_result[:search_metadata]
}
end
private
def serialize_locations(locations)
locations.map do |location|
{
place_name: location[:place_name],
coordinates: location[:coordinates],
address: location[:address],
total_visits: location[:total_visits],
first_visit: location[:first_visit],
last_visit: location[:last_visit],
visits: serialize_visits(location[:visits])
}
end
end
def serialize_visits(visits)
visits.map do |visit|
{
timestamp: visit[:timestamp],
date: visit[:date],
coordinates: visit[:coordinates],
distance_meters: visit[:distance_meters],
duration_estimate: visit[:duration_estimate],
points_count: visit[:points_count],
accuracy_meters: visit[:accuracy_meters],
visit_details: visit[:visit_details]
}
end
end
end

View file

@ -0,0 +1,192 @@
# frozen_string_literal: true
module LocationSearch
class GeocodingService
MAX_RESULTS = 5
CACHE_TTL = 1.hour
def initialize
@cache_key_prefix = 'location_search:geocoding'
end
def search(query)
return [] if query.blank?
cache_key = "#{@cache_key_prefix}:#{Digest::SHA256.hexdigest(query.downcase)}"
Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
perform_geocoding_search(query)
end
rescue StandardError => e
Rails.logger.error "Geocoding search failed for query '#{query}': #{e.message}"
[]
end
def provider_name
Geocoder.config.lookup.to_s.capitalize
end
private
def perform_geocoding_search(query)
results = Geocoder.search(query, limit: MAX_RESULTS)
return [] if results.blank?
normalize_geocoding_results(results)
end
def normalize_geocoding_results(results)
normalized_results = []
results.each do |result|
next unless valid_result?(result)
normalized_result = {
lat: result.latitude.to_f,
lon: result.longitude.to_f,
name: extract_name(result),
address: extract_address(result),
type: extract_type(result),
provider_data: extract_provider_data(result)
}
normalized_results << normalized_result
end
# Remove duplicates based on coordinates (within 100m)
deduplicate_results(normalized_results)
end
def valid_result?(result)
result.present? &&
result.latitude.present? &&
result.longitude.present? &&
result.latitude.to_f.abs <= 90 &&
result.longitude.to_f.abs <= 180
end
def extract_name(result)
case provider_name.downcase
when 'photon'
extract_photon_name(result)
when 'nominatim'
extract_nominatim_name(result)
when 'geoapify'
extract_geoapify_name(result)
else
result.address || result.data&.dig('display_name') || 'Unknown location'
end
end
def extract_address(result)
case provider_name.downcase
when 'photon'
extract_photon_address(result)
when 'nominatim'
extract_nominatim_address(result)
when 'geoapify'
extract_geoapify_address(result)
else
result.address || result.data&.dig('display_name') || ''
end
end
def extract_type(result)
data = result.data || {}
case provider_name.downcase
when 'photon'
data.dig('properties', 'osm_key') || data.dig('properties', 'type') || 'unknown'
when 'nominatim'
data['type'] || data['class'] || 'unknown'
when 'geoapify'
data.dig('properties', 'datasource', 'sourcename') || data.dig('properties', 'place_type') || 'unknown'
else
'unknown'
end
end
def extract_provider_data(result)
{
osm_id: result.data&.dig('properties', 'osm_id'),
osm_type: result.data&.dig('properties', 'osm_type'),
place_rank: result.data&.dig('place_rank'),
importance: result.data&.dig('importance')
}
end
# Provider-specific extractors
def extract_photon_name(result)
properties = result.data&.dig('properties') || {}
properties['name'] || properties['street'] || properties['city'] || 'Unknown location'
end
def extract_photon_address(result)
properties = result.data&.dig('properties') || {}
parts = []
parts << properties['street'] if properties['street'].present?
parts << properties['housenumber'] if properties['housenumber'].present?
parts << properties['city'] if properties['city'].present?
parts << properties['state'] if properties['state'].present?
parts << properties['country'] if properties['country'].present?
parts.join(', ')
end
def extract_nominatim_name(result)
data = result.data || {}
data['display_name']&.split(',')&.first || 'Unknown location'
end
def extract_nominatim_address(result)
result.data&.dig('display_name') || ''
end
def extract_geoapify_name(result)
properties = result.data&.dig('properties') || {}
properties['name'] || properties['street'] || properties['city'] || 'Unknown location'
end
def extract_geoapify_address(result)
properties = result.data&.dig('properties') || {}
properties['formatted'] || ''
end
def deduplicate_results(results)
deduplicated = []
results.each do |result|
# Check if there's already a result within 100m
duplicate = deduplicated.find do |existing|
distance = calculate_distance(
result[:lat], result[:lon],
existing[:lat], existing[:lon]
)
distance < 100 # meters
end
deduplicated << result unless duplicate
end
deduplicated
end
def calculate_distance(lat1, lon1, lat2, lon2)
# Haversine formula for distance calculation in meters
rad_per_deg = Math::PI / 180
rkm = 6371000 # Earth radius in meters
dlat_rad = (lat2 - lat1) * rad_per_deg
dlon_rad = (lon2 - lon1) * rad_per_deg
lat1_rad = lat1 * rad_per_deg
lat2_rad = lat2 * rad_per_deg
a = Math.sin(dlat_rad / 2)**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon_rad / 2)**2
c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
rkm * c
end
end
end

View file

@ -0,0 +1,116 @@
# frozen_string_literal: true
module LocationSearch
class PointFinder
def initialize(user, params = {})
@user = user
@query = params[:query]
@limit = params[:limit] || 50
@date_from = params[:date_from]
@date_to = params[:date_to]
@radius_override = params[:radius_override]
end
def call
return empty_result if @query.blank?
geocoded_locations = geocoding_service.search(@query)
return empty_result if geocoded_locations.empty?
find_matching_points(geocoded_locations)
end
private
def geocoding_service
@geocoding_service ||= LocationSearch::GeocodingService.new
end
def find_matching_points(geocoded_locations)
results = []
geocoded_locations.each do |location|
matching_points = spatial_matcher.find_points_near(
@user,
location[:lat],
location[:lon],
determine_search_radius(location),
date_filter_options
)
next if matching_points.empty?
visits = result_aggregator.group_points_into_visits(matching_points)
results << {
place_name: location[:name],
coordinates: [location[:lat], location[:lon]],
address: location[:address],
total_visits: visits.length,
first_visit: visits.first[:date],
last_visit: visits.last[:date],
visits: visits.take(@limit)
}
end
{
query: @query,
locations: results,
total_locations: results.length,
search_metadata: {
geocoding_provider: geocoding_service.provider_name,
candidates_found: geocoded_locations.length,
search_time_ms: nil # TODO: implement timing
}
}
end
def determine_search_radius(location)
return @radius_override if @radius_override.present?
# Smart radius selection based on place type
place_type = location[:type]&.downcase || ''
case place_type
when /shop|store|restaurant|cafe|supermarket|mall/
75 # meters - specific businesses
when /street|road|avenue|boulevard/
50 # meters - street addresses
when /neighborhood|district|area/
300 # meters - areas
when /city|town|village/
1000 # meters - cities
else
100 # meters - default for unknown types
end
end
def spatial_matcher
@spatial_matcher ||= LocationSearch::SpatialMatcher.new
end
def result_aggregator
@result_aggregator ||= LocationSearch::ResultAggregator.new
end
def date_filter_options
{
date_from: @date_from,
date_to: @date_to
}
end
def empty_result
{
query: @query,
locations: [],
total_locations: 0,
search_metadata: {
geocoding_provider: nil,
candidates_found: 0,
search_time_ms: 0
}
}
end
end
end

View file

@ -0,0 +1,105 @@
# frozen_string_literal: true
module LocationSearch
class ResultAggregator
# Time threshold for grouping consecutive points into visits (minutes)
VISIT_TIME_THRESHOLD = 30
def group_points_into_visits(points)
return [] if points.empty?
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
else
# Finalize current visit and start a new one
visits << create_visit_from_points(current_visit_points) if current_visit_points.any?
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
private
def within_visit_threshold?(previous_point, current_point)
time_diff = (current_point[:timestamp] - previous_point[:timestamp]).abs / 60.0 # minutes
time_diff <= VISIT_TIME_THRESHOLD
end
def create_visit_from_points(points)
return nil if points.empty?
# Sort points by timestamp to get chronological order
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
else
# Single point visit - estimate based on typical stay time
15 # minutes
end
# 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)
{
timestamp: first_point[:timestamp],
date: first_point[:date],
coordinates: most_accurate_point[:coordinates],
distance_meters: average_distance,
duration_estimate: format_duration(duration_minutes),
points_count: points.length,
accuracy_meters: most_accurate_point[:accuracy],
visit_details: {
start_time: first_point[:date],
end_time: last_point[:date],
duration_minutes: duration_minutes,
city: most_accurate_point[:city],
country: most_accurate_point[:country],
altitude_range: calculate_altitude_range(points)
}
}
end
def format_duration(minutes)
return "~#{minutes}m" if minutes < 60
hours = minutes / 60
remaining_minutes = minutes % 60
if remaining_minutes == 0
"~#{hours}h"
else
"~#{hours}h #{remaining_minutes}m"
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
"#{min_altitude}m - #{max_altitude}m"
end
end
end
end

View file

@ -0,0 +1,80 @@
# frozen_string_literal: true
module LocationSearch
class SpatialMatcher
def initialize
# Using PostGIS for efficient spatial queries
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) }
.sort_by { |point| point[:timestamp] }
.reverse # Most recent first
end
private
def build_spatial_query(user, latitude, longitude, radius_meters, date_options = {})
date_filter = build_date_filter(date_options)
<<~SQL
SELECT
p.id,
p.timestamp,
p.latitude,
p.longitude,
p.city,
p.country,
p.altitude,
p.accuracy,
ST_Distance(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography) as distance_meters,
TO_TIMESTAMP(p.timestamp) as recorded_at
FROM points p
WHERE p.user_id = #{user.id}
AND ST_DWithin(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography, #{radius_meters})
#{date_filter}
ORDER BY p.timestamp DESC;
SQL
end
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
def format_point_result(row)
{
id: row['id'].to_i,
timestamp: row['timestamp'].to_i,
coordinates: [row['latitude'].to_f, row['longitude'].to_f],
city: row['city'],
country: row['country'],
altitude: row['altitude']&.to_i,
accuracy: row['accuracy']&.to_i,
distance_meters: row['distance_meters'].to_f.round(2),
recorded_at: row['recorded_at'],
date: Time.zone.at(row['timestamp'].to_i).iso8601
}
end
end
end

View file

@ -100,6 +100,7 @@ Rails.application.routes.draw do
get 'users/me', to: 'users#me'
resources :areas, only: %i[index create update destroy]
resources :locations, only: %i[index]
resources :points, only: %i[index create update destroy]
resources :visits, only: %i[index create update destroy] do
get 'possible_places', to: 'visits/possible_places#index', on: :member

View file

@ -0,0 +1,254 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Api::V1::LocationsController, type: :request do
let(:user) { create(:user) }
let(:api_key) { user.api_key }
let(:headers) { { 'Authorization' => "Bearer #{api_key}" } }
describe 'GET /api/v1/locations' do
context 'with valid authentication' do
context 'when search query is provided' do
let(:search_query) { 'Kaufland' }
let(:mock_search_result) do
{
query: search_query,
locations: [
{
place_name: 'Kaufland Mitte',
coordinates: [52.5200, 13.4050],
address: 'Alexanderplatz 1, Berlin',
total_visits: 2,
first_visit: '2024-01-15T09:30:00Z',
last_visit: '2024-03-20T18:45:00Z',
visits: [
{
timestamp: 1711814700,
date: '2024-03-20T18:45:00Z',
coordinates: [52.5201, 13.4051],
distance_meters: 45.5,
duration_estimate: '~25m',
points_count: 8
}
]
}
],
total_locations: 1,
search_metadata: {
geocoding_provider: 'photon',
candidates_found: 3,
search_time_ms: 234
}
}
end
before do
allow_any_instance_of(LocationSearch::PointFinder)
.to receive(:call).and_return(mock_search_result)
end
it 'returns successful response with search results' do
get '/api/v1/locations', params: { q: search_query }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['query']).to eq(search_query)
expect(json_response['locations']).to be_an(Array)
expect(json_response['locations'].first['place_name']).to eq('Kaufland Mitte')
expect(json_response['total_locations']).to eq(1)
end
it 'includes search metadata in response' do
get '/api/v1/locations', params: { q: search_query }, headers: headers
json_response = JSON.parse(response.body)
expect(json_response['search_metadata']).to include(
'geocoding_provider' => 'photon',
'candidates_found' => 3
)
end
it 'passes search parameters to PointFinder service' do
expect(LocationSearch::PointFinder)
.to receive(:new)
.with(user, hash_including(
query: search_query,
limit: 50,
date_from: nil,
date_to: nil,
radius_override: nil
))
.and_return(double(call: mock_search_result))
get '/api/v1/locations', params: { q: search_query }, headers: headers
end
context 'with additional search parameters' do
let(:params) do
{
q: search_query,
limit: 20,
date_from: '2024-01-01',
date_to: '2024-03-31',
radius_override: 200
}
end
it 'passes all parameters to the service' do
expect(LocationSearch::PointFinder)
.to receive(:new)
.with(user, hash_including(
query: search_query,
limit: 20,
date_from: Date.parse('2024-01-01'),
date_to: Date.parse('2024-03-31'),
radius_override: 200
))
.and_return(double(call: mock_search_result))
get '/api/v1/locations', params: params, headers: headers
end
end
context 'with invalid date parameters' do
it 'handles invalid date_from gracefully' do
expect {
get '/api/v1/locations', params: { q: search_query, date_from: 'invalid-date' }, headers: headers
}.not_to raise_error
expect(response).to have_http_status(:ok)
end
it 'handles invalid date_to gracefully' do
expect {
get '/api/v1/locations', params: { q: search_query, date_to: 'invalid-date' }, headers: headers
}.not_to raise_error
expect(response).to have_http_status(:ok)
end
end
end
context 'when no search results are found' do
let(:empty_result) do
{
query: 'NonexistentPlace',
locations: [],
total_locations: 0,
search_metadata: { geocoding_provider: nil, candidates_found: 0, search_time_ms: 0 }
}
end
before do
allow_any_instance_of(LocationSearch::PointFinder)
.to receive(:call).and_return(empty_result)
end
it 'returns empty results successfully' do
get '/api/v1/locations', params: { q: 'NonexistentPlace' }, headers: headers
expect(response).to have_http_status(:ok)
json_response = JSON.parse(response.body)
expect(json_response['locations']).to be_empty
expect(json_response['total_locations']).to eq(0)
end
end
context 'when search query is missing' do
it 'returns bad request error' do
get '/api/v1/locations', headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Search query parameter (q) is required')
end
end
context 'when search query is blank' do
it 'returns bad request error' do
get '/api/v1/locations', params: { q: ' ' }, headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Search query parameter (q) is required')
end
end
context 'when search query is too long' do
let(:long_query) { 'a' * 201 }
it 'returns bad request error' do
get '/api/v1/locations', params: { q: long_query }, headers: headers
expect(response).to have_http_status(:bad_request)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Search query too long (max 200 characters)')
end
end
context 'when service raises an error' do
before do
allow_any_instance_of(LocationSearch::PointFinder)
.to receive(:call).and_raise(StandardError.new('Service error'))
end
it 'returns internal server error' do
get '/api/v1/locations', params: { q: 'test' }, headers: headers
expect(response).to have_http_status(:internal_server_error)
json_response = JSON.parse(response.body)
expect(json_response['error']).to eq('Search failed. Please try again.')
end
end
end
context 'without authentication' do
it 'returns unauthorized error' do
get '/api/v1/locations', params: { q: 'test' }
expect(response).to have_http_status(:unauthorized)
end
end
context 'with invalid API key' do
let(:invalid_headers) { { 'Authorization' => 'Bearer invalid_key' } }
it 'returns unauthorized error' do
get '/api/v1/locations', params: { q: 'test' }, headers: invalid_headers
expect(response).to have_http_status(:unauthorized)
end
end
context 'with user data isolation' do
let(:user1) { create(:user) }
let(:user2) { create(:user) }
let(:user1_headers) { { 'Authorization' => "Bearer #{user1.api_key}" } }
before do
# Create points for both users
create(:point, user: user1, latitude: 52.5200, longitude: 13.4050)
create(:point, user: user2, latitude: 52.5200, longitude: 13.4050)
# Mock service to verify user isolation
allow(LocationSearch::PointFinder).to receive(:new) do |user, _params|
expect(user).to eq(user1) # Should only be called with user1
double(call: { query: 'test', locations: [], total_locations: 0, search_metadata: {} })
end
end
it 'only searches within the authenticated user data' do
get '/api/v1/locations', params: { q: 'test' }, headers: user1_headers
expect(response).to have_http_status(:ok)
end
end
end
end

View file

@ -0,0 +1,250 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::GeocodingService do
let(:service) { described_class.new }
describe '#search' do
context 'with valid query' do
let(:query) { 'Kaufland Berlin' }
let(:mock_geocoder_result) do
double(
'Geocoder::Result',
latitude: 52.5200,
longitude: 13.4050,
address: 'Kaufland, Alexanderplatz 1, Berlin',
data: {
'properties' => {
'name' => 'Kaufland Mitte',
'street' => 'Alexanderplatz',
'housenumber' => '1',
'city' => 'Berlin',
'country' => 'Germany',
'osm_key' => 'shop',
'osm_id' => '12345'
}
}
)
end
before do
allow(Geocoder).to receive(:search).and_return([mock_geocoder_result])
allow(Geocoder.config).to receive(:lookup).and_return(:photon)
end
it 'returns normalized geocoding results' do
results = service.search(query)
expect(results).to be_an(Array)
expect(results.first).to include(
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland Mitte',
address: 'Alexanderplatz, 1, Berlin, Germany',
type: 'shop'
)
end
it 'includes provider data' do
results = service.search(query)
expect(results.first[:provider_data]).to include(
osm_id: '12345',
osm_type: nil
)
end
it 'caches results' do
expect(Rails.cache).to receive(:fetch).and_call_original
service.search(query)
end
it 'limits results to MAX_RESULTS' do
expect(Geocoder).to receive(:search).with(query, limit: 5)
service.search(query)
end
context 'with cached results' do
let(:cached_results) { [{ lat: 1.0, lon: 2.0, name: 'Cached' }] }
before do
allow(Rails.cache).to receive(:fetch).and_return(cached_results)
end
it 'returns cached results without calling Geocoder' do
expect(Geocoder).not_to receive(:search)
results = service.search(query)
expect(results).to eq(cached_results)
end
end
end
context 'with blank query' do
it 'returns empty array' do
results = service.search('')
expect(results).to eq([])
results = service.search(nil)
expect(results).to eq([])
end
end
context 'when Geocoder returns no results' do
before do
allow(Geocoder).to receive(:search).and_return([])
end
it 'returns empty array' do
results = service.search('nonexistent place')
expect(results).to eq([])
end
end
context 'when Geocoder raises an error' do
before do
allow(Geocoder).to receive(:search).and_raise(StandardError.new('API error'))
end
it 'handles error gracefully and returns empty array' do
expect(Rails.logger).to receive(:error).with(/Geocoding search failed/)
results = service.search('test query')
expect(results).to eq([])
end
end
context 'with invalid coordinates' do
let(:invalid_result) do
double(
'Geocoder::Result',
latitude: 91.0, # Invalid latitude
longitude: 181.0, # Invalid longitude
address: 'Invalid Location',
data: {}
)
end
before do
allow(Geocoder).to receive(:search).and_return([invalid_result])
end
it 'filters out results with invalid coordinates' do
results = service.search('test')
expect(results).to be_empty
end
end
describe '#deduplicate_results' do
let(:duplicate_results) do
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Location A',
address: 'Address A',
type: 'shop'
},
{
lat: 52.5201, # Very close to first location (~11 meters)
lon: 13.4051,
name: 'Location B',
address: 'Address B',
type: 'shop'
},
{
lat: 52.5300, # Far from others
lon: 13.4150,
name: 'Location C',
address: 'Address C',
type: 'restaurant'
}
]
end
before do
allow(service).to receive(:perform_geocoding_search).and_return(duplicate_results)
end
it 'removes locations within 100m of each other' do
results = service.search('test')
expect(results.length).to eq(2)
expect(results.map { |r| r[:name] }).to include('Location A', 'Location C')
end
end
end
describe '#provider_name' do
it 'returns the current geocoding provider name' do
allow(Geocoder.config).to receive(:lookup).and_return(:photon)
expect(service.provider_name).to eq('Photon')
end
end
describe 'provider-specific extraction' do
context 'with Photon provider' do
let(:photon_result) do
double(
'Geocoder::Result',
latitude: 52.5200,
longitude: 13.4050,
data: {
'properties' => {
'name' => 'Kaufland',
'street' => 'Alexanderplatz',
'housenumber' => '1',
'city' => 'Berlin',
'state' => 'Berlin',
'country' => 'Germany'
}
}
)
end
before do
allow(Geocoder).to receive(:search).and_return([photon_result])
allow(Geocoder.config).to receive(:lookup).and_return(:photon)
end
it 'extracts Photon-specific data correctly' do
results = service.search('test')
expect(results.first[:name]).to eq('Kaufland')
expect(results.first[:address]).to eq('Alexanderplatz, 1, Berlin, Berlin, Germany')
end
end
context 'with Nominatim provider' do
let(:nominatim_result) do
double(
'Geocoder::Result',
latitude: 52.5200,
longitude: 13.4050,
data: {
'display_name' => 'Kaufland, Alexanderplatz 1, Berlin, Germany',
'type' => 'shop',
'class' => 'amenity'
}
)
end
before do
allow(Geocoder).to receive(:search).and_return([nominatim_result])
allow(Geocoder.config).to receive(:lookup).and_return(:nominatim)
end
it 'extracts Nominatim-specific data correctly' do
results = service.search('test')
expect(results.first[:name]).to eq('Kaufland')
expect(results.first[:address]).to eq('Kaufland, Alexanderplatz 1, Berlin, Germany')
expect(results.first[:type]).to eq('shop')
end
end
end
end

View file

@ -0,0 +1,226 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::PointFinder do
let(:user) { create(:user) }
let(:service) { described_class.new(user, search_params) }
let(:search_params) { { query: 'Kaufland' } }
describe '#call' do
context 'with valid search query' do
let(:mock_geocoded_locations) do
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland Mitte',
address: 'Alexanderplatz 1, Berlin',
type: 'shop'
}
]
end
let(:mock_matching_points) do
[
{
id: 1,
timestamp: 1711814700,
coordinates: [52.5201, 13.4051],
distance_meters: 45.5,
date: '2024-03-20T18:45:00Z'
}
]
end
let(:mock_visits) do
[
{
timestamp: 1711814700,
date: '2024-03-20T18:45:00Z',
coordinates: [52.5201, 13.4051],
distance_meters: 45.5,
duration_estimate: '~25m',
points_count: 1
}
]
end
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return(mock_geocoded_locations)
allow_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near).and_return(mock_matching_points)
allow_any_instance_of(LocationSearch::ResultAggregator)
.to receive(:group_points_into_visits).and_return(mock_visits)
end
it 'returns search results with location data' do
result = service.call
expect(result[:query]).to eq('Kaufland')
expect(result[:locations]).to be_an(Array)
expect(result[:locations].first).to include(
place_name: 'Kaufland Mitte',
coordinates: [52.5200, 13.4050],
address: 'Alexanderplatz 1, Berlin',
total_visits: 1
)
end
it 'includes search metadata' do
result = service.call
expect(result[:search_metadata]).to include(
:geocoding_provider,
:candidates_found,
:search_time_ms
)
expect(result[:search_metadata][:candidates_found]).to eq(1)
end
it 'calls geocoding service with the query' do
expect_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).with('Kaufland')
service.call
end
it 'calls spatial matcher with correct parameters' do
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, 52.5200, 13.4050, 75, { date_from: nil, date_to: nil })
service.call
end
it 'determines appropriate search radius for shop type' do
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, 75, anything)
service.call
end
context 'with different place types' do
it 'uses smaller radius for street addresses' do
mock_geocoded_locations[0][:type] = 'street'
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, 50, anything)
service.call
end
it 'uses larger radius for neighborhoods' do
mock_geocoded_locations[0][:type] = 'neighborhood'
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, 300, anything)
service.call
end
it 'uses custom radius when override provided' do
service = described_class.new(user, search_params.merge(radius_override: 150))
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, 150, anything)
service.call
end
end
context 'with date filtering' do
let(:search_params) do
{
query: 'Kaufland',
date_from: Date.parse('2024-01-01'),
date_to: Date.parse('2024-03-31')
}
end
it 'passes date filters to spatial matcher' do
expect_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near)
.with(user, anything, anything, anything, {
date_from: Date.parse('2024-01-01'),
date_to: Date.parse('2024-03-31')
})
service.call
end
end
end
context 'when no geocoding results found' do
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return([])
end
it 'returns empty result' do
result = service.call
expect(result[:locations]).to be_empty
expect(result[:total_locations]).to eq(0)
end
end
context 'when no matching points found' do
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return([{ lat: 52.5200, lon: 13.4050, name: 'Test' }])
allow_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near).and_return([])
end
it 'excludes locations with no visits' do
result = service.call
expect(result[:locations]).to be_empty
expect(result[:total_locations]).to eq(0)
end
end
context 'with blank query' do
let(:search_params) { { query: '' } }
it 'returns empty result without calling services' do
expect(LocationSearch::GeocodingService).not_to receive(:new)
result = service.call
expect(result[:locations]).to be_empty
end
end
context 'with limit parameter' do
let(:search_params) { { query: 'Kaufland', limit: 10 } }
let(:many_visits) { Array.new(15) { |i| { timestamp: i, date: "2024-01-#{i+1}T12:00:00Z" } } }
before do
allow_any_instance_of(LocationSearch::GeocodingService)
.to receive(:search).and_return([{ lat: 52.5200, lon: 13.4050, name: 'Test' }])
allow_any_instance_of(LocationSearch::SpatialMatcher)
.to receive(:find_points_near).and_return([{}])
allow_any_instance_of(LocationSearch::ResultAggregator)
.to receive(:group_points_into_visits).and_return(many_visits)
end
it 'limits the number of visits returned' do
result = service.call
expect(result[:locations].first[:visits].length).to eq(10)
end
end
end
end

View file

@ -0,0 +1,295 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::ResultAggregator do
let(:service) { described_class.new }
describe '#group_points_into_visits' do
context 'with empty points array' do
it 'returns empty array' do
result = service.group_points_into_visits([])
expect(result).to eq([])
end
end
context 'with single point' do
let(:single_point) do
{
id: 1,
timestamp: 1711814700,
coordinates: [52.5200, 13.4050],
distance_meters: 45.5,
accuracy: 10,
date: '2024-03-20T18:45:00Z',
city: 'Berlin',
country: 'Germany',
altitude: 100
}
end
it 'creates a single visit' do
result = service.group_points_into_visits([single_point])
expect(result.length).to eq(1)
visit = result.first
expect(visit[:timestamp]).to eq(1711814700)
expect(visit[:coordinates]).to eq([52.5200, 13.4050])
expect(visit[:points_count]).to eq(1)
end
it 'estimates duration for single point visits' do
result = service.group_points_into_visits([single_point])
visit = result.first
expect(visit[:duration_estimate]).to eq('~15m')
expect(visit[:visit_details][:duration_minutes]).to eq(15)
end
end
context 'with consecutive points' do
let(:consecutive_points) do
[
{
id: 1,
timestamp: 1711814700, # 18:45
coordinates: [52.5200, 13.4050],
distance_meters: 45.5,
accuracy: 10,
date: '2024-03-20T18:45:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 2,
timestamp: 1711816500, # 19:15 (30 minutes later)
coordinates: [52.5201, 13.4051],
distance_meters: 48.2,
accuracy: 8,
date: '2024-03-20T19:15:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 3,
timestamp: 1711817400, # 19:30 (15 minutes later)
coordinates: [52.5199, 13.4049],
distance_meters: 42.1,
accuracy: 12,
date: '2024-03-20T19:30:00Z',
city: 'Berlin',
country: 'Germany'
}
]
end
it 'groups consecutive points into single visit' do
result = service.group_points_into_visits(consecutive_points)
expect(result.length).to eq(1)
visit = result.first
expect(visit[:points_count]).to eq(3)
end
it 'calculates visit duration from start to end' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
expect(visit[:duration_estimate]).to eq('~45m')
expect(visit[:visit_details][:duration_minutes]).to eq(45)
end
it 'uses most accurate point coordinates' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
# Point with accuracy 8 should be selected
expect(visit[:coordinates]).to eq([52.5201, 13.4051])
expect(visit[:accuracy_meters]).to eq(8)
end
it 'calculates average distance' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
expected_avg = (45.5 + 48.2 + 42.1) / 3
expect(visit[:distance_meters]).to eq(expected_avg.round(2))
end
it 'sets correct start and end times' do
result = service.group_points_into_visits(consecutive_points)
visit = result.first
expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z')
expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z')
end
end
context 'with separate visits (time gaps)' do
let(:separate_visits_points) do
[
{
id: 1,
timestamp: 1711814700, # 18:45
coordinates: [52.5200, 13.4050],
distance_meters: 45.5,
accuracy: 10,
date: '2024-03-20T18:45:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 2,
timestamp: 1711816500, # 19:15 (30 minutes later - within threshold)
coordinates: [52.5201, 13.4051],
distance_meters: 48.2,
accuracy: 8,
date: '2024-03-20T19:15:00Z',
city: 'Berlin',
country: 'Germany'
},
{
id: 3,
timestamp: 1711820100, # 20:15 (60 minutes after last point - exceeds threshold)
coordinates: [52.5199, 13.4049],
distance_meters: 42.1,
accuracy: 12,
date: '2024-03-20T20:15:00Z',
city: 'Berlin',
country: 'Germany'
}
]
end
it 'creates separate visits when time gap exceeds threshold' do
result = service.group_points_into_visits(separate_visits_points)
expect(result.length).to eq(2)
expect(result.first[:points_count]).to eq(2)
expect(result.last[:points_count]).to eq(1)
end
it 'orders visits by timestamp descending (most recent first)' do
result = service.group_points_into_visits(separate_visits_points)
expect(result.first[:timestamp]).to be > result.last[:timestamp]
end
end
context 'with duration formatting' do
let(:points_with_various_durations) do
# Helper to create points with time differences
base_time = 1711814700
[
# Short visit (25 minutes)
{ id: 1, timestamp: base_time, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' },
{ id: 2, timestamp: base_time + 25 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T19:10:00Z' },
# Long visit (2 hours 15 minutes) - starts 45 minutes after previous to create gap
{ id: 3, timestamp: base_time + 70 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T19:55:00Z' },
{ id: 4, timestamp: base_time + 205 * 60, accuracy: 10, coordinates: [52.5300, 13.4100], distance_meters: 30, date: '2024-03-20T22:10:00Z' }
]
end
it 'formats duration correctly for minutes only' 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')
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')
end
it 'formats duration correctly for hours only' do
# Create points exactly 2 hours apart
exact_hour_points = [
{ id: 1, timestamp: 1711814700, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T18:45:00Z' },
{ id: 2, timestamp: 1711814700 + 120 * 60, accuracy: 10, coordinates: [52.5200, 13.4050], distance_meters: 50, date: '2024-03-20T20:45:00Z' }
]
result = service.group_points_into_visits(exact_hour_points)
expect(result.first[:duration_estimate]).to eq('~2h')
end
end
context 'with altitude data' do
let(:points_with_altitude) do
[
{
id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050],
accuracy: 10, distance_meters: 50, altitude: 100,
date: '2024-03-20T18:45:00Z'
},
{
id: 2, timestamp: 1711815600, coordinates: [52.5201, 13.4051],
accuracy: 10, distance_meters: 50, altitude: 105,
date: '2024-03-20T19:00:00Z'
},
{
id: 3, timestamp: 1711816500, coordinates: [52.5199, 13.4049],
accuracy: 10, distance_meters: 50, altitude: 95,
date: '2024-03-20T19:15:00Z'
}
]
end
it 'includes altitude range in visit details' do
result = service.group_points_into_visits(points_with_altitude)
visit = result.first
expect(visit[:visit_details][:altitude_range]).to eq('95m - 105m')
end
context 'with same altitude for all points' do
before do
points_with_altitude.each { |p| p[:altitude] = 100 }
end
it 'shows single altitude value' do
result = service.group_points_into_visits(points_with_altitude)
visit = result.first
expect(visit[:visit_details][:altitude_range]).to eq('100m')
end
end
context 'with missing altitude data' do
before do
points_with_altitude.each { |p| p.delete(:altitude) }
end
it 'handles missing altitude gracefully' do
result = service.group_points_into_visits(points_with_altitude)
visit = result.first
expect(visit[:visit_details][:altitude_range]).to be_nil
end
end
end
context 'with unordered points' do
let(:unordered_points) do
[
{ id: 3, timestamp: 1711817400, coordinates: [52.5199, 13.4049], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:30:00Z' },
{ id: 1, timestamp: 1711814700, coordinates: [52.5200, 13.4050], accuracy: 10, distance_meters: 50, date: '2024-03-20T18:45:00Z' },
{ id: 2, timestamp: 1711816500, coordinates: [52.5201, 13.4051], accuracy: 10, distance_meters: 50, date: '2024-03-20T19:15:00Z' }
]
end
it 'handles unordered input correctly' do
result = service.group_points_into_visits(unordered_points)
visit = result.first
expect(visit[:visit_details][:start_time]).to eq('2024-03-20T18:45:00Z')
expect(visit[:visit_details][:end_time]).to eq('2024-03-20T19:30:00Z')
end
end
end
end

View file

@ -0,0 +1,231 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe LocationSearch::SpatialMatcher do
let(:service) { described_class.new }
let(:user) { create(:user) }
let(:latitude) { 52.5200 }
let(:longitude) { 13.4050 }
let(:radius_meters) { 100 }
describe '#find_points_near' do
let!(:near_point) do
create(:point,
user: user,
latitude: 52.5201,
longitude: 13.4051,
timestamp: 1.hour.ago.to_i,
city: 'Berlin',
country: 'Germany',
altitude: 100,
accuracy: 5
)
end
let!(:far_point) do
create(:point,
user: user,
latitude: 52.6000, # ~9km away
longitude: 13.5000,
timestamp: 2.hours.ago.to_i
)
end
let!(:other_user_point) do
create(:point,
user: create(:user),
latitude: 52.5201,
longitude: 13.4051,
timestamp: 30.minutes.ago.to_i
)
end
context 'with points within radius' do
it 'returns points within the specified radius' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.length).to eq(1)
expect(results.first[:id]).to eq(near_point.id)
end
it 'excludes points outside the radius' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
point_ids = results.map { |r| r[:id] }
expect(point_ids).not_to include(far_point.id)
end
it 'only includes points from the specified user' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
point_ids = results.map { |r| r[:id] }
expect(point_ids).not_to include(other_user_point.id)
end
it 'includes calculated distance' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.first[:distance_meters]).to be_a(Float)
expect(results.first[:distance_meters]).to be < radius_meters
end
it 'includes point attributes' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
point = results.first
expect(point).to include(
id: near_point.id,
timestamp: near_point.timestamp,
coordinates: [near_point.latitude.to_f, near_point.longitude.to_f],
city: 'Berlin',
country: 'Germany',
altitude: 100,
accuracy: 5
)
end
it 'includes ISO8601 formatted date' do
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.first[:date]).to match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
end
it 'orders results by timestamp descending (most recent first)' do
# Create another nearby point with older timestamp
older_point = create(:point,
user: user,
latitude: 52.5199,
longitude: 13.4049,
timestamp: 3.hours.ago.to_i
)
results = service.find_points_near(user, latitude, longitude, radius_meters)
expect(results.first[:id]).to eq(near_point.id) # More recent
expect(results.last[:id]).to eq(older_point.id) # Older
end
end
context 'with date filtering' do
let(:date_options) do
{
date_from: 2.days.ago.to_date,
date_to: Date.current
}
end
let!(:old_point) do
create(:point,
user: user,
latitude: 52.5201,
longitude: 13.4051,
timestamp: 1.week.ago.to_i
)
end
it 'filters points by date range' do
results = service.find_points_near(user, latitude, longitude, radius_meters, date_options)
point_ids = results.map { |r| r[:id] }
expect(point_ids).to include(near_point.id)
expect(point_ids).not_to include(old_point.id)
end
context 'with only date_from' do
let(:date_options) { { date_from: 2.hours.ago.to_date } }
it 'includes points after date_from' do
results = service.find_points_near(user, latitude, longitude, radius_meters, date_options)
point_ids = results.map { |r| r[:id] }
expect(point_ids).to include(near_point.id)
end
end
context 'with only date_to' do
let(:date_options) { { date_to: 2.days.ago.to_date } }
it 'includes points before date_to' do
results = service.find_points_near(user, latitude, longitude, radius_meters, date_options)
point_ids = results.map { |r| r[:id] }
expect(point_ids).to include(old_point.id)
expect(point_ids).not_to include(near_point.id)
end
end
end
context 'with no points within radius' do
it 'returns empty array' do
results = service.find_points_near(user, 60.0, 30.0, 100) # Far away coordinates
expect(results).to be_empty
end
end
context 'with edge cases' do
it 'handles points at the exact radius boundary' do
# This test would require creating a point at exactly 100m distance
# For simplicity, we'll test with a very small radius that should exclude our test point
results = service.find_points_near(user, latitude, longitude, 1) # 1 meter radius
expect(results).to be_empty
end
it 'handles negative coordinates' do
# Create point with negative coordinates
negative_point = create(:point,
user: user,
latitude: -33.8688, # Sydney
longitude: 151.2093,
timestamp: 1.hour.ago.to_i
)
results = service.find_points_near(user, -33.8688, 151.2093, 1000)
expect(results.length).to eq(1)
expect(results.first[:id]).to eq(negative_point.id)
end
it 'handles coordinates near poles' do
# Create point near north pole
polar_point = create(:point,
user: user,
latitude: 89.0,
longitude: 0.0,
timestamp: 1.hour.ago.to_i
)
results = service.find_points_near(user, 89.0, 0.0, 1000)
expect(results.length).to eq(1)
expect(results.first[:id]).to eq(polar_point.id)
end
end
context 'with large datasets' do
before do
# Create many points to test performance
50.times do |i|
create(:point,
user: user,
latitude: latitude + (i * 0.0001), # Spread points slightly
longitude: longitude + (i * 0.0001),
timestamp: i.hours.ago.to_i
)
end
end
it 'efficiently queries large datasets' do
start_time = Time.current
results = service.find_points_near(user, latitude, longitude, 1000)
query_time = Time.current - start_time
expect(query_time).to be < 1.0 # Should complete within 1 second
expect(results.length).to be > 40 # Should find most of the points
end
end
end
end

View file

@ -0,0 +1,346 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Location Search Feature', type: :system, js: true do
let(:user) { create(:user) }
before do
driven_by(:selenium_headless_chrome)
sign_in user
# Create some test points near Berlin
create(:point,
user: user,
latitude: 52.5200,
longitude: 13.4050,
timestamp: 1.day.ago.to_i,
city: 'Berlin',
country: 'Germany'
)
create(:point,
user: user,
latitude: 52.5201,
longitude: 13.4051,
timestamp: 2.hours.ago.to_i,
city: 'Berlin',
country: 'Germany'
)
# Mock the geocoding service to avoid external API calls
allow_any_instance_of(LocationSearch::GeocodingService).to receive(:search) do |_service, query|
case query.downcase
when /kaufland/
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Kaufland Mitte',
address: 'Alexanderplatz 1, Berlin',
type: 'shop'
}
]
when /nonexistent/
[]
else
[
{
lat: 52.5200,
lon: 13.4050,
name: 'Generic Location',
address: 'Berlin, Germany',
type: 'unknown'
}
]
end
end
end
describe 'Search Bar' do
before do
visit map_path
# Wait for map to load
expect(page).to have_css('#map')
sleep(2) # Give time for JavaScript to initialize
end
it 'displays search toggle button on the map' do
expect(page).to have_css('#location-search-toggle')
expect(page).to have_css('button:contains("🔍")')
end
it 'initially hides the search bar' do
expect(page).to have_css('#location-search-container.hidden')
end
it 'shows search bar when toggle button is clicked' do
find('#location-search-toggle').click
expect(page).to have_css('#location-search-container:not(.hidden)')
expect(page).to have_css('#location-search-input')
end
it 'hides search bar when toggle button is clicked again' do
# Show search bar first
find('#location-search-toggle').click
expect(page).to have_css('#location-search-container:not(.hidden)')
# Hide it
find('#location-search-toggle').click
expect(page).to have_css('#location-search-container.hidden')
end
it 'shows placeholder text in search input when visible' do
find('#location-search-toggle').click
search_input = find('#location-search-input')
expect(search_input[:placeholder]).to include('Search locations')
end
context 'when performing a search' do
before do
# Show the search bar first
find('#location-search-toggle').click
end
it 'shows loading state during search' do
fill_in 'location-search-input', with: 'Kaufland'
within('#location-search-container') do
click_button '🔍'
end
# Should show loading indicator briefly
expect(page).to have_content('Searching for "Kaufland"')
end
it 'displays search results for existing locations' do
fill_in 'location-search-input', with: 'Kaufland'
within('#location-search-container') do
click_button '🔍'
end
# Wait for results to appear
within('#location-search-results') do
expect(page).to have_content('Kaufland Mitte')
expect(page).to have_content('Alexanderplatz 1, Berlin')
expect(page).to have_content('visit(s)')
end
end
it 'shows visit details in results' do
fill_in 'location-search-input', with: 'Kaufland'
within('#location-search-container') do
click_button '🔍'
end
within('#location-search-results') do
# Should show visit timestamps and distances
expect(page).to have_css('.location-result')
expect(page).to have_content('m away') # distance indicator
end
end
it 'handles search with Enter key' do
fill_in 'location-search-input', with: 'Kaufland'
find('#location-search-input').send_keys(:enter)
within('#location-search-results') do
expect(page).to have_content('Kaufland Mitte')
end
end
it 'displays appropriate message for no results' do
fill_in 'location-search-input', with: 'NonexistentPlace'
click_button '🔍'
within('#location-search-results') do
expect(page).to have_content('No visits found for "NonexistentPlace"')
end
end
end
context 'with search interaction' do
before do
# Show the search bar first
find('#location-search-toggle').click
end
it 'focuses search input when search bar is shown' do
expect(page).to have_css('#location-search-input:focus')
end
it 'closes search bar when Escape key is pressed' do
find('#location-search-input').send_keys(:escape)
expect(page).to have_css('#location-search-container.hidden')
end
it 'shows clear button when text is entered' do
search_input = find('#location-search-input')
clear_button = find('#location-search-clear')
expect(clear_button).not_to be_visible
search_input.fill_in(with: 'test')
expect(clear_button).to be_visible
end
it 'clears search when clear button is clicked' do
search_input = find('#location-search-input')
clear_button = find('#location-search-clear')
search_input.fill_in(with: 'test search')
clear_button.click
expect(search_input.value).to be_empty
expect(clear_button).not_to be_visible
end
it 'hides results and search bar when clicking outside' do
# First, show search bar and perform search
find('#location-search-toggle').click
fill_in 'location-search-input', with: 'Kaufland'
within('#location-search-container') do
click_button '🔍'
end
# Wait for results to show
expect(page).to have_css('#location-search-results:not(.hidden)')
# Click outside the search area (left side of map to avoid controls)
page.execute_script("document.querySelector('#map').dispatchEvent(new MouseEvent('click', {clientX: 100, clientY: 200}))")
# Both results and search bar should be hidden
expect(page).to have_css('#location-search-results.hidden')
expect(page).to have_css('#location-search-container.hidden')
end
end
context 'with map interaction' do
before do
# Show the search bar first
find('#location-search-toggle').click
end
it 'adds search markers to the map' do
fill_in 'location-search-input', with: 'Kaufland'
within('#location-search-container') do
click_button '🔍'
end
# 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
expect(page).to have_content('Found 1 location(s)')
end
it 'focuses map on clicked search result' do
fill_in 'location-search-input', with: 'Kaufland'
within('#location-search-container') do
click_button '🔍'
end
within('#location-search-results') do
find('.location-result').click
end
# Results should be hidden after clicking
expect(page).to have_css('#location-search-results.hidden')
end
end
context 'with error handling' do
before do
# Mock API to return error
allow_any_instance_of(LocationSearch::PointFinder).to receive(:call).and_raise(StandardError.new('API Error'))
end
it 'handles API errors gracefully' do
fill_in 'location-search-input', with: 'test'
click_button '🔍'
within('#location-search-results') do
expect(page).to have_content('Failed to search locations')
end
end
end
context 'with authentication' do
it 'includes API key in search requests' do
# This test verifies that the search component receives the API key
# from the data attribute and includes it in requests
map_element = find('#map')
expect(map_element['data-api_key']).to eq(user.api_key)
end
end
end
describe 'Search API Integration' do
it 'makes authenticated requests to the search API' do
# Test that the frontend makes proper API calls
visit map_path
fill_in 'location-search-input', with: 'Kaufland'
# Intercept the API request
expect(page.driver.browser.manage).to receive(:add_cookie).with(
hash_including(name: 'api_request_made')
)
click_button '🔍'
end
end
describe 'Real-world Search Scenarios' do
context 'with business name search' do
it 'finds visits to business locations' do
visit map_path
fill_in 'location-search-input', with: 'Kaufland'
click_button '🔍'
expect(page).to have_content('Kaufland Mitte')
expect(page).to have_content('visit(s)')
end
end
context 'with address search' do
it 'handles street address searches' do
visit map_path
fill_in 'location-search-input', with: 'Alexanderplatz 1'
click_button '🔍'
within('#location-search-results') do
expect(page).to have_content('location(s)')
end
end
end
context 'with multiple search terms' do
it 'handles complex search queries' do
visit map_path
fill_in 'location-search-input', with: 'Kaufland Berlin'
click_button '🔍'
# Should handle multi-word searches
expect(page).to have_content('location(s) for "Kaufland Berlin"')
end
end
end
private
def sign_in(user)
visit new_user_session_path
fill_in 'Email', with: user.email
fill_in 'Password', with: user.password
click_button 'Log in'
end
end