mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-14 11:11:38 -05:00
Add search bar
This commit is contained in:
parent
5b9ed23cae
commit
1709aa612d
17 changed files with 3048 additions and 2 deletions
406
LOCATION_SEARCH_FEATURE_PLAN.md
Normal file
406
LOCATION_SEARCH_FEATURE_PLAN.md
Normal 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
56
app/controllers/api/v1/locations_controller.rb
Normal file
56
app/controllers/api/v1/locations_controller.rb
Normal 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
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
431
app/javascript/maps/location_search.js
Normal file
431
app/javascript/maps/location_search.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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 };
|
||||
47
app/serializers/location_search_result_serializer.rb
Normal file
47
app/serializers/location_search_result_serializer.rb
Normal 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
|
||||
192
app/services/location_search/geocoding_service.rb
Normal file
192
app/services/location_search/geocoding_service.rb
Normal 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
|
||||
116
app/services/location_search/point_finder.rb
Normal file
116
app/services/location_search/point_finder.rb
Normal 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
|
||||
105
app/services/location_search/result_aggregator.rb
Normal file
105
app/services/location_search/result_aggregator.rb
Normal 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
|
||||
80
app/services/location_search/spatial_matcher.rb
Normal file
80
app/services/location_search/spatial_matcher.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
254
spec/requests/api/v1/locations_spec.rb
Normal file
254
spec/requests/api/v1/locations_spec.rb
Normal 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
|
||||
250
spec/services/location_search/geocoding_service_spec.rb
Normal file
250
spec/services/location_search/geocoding_service_spec.rb
Normal 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
|
||||
226
spec/services/location_search/point_finder_spec.rb
Normal file
226
spec/services/location_search/point_finder_spec.rb
Normal 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
|
||||
295
spec/services/location_search/result_aggregator_spec.rb
Normal file
295
spec/services/location_search/result_aggregator_spec.rb
Normal 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
|
||||
231
spec/services/location_search/spatial_matcher_spec.rb
Normal file
231
spec/services/location_search/spatial_matcher_spec.rb
Normal 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
|
||||
346
spec/system/location_search_spec.rb
Normal file
346
spec/system/location_search_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue