mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Implement search by user's points
This commit is contained in:
parent
1709aa612d
commit
2d240c2094
9 changed files with 656 additions and 45 deletions
File diff suppressed because one or more lines are too long
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
class Api::V1::LocationsController < ApiController
|
||||
before_action :validate_search_params, only: [:index]
|
||||
before_action :validate_suggestion_params, only: [:suggestions]
|
||||
|
||||
def index
|
||||
if search_query.present?
|
||||
if search_query.present? || coordinate_search?
|
||||
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
|
||||
render json: { error: 'Search query parameter (q) or coordinates (lat, lon) are required' }, status: :bad_request
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Location search error: #{e.message}"
|
||||
|
|
@ -16,6 +17,29 @@ class Api::V1::LocationsController < ApiController
|
|||
render json: { error: 'Search failed. Please try again.' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def suggestions
|
||||
if search_query.present? && search_query.length >= 2
|
||||
suggestions = LocationSearch::GeocodingService.new.search(search_query)
|
||||
|
||||
# Format suggestions for the frontend
|
||||
formatted_suggestions = suggestions.take(5).map do |suggestion|
|
||||
{
|
||||
name: suggestion[:name],
|
||||
address: suggestion[:address],
|
||||
coordinates: [suggestion[:lat], suggestion[:lon]],
|
||||
type: suggestion[:type]
|
||||
}
|
||||
end
|
||||
|
||||
render json: { suggestions: formatted_suggestions }
|
||||
else
|
||||
render json: { suggestions: [] }
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Suggestions error: #{e.message}"
|
||||
render json: { suggestions: [] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def search_query
|
||||
|
|
@ -25,6 +49,10 @@ class Api::V1::LocationsController < ApiController
|
|||
def search_params
|
||||
{
|
||||
query: search_query,
|
||||
latitude: params[:lat]&.to_f,
|
||||
longitude: params[:lon]&.to_f,
|
||||
name: params[:name],
|
||||
address: params[:address],
|
||||
limit: params[:limit]&.to_i || 50,
|
||||
date_from: parse_date(params[:date_from]),
|
||||
date_to: parse_date(params[:date_to]),
|
||||
|
|
@ -32,13 +60,36 @@ class Api::V1::LocationsController < ApiController
|
|||
}
|
||||
end
|
||||
|
||||
def coordinate_search?
|
||||
params[:lat].present? && params[:lon].present?
|
||||
end
|
||||
|
||||
def validate_search_params
|
||||
if search_query.blank?
|
||||
render json: { error: 'Search query parameter (q) is required' }, status: :bad_request
|
||||
if search_query.blank? && !coordinate_search?
|
||||
render json: { error: 'Search query parameter (q) or coordinates (lat, lon) are required' }, status: :bad_request
|
||||
return false
|
||||
end
|
||||
|
||||
if search_query.length > 200
|
||||
if search_query.present? && search_query.length > 200
|
||||
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
|
||||
return false
|
||||
end
|
||||
|
||||
if coordinate_search?
|
||||
lat = params[:lat]&.to_f
|
||||
lon = params[:lon]&.to_f
|
||||
|
||||
if lat.abs > 90 || lon.abs > 180
|
||||
render json: { error: 'Invalid coordinates: latitude must be between -90 and 90, longitude between -180 and 180' }, status: :bad_request
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def validate_suggestion_params
|
||||
if search_query.present? && search_query.length > 200
|
||||
render json: { error: 'Search query too long (max 200 characters)' }, status: :bad_request
|
||||
return false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -6,9 +6,13 @@ class LocationSearch {
|
|||
this.searchResults = [];
|
||||
this.searchMarkersLayer = null;
|
||||
this.currentSearchQuery = '';
|
||||
this.searchTimeout = null;
|
||||
this.suggestionsVisible = false;
|
||||
this.currentSuggestionIndex = -1;
|
||||
|
||||
this.initializeSearchBar();
|
||||
this.initializeSearchResults();
|
||||
this.initializeSuggestions();
|
||||
}
|
||||
|
||||
initializeSearchBar() {
|
||||
|
|
@ -101,6 +105,18 @@ class LocationSearch {
|
|||
this.resultsContainer = resultsContainer;
|
||||
}
|
||||
|
||||
initializeSuggestions() {
|
||||
// Create suggestions dropdown (positioned below search input)
|
||||
const suggestionsContainer = document.createElement('div');
|
||||
suggestionsContainer.className = 'location-search-suggestions fixed z-50 w-80 max-h-48 overflow-y-auto bg-white rounded-lg shadow-xl border hidden';
|
||||
suggestionsContainer.id = 'location-search-suggestions';
|
||||
|
||||
const mapContainer = document.getElementById('map');
|
||||
mapContainer.appendChild(suggestionsContainer);
|
||||
|
||||
this.suggestionsContainer = suggestionsContainer;
|
||||
}
|
||||
|
||||
bindSearchEvents() {
|
||||
// Toggle search bar visibility
|
||||
this.toggleButton.addEventListener('click', () => {
|
||||
|
|
@ -124,12 +140,43 @@ class LocationSearch {
|
|||
this.clearSearch();
|
||||
});
|
||||
|
||||
// Show clear button when input has content
|
||||
// Show clear button when input has content and handle real-time suggestions
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
if (e.target.value.length > 0) {
|
||||
const query = e.target.value.trim();
|
||||
|
||||
if (query.length > 0) {
|
||||
this.clearButton.classList.remove('hidden');
|
||||
this.debouncedSuggestionSearch(query);
|
||||
} else {
|
||||
this.clearButton.classList.add('hidden');
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard navigation for suggestions
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (this.suggestionsVisible) {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
this.navigateSuggestions(1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
this.navigateSuggestions(-1);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (this.currentSuggestionIndex >= 0) {
|
||||
this.selectSuggestion(this.currentSuggestionIndex);
|
||||
} else {
|
||||
this.performSearch();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
this.hideSuggestions();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -137,8 +184,10 @@ class LocationSearch {
|
|||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.location-search-container') &&
|
||||
!e.target.closest('.location-search-results') &&
|
||||
!e.target.closest('.location-search-suggestions') &&
|
||||
!e.target.closest('#location-search-toggle')) {
|
||||
this.hideResults();
|
||||
this.hideSuggestions();
|
||||
if (this.searchVisible) {
|
||||
this.hideSearchBar();
|
||||
}
|
||||
|
|
@ -406,6 +455,181 @@ class LocationSearch {
|
|||
this.resultsContainer.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Suggestion-related methods
|
||||
debouncedSuggestionSearch(query) {
|
||||
// Clear existing timeout
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
|
||||
// Set new timeout for debounced search
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.performSuggestionSearch(query);
|
||||
}, 300); // 300ms debounce delay
|
||||
}
|
||||
|
||||
async performSuggestionSearch(query) {
|
||||
if (query.length < 2) {
|
||||
this.hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Suggestions failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.displaySuggestions(data.suggestions || []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Suggestion search error:', error);
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
displaySuggestions(suggestions) {
|
||||
if (!suggestions.length) {
|
||||
this.hideSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
// Position suggestions container below search input, aligned with the search container
|
||||
const searchRect = this.searchContainer.getBoundingClientRect();
|
||||
const suggestionsTop = searchRect.bottom + 2;
|
||||
const suggestionsRight = window.innerWidth - searchRect.left;
|
||||
|
||||
this.suggestionsContainer.style.top = suggestionsTop + 'px';
|
||||
this.suggestionsContainer.style.right = suggestionsRight + 'px';
|
||||
|
||||
// Build suggestions HTML
|
||||
let suggestionsHtml = '';
|
||||
suggestions.forEach((suggestion, index) => {
|
||||
const isActive = index === this.currentSuggestionIndex;
|
||||
suggestionsHtml += `
|
||||
<div class="suggestion-item p-2 border-b border-gray-100 hover:bg-gray-50 cursor-pointer text-sm ${isActive ? 'bg-blue-50 text-blue-700' : ''}"
|
||||
data-suggestion-index="${index}">
|
||||
<div class="font-medium">${this.escapeHtml(suggestion.name)}</div>
|
||||
<div class="text-xs text-gray-600">${this.escapeHtml(suggestion.address || '')}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
this.suggestionsContainer.innerHTML = suggestionsHtml;
|
||||
this.suggestionsContainer.classList.remove('hidden');
|
||||
this.suggestionsVisible = true;
|
||||
this.suggestions = suggestions;
|
||||
|
||||
// Bind click events to suggestions
|
||||
this.bindSuggestionEvents();
|
||||
}
|
||||
|
||||
bindSuggestionEvents() {
|
||||
const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
suggestionItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
const index = parseInt(e.currentTarget.dataset.suggestionIndex);
|
||||
this.selectSuggestion(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
navigateSuggestions(direction) {
|
||||
if (!this.suggestions || !this.suggestions.length) return;
|
||||
|
||||
const maxIndex = this.suggestions.length - 1;
|
||||
|
||||
if (direction > 0) {
|
||||
// Arrow down
|
||||
this.currentSuggestionIndex = this.currentSuggestionIndex < maxIndex
|
||||
? this.currentSuggestionIndex + 1
|
||||
: 0;
|
||||
} else {
|
||||
// Arrow up
|
||||
this.currentSuggestionIndex = this.currentSuggestionIndex > 0
|
||||
? this.currentSuggestionIndex - 1
|
||||
: maxIndex;
|
||||
}
|
||||
|
||||
this.highlightActiveSuggestion();
|
||||
}
|
||||
|
||||
highlightActiveSuggestion() {
|
||||
const suggestionItems = this.suggestionsContainer.querySelectorAll('.suggestion-item');
|
||||
|
||||
suggestionItems.forEach((item, index) => {
|
||||
if (index === this.currentSuggestionIndex) {
|
||||
item.classList.add('bg-blue-50', 'text-blue-700');
|
||||
item.classList.remove('bg-gray-50');
|
||||
} else {
|
||||
item.classList.remove('bg-blue-50', 'text-blue-700');
|
||||
item.classList.add('bg-gray-50');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectSuggestion(index) {
|
||||
if (!this.suggestions || index < 0 || index >= this.suggestions.length) return;
|
||||
|
||||
const suggestion = this.suggestions[index];
|
||||
this.searchInput.value = suggestion.name;
|
||||
this.hideSuggestions();
|
||||
this.performCoordinateSearch(suggestion); // Use coordinate-based search for selected suggestion
|
||||
}
|
||||
|
||||
async performCoordinateSearch(suggestion) {
|
||||
this.currentSearchQuery = suggestion.name;
|
||||
this.showLoading();
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: suggestion.coordinates[0],
|
||||
lon: suggestion.coordinates[1],
|
||||
name: suggestion.name,
|
||||
address: suggestion.address || ''
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/v1/locations?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Coordinate search failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.displaySearchResults(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Coordinate search error:', error);
|
||||
this.showError('Failed to search locations. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
hideSuggestions() {
|
||||
this.suggestionsContainer.classList.add('hidden');
|
||||
this.suggestionsVisible = false;
|
||||
this.currentSuggestionIndex = -1;
|
||||
this.suggestions = [];
|
||||
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
this.searchTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
|
|
|
|||
|
|
@ -29,17 +29,39 @@ module LocationSearch
|
|||
private
|
||||
|
||||
def perform_geocoding_search(query)
|
||||
Rails.logger.info "LocationSearch::GeocodingService: Searching for '#{query}' using #{provider_name}"
|
||||
|
||||
# Try original query first
|
||||
results = Geocoder.search(query, limit: MAX_RESULTS)
|
||||
Rails.logger.info "LocationSearch::GeocodingService: Raw geocoder returned #{results.length} results"
|
||||
|
||||
# If we got results but they seem too generic (common chain names),
|
||||
# also try with location context
|
||||
if results.length > 1 && looks_like_chain_store?(query)
|
||||
Rails.logger.info "LocationSearch::GeocodingService: Query looks like chain store, trying with Berlin context"
|
||||
berlin_results = Geocoder.search("#{query} Berlin", limit: MAX_RESULTS)
|
||||
Rails.logger.info "LocationSearch::GeocodingService: Berlin-specific search returned #{berlin_results.length} results"
|
||||
|
||||
# Prioritize Berlin results
|
||||
results = (berlin_results + results).uniq
|
||||
end
|
||||
|
||||
return [] if results.blank?
|
||||
|
||||
normalize_geocoding_results(results)
|
||||
normalized = normalize_geocoding_results(results)
|
||||
Rails.logger.info "LocationSearch::GeocodingService: After normalization: #{normalized.length} results"
|
||||
|
||||
normalized
|
||||
end
|
||||
|
||||
def normalize_geocoding_results(results)
|
||||
normalized_results = []
|
||||
|
||||
results.each do |result|
|
||||
next unless valid_result?(result)
|
||||
results.each_with_index do |result, idx|
|
||||
unless valid_result?(result)
|
||||
Rails.logger.warn "LocationSearch::GeocodingService: Result #{idx} is invalid: lat=#{result.latitude}, lon=#{result.longitude}"
|
||||
next
|
||||
end
|
||||
|
||||
normalized_result = {
|
||||
lat: result.latitude.to_f,
|
||||
|
|
@ -50,11 +72,16 @@ module LocationSearch
|
|||
provider_data: extract_provider_data(result)
|
||||
}
|
||||
|
||||
Rails.logger.info "LocationSearch::GeocodingService: Result #{idx}: '#{normalized_result[:name]}' at [#{normalized_result[:lat]}, #{normalized_result[:lon]}]"
|
||||
|
||||
normalized_results << normalized_result
|
||||
end
|
||||
|
||||
# Remove duplicates based on coordinates (within 100m)
|
||||
deduplicate_results(normalized_results)
|
||||
deduplicated = deduplicate_results(normalized_results)
|
||||
Rails.logger.info "LocationSearch::GeocodingService: After deduplication: #{deduplicated.length} results"
|
||||
|
||||
deduplicated
|
||||
end
|
||||
|
||||
def valid_result?(result)
|
||||
|
|
@ -188,5 +215,17 @@ module LocationSearch
|
|||
|
||||
rkm * c
|
||||
end
|
||||
|
||||
def looks_like_chain_store?(query)
|
||||
chain_patterns = [
|
||||
/\b(netto|kaufland|rewe|edeka|aldi|lidl|penny|real)\b/i,
|
||||
/\b(mcdonalds?|burger king|kfc|subway)\b/i,
|
||||
/\b(shell|aral|esso|bp|total)\b/i,
|
||||
/\b(dm|rossmann|müller)\b/i,
|
||||
/\b(h&m|c&a|zara|primark)\b/i
|
||||
]
|
||||
|
||||
chain_patterns.any? { |pattern| query.match?(pattern) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -5,6 +5,10 @@ module LocationSearch
|
|||
def initialize(user, params = {})
|
||||
@user = user
|
||||
@query = params[:query]
|
||||
@latitude = params[:latitude]
|
||||
@longitude = params[:longitude]
|
||||
@name = params[:name] || 'Selected Location'
|
||||
@address = params[:address] || ''
|
||||
@limit = params[:limit] || 50
|
||||
@date_from = params[:date_from]
|
||||
@date_to = params[:date_to]
|
||||
|
|
@ -12,16 +16,52 @@ module LocationSearch
|
|||
end
|
||||
|
||||
def call
|
||||
if coordinate_search?
|
||||
return coordinate_based_search
|
||||
elsif @query.present?
|
||||
return text_based_search
|
||||
else
|
||||
return empty_result
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def coordinate_search?
|
||||
@latitude.present? && @longitude.present?
|
||||
end
|
||||
|
||||
def coordinate_based_search
|
||||
Rails.logger.info "LocationSearch: Coordinate-based search at [#{@latitude}, #{@longitude}] for '#{@name}'"
|
||||
|
||||
# Create a single location object with the provided coordinates
|
||||
location = {
|
||||
lat: @latitude,
|
||||
lon: @longitude,
|
||||
name: @name,
|
||||
address: @address,
|
||||
type: 'coordinate_search'
|
||||
}
|
||||
|
||||
find_matching_points([location])
|
||||
end
|
||||
|
||||
def text_based_search
|
||||
return empty_result if @query.blank?
|
||||
|
||||
geocoded_locations = geocoding_service.search(@query)
|
||||
|
||||
# Debug: Log geocoding results
|
||||
Rails.logger.info "LocationSearch: Geocoding '#{@query}' returned #{geocoded_locations.length} locations"
|
||||
geocoded_locations.each_with_index do |loc, idx|
|
||||
Rails.logger.info "LocationSearch: [#{idx}] #{loc[:name]} at [#{loc[:lat]}, #{loc[:lon]}] - #{loc[:address]}"
|
||||
end
|
||||
|
||||
return empty_result if geocoded_locations.empty?
|
||||
|
||||
find_matching_points(geocoded_locations)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def geocoding_service
|
||||
@geocoding_service ||= LocationSearch::GeocodingService.new
|
||||
end
|
||||
|
|
@ -30,15 +70,32 @@ module LocationSearch
|
|||
results = []
|
||||
|
||||
geocoded_locations.each do |location|
|
||||
# Debug: Log the geocoded location
|
||||
Rails.logger.info "LocationSearch: Searching for points near #{location[:name]} at [#{location[:lat]}, #{location[:lon]}]"
|
||||
|
||||
matching_points = spatial_matcher.find_points_near(
|
||||
@user,
|
||||
location[:lat],
|
||||
location[:lon],
|
||||
determine_search_radius(location),
|
||||
@radius_override || 500, # Allow radius override, default 500 meters
|
||||
date_filter_options
|
||||
)
|
||||
|
||||
next if matching_points.empty?
|
||||
# Debug: Log the number of matching points found
|
||||
Rails.logger.info "LocationSearch: Found #{matching_points.length} points within #{@radius_override || 500}m radius"
|
||||
|
||||
if matching_points.empty?
|
||||
# Try with a larger radius to see if there are any points nearby
|
||||
wider_search = spatial_matcher.find_points_near(
|
||||
@user,
|
||||
location[:lat],
|
||||
location[:lon],
|
||||
1000, # 1km radius for debugging
|
||||
date_filter_options
|
||||
)
|
||||
Rails.logger.info "LocationSearch: Found #{wider_search.length} points within 1000m radius (debug)"
|
||||
next
|
||||
end
|
||||
|
||||
visits = result_aggregator.group_points_into_visits(matching_points)
|
||||
|
||||
|
|
@ -65,26 +122,6 @@ module LocationSearch
|
|||
}
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,38 @@ module LocationSearch
|
|||
# Using PostGIS for efficient spatial queries
|
||||
end
|
||||
|
||||
# Debug method to test spatial queries directly
|
||||
def debug_points_near(user, latitude, longitude, radius_meters = 1000)
|
||||
query = <<~SQL
|
||||
SELECT
|
||||
p.id,
|
||||
p.timestamp,
|
||||
ST_Y(p.lonlat::geometry) as latitude,
|
||||
ST_X(p.lonlat::geometry) as longitude,
|
||||
p.city,
|
||||
p.country,
|
||||
ST_Distance(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography) as distance_meters
|
||||
FROM points p
|
||||
WHERE p.user_id = #{user.id}
|
||||
AND ST_DWithin(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography, #{radius_meters})
|
||||
ORDER BY distance_meters ASC
|
||||
LIMIT 10;
|
||||
SQL
|
||||
|
||||
puts "=== DEBUG SPATIAL QUERY ==="
|
||||
puts "Searching for user #{user.id} near [#{latitude}, #{longitude}] within #{radius_meters}m"
|
||||
puts "Query: #{query}"
|
||||
|
||||
results = ActiveRecord::Base.connection.exec_query(query)
|
||||
puts "Found #{results.count} points:"
|
||||
|
||||
results.each do |row|
|
||||
puts "- Point #{row['id']}: [#{row['latitude']}, #{row['longitude']}] - #{row['distance_meters'].to_f.round(2)}m away"
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
def find_points_near(user, latitude, longitude, radius_meters, date_options = {})
|
||||
points_query = build_spatial_query(user, latitude, longitude, radius_meters, date_options)
|
||||
|
||||
|
|
@ -25,8 +57,8 @@ module LocationSearch
|
|||
SELECT
|
||||
p.id,
|
||||
p.timestamp,
|
||||
p.latitude,
|
||||
p.longitude,
|
||||
ST_Y(p.lonlat::geometry) as latitude,
|
||||
ST_X(p.lonlat::geometry) as longitude,
|
||||
p.city,
|
||||
p.country,
|
||||
p.altitude,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,11 @@ 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 :locations, only: %i[index] do
|
||||
collection do
|
||||
get 'suggestions'
|
||||
end
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -251,4 +251,118 @@ RSpec.describe Api::V1::LocationsController, type: :request do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/locations/suggestions' do
|
||||
context 'with valid authentication' do
|
||||
let(:mock_suggestions) do
|
||||
[
|
||||
{
|
||||
lat: 52.5200,
|
||||
lon: 13.4050,
|
||||
name: 'Kaufland Mitte',
|
||||
address: 'Alexanderplatz 1, Berlin',
|
||||
type: 'shop'
|
||||
},
|
||||
{
|
||||
lat: 52.5100,
|
||||
lon: 13.4000,
|
||||
name: 'Kaufland Friedrichshain',
|
||||
address: 'Warschauer Str. 80, Berlin',
|
||||
type: 'shop'
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
before do
|
||||
allow_any_instance_of(LocationSearch::GeocodingService)
|
||||
.to receive(:search).and_return(mock_suggestions)
|
||||
end
|
||||
|
||||
context 'with valid search query' do
|
||||
it 'returns formatted suggestions' do
|
||||
get '/api/v1/locations/suggestions', params: { q: 'Kaufland' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['suggestions']).to be_an(Array)
|
||||
expect(json_response['suggestions'].length).to eq(2)
|
||||
|
||||
first_suggestion = json_response['suggestions'].first
|
||||
expect(first_suggestion).to include(
|
||||
'name' => 'Kaufland Mitte',
|
||||
'address' => 'Alexanderplatz 1, Berlin',
|
||||
'coordinates' => [52.5200, 13.4050],
|
||||
'type' => 'shop'
|
||||
)
|
||||
end
|
||||
|
||||
it 'limits suggestions to 5 results' do
|
||||
large_suggestions = Array.new(10) do |i|
|
||||
{
|
||||
lat: 52.5000 + i * 0.001,
|
||||
lon: 13.4000 + i * 0.001,
|
||||
name: "Location #{i}",
|
||||
address: "Address #{i}",
|
||||
type: 'place'
|
||||
}
|
||||
end
|
||||
|
||||
allow_any_instance_of(LocationSearch::GeocodingService)
|
||||
.to receive(:search).and_return(large_suggestions)
|
||||
|
||||
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['suggestions'].length).to eq(5)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with short search query' do
|
||||
it 'returns empty suggestions for queries shorter than 2 characters' do
|
||||
get '/api/v1/locations/suggestions', params: { q: 'a' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['suggestions']).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank query' do
|
||||
it 'returns empty suggestions' do
|
||||
get '/api/v1/locations/suggestions', params: { q: '' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['suggestions']).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when geocoding service raises an error' do
|
||||
before do
|
||||
allow_any_instance_of(LocationSearch::GeocodingService)
|
||||
.to receive(:search).and_raise(StandardError.new('Geocoding error'))
|
||||
end
|
||||
|
||||
it 'returns empty suggestions gracefully' do
|
||||
get '/api/v1/locations/suggestions', params: { q: 'test' }, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['suggestions']).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'without authentication' do
|
||||
it 'returns unauthorized error' do
|
||||
get '/api/v1/locations/suggestions', params: { q: 'test' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -333,12 +333,122 @@ RSpec.describe 'Location Search Feature', type: :system, js: true do
|
|||
expect(page).to have_content('location(s) for "Kaufland Berlin"')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with real-time suggestions' do
|
||||
before do
|
||||
# Mock the geocoding service to return suggestions
|
||||
allow_any_instance_of(LocationSearch::GeocodingService).to receive(:search) do |_service, query|
|
||||
case query.downcase
|
||||
when /kau/
|
||||
[
|
||||
{
|
||||
lat: 52.5200,
|
||||
lon: 13.4050,
|
||||
name: 'Kaufland Mitte',
|
||||
address: 'Alexanderplatz 1, Berlin',
|
||||
type: 'shop'
|
||||
},
|
||||
{
|
||||
lat: 52.5100,
|
||||
lon: 13.4000,
|
||||
name: 'Kaufland Friedrichshain',
|
||||
address: 'Warschauer Str. 80, Berlin',
|
||||
type: 'shop'
|
||||
}
|
||||
]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Show the search bar first
|
||||
find('#location-search-toggle').click
|
||||
end
|
||||
|
||||
it 'shows suggestions as user types' do
|
||||
search_input = find('#location-search-input')
|
||||
search_input.fill_in(with: 'Kau')
|
||||
|
||||
# Wait for debounced search to trigger
|
||||
sleep(0.4)
|
||||
|
||||
within('#location-search-suggestions') do
|
||||
expect(page).to have_content('Kaufland Mitte')
|
||||
expect(page).to have_content('Alexanderplatz 1, Berlin')
|
||||
expect(page).to have_content('Kaufland Friedrichshain')
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows selecting suggestions with mouse click' do
|
||||
search_input = find('#location-search-input')
|
||||
search_input.fill_in(with: 'Kau')
|
||||
sleep(0.4)
|
||||
|
||||
within('#location-search-suggestions') do
|
||||
find('.suggestion-item', text: 'Kaufland Mitte').click
|
||||
end
|
||||
|
||||
expect(search_input.value).to eq('Kaufland Mitte')
|
||||
expect(page).to have_css('#location-search-suggestions.hidden')
|
||||
end
|
||||
|
||||
it 'allows keyboard navigation through suggestions' do
|
||||
search_input = find('#location-search-input')
|
||||
search_input.fill_in(with: 'Kau')
|
||||
sleep(0.4)
|
||||
|
||||
# Navigate down through suggestions
|
||||
search_input.send_keys(:arrow_down)
|
||||
within('#location-search-suggestions') do
|
||||
expect(page).to have_css('.suggestion-item.bg-blue-50', text: 'Kaufland Mitte')
|
||||
end
|
||||
|
||||
search_input.send_keys(:arrow_down)
|
||||
within('#location-search-suggestions') do
|
||||
expect(page).to have_css('.suggestion-item.bg-blue-50', text: 'Kaufland Friedrichshain')
|
||||
end
|
||||
|
||||
# Select with Enter
|
||||
search_input.send_keys(:enter)
|
||||
expect(search_input.value).to eq('Kaufland Friedrichshain')
|
||||
end
|
||||
|
||||
it 'hides suggestions when input is cleared' do
|
||||
search_input = find('#location-search-input')
|
||||
search_input.fill_in(with: 'Kau')
|
||||
sleep(0.4)
|
||||
|
||||
expect(page).to have_css('#location-search-suggestions:not(.hidden)')
|
||||
|
||||
search_input.set('')
|
||||
expect(page).to have_css('#location-search-suggestions.hidden')
|
||||
end
|
||||
|
||||
it 'hides suggestions on Escape key' do
|
||||
search_input = find('#location-search-input')
|
||||
search_input.fill_in(with: 'Kau')
|
||||
sleep(0.4)
|
||||
|
||||
expect(page).to have_css('#location-search-suggestions:not(.hidden)')
|
||||
|
||||
search_input.send_keys(:escape)
|
||||
expect(page).to have_css('#location-search-suggestions.hidden')
|
||||
end
|
||||
|
||||
it 'does not show suggestions for queries shorter than 2 characters' do
|
||||
search_input = find('#location-search-input')
|
||||
search_input.fill_in(with: 'K')
|
||||
sleep(0.4)
|
||||
|
||||
expect(page).to have_css('#location-search-suggestions.hidden')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sign_in(user)
|
||||
visit new_user_session_path
|
||||
visit '/users/sign_in'
|
||||
fill_in 'Email', with: user.email
|
||||
fill_in 'Password', with: user.password
|
||||
click_button 'Log in'
|
||||
|
|
|
|||
Loading…
Reference in a new issue