mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 09:38:11 -05:00
Extract hexagon query to separate class
This commit is contained in:
@@ -285,18 +285,7 @@ export class HexagonGrid {
|
||||
// Calculate opacity based on point density (0.2 to 0.8)
|
||||
const opacity = 0.2 + (pointCount / maxPoints) * 0.6;
|
||||
|
||||
// Calculate color based on density
|
||||
let color = '#3388ff'
|
||||
// let color = '#3388ff'; // Default blue
|
||||
// if (pointCount > maxPoints * 0.7) {
|
||||
// color = '#d73027'; // High density - red
|
||||
// } else if (pointCount > maxPoints * 0.4) {
|
||||
// color = '#fc8d59'; // Medium-high density - orange
|
||||
// } else if (pointCount > maxPoints * 0.2) {
|
||||
// color = '#fee08b'; // Medium density - yellow
|
||||
// } else {
|
||||
// color = '#91bfdb'; // Low density - light blue
|
||||
// }
|
||||
|
||||
return {
|
||||
fillColor: color,
|
||||
|
||||
104
app/queries/hexagon_query.rb
Normal file
104
app/queries/hexagon_query.rb
Normal file
@@ -0,0 +1,104 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class HexagonQuery
|
||||
# Maximum number of hexagons to return in a single request
|
||||
MAX_HEXAGONS_PER_REQUEST = 5000
|
||||
|
||||
attr_reader :min_lon, :min_lat, :max_lon, :max_lat, :hex_size, :user_id, :start_date, :end_date
|
||||
|
||||
def initialize(min_lon:, min_lat:, max_lon:, max_lat:, hex_size:, user_id: nil, start_date: nil, end_date: nil)
|
||||
@min_lon = min_lon
|
||||
@min_lat = min_lat
|
||||
@max_lon = max_lon
|
||||
@max_lat = max_lat
|
||||
@hex_size = hex_size
|
||||
@user_id = user_id
|
||||
@start_date = start_date
|
||||
@end_date = end_date
|
||||
end
|
||||
|
||||
def call
|
||||
ActiveRecord::Base.connection.execute(build_hexagon_sql)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_hexagon_sql
|
||||
user_filter = user_id ? "user_id = #{user_id}" : '1=1'
|
||||
date_filter = build_date_filter
|
||||
|
||||
<<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT
|
||||
ST_Transform(geom, 3857) as geom_utm,
|
||||
geom as geom_wgs84
|
||||
FROM bbox_geom
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
id,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE #{user_filter}
|
||||
#{date_filter}
|
||||
AND ST_Intersects(
|
||||
lonlat::geometry,
|
||||
(SELECT geom FROM bbox_geom)
|
||||
)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
|
||||
FROM bbox_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hex_geom_utm,
|
||||
hex_i,
|
||||
hex_j
|
||||
FROM hex_grid hg
|
||||
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_stats AS (
|
||||
SELECT
|
||||
hwp.hex_geom_utm,
|
||||
hwp.hex_i,
|
||||
hwp.hex_j,
|
||||
COUNT(up.id) as point_count,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
|
||||
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
|
||||
)
|
||||
SELECT
|
||||
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||
hex_i,
|
||||
hex_j,
|
||||
point_count,
|
||||
earliest_point,
|
||||
latest_point,
|
||||
row_number() OVER (ORDER BY point_count DESC) as id
|
||||
FROM hexagon_stats
|
||||
ORDER BY point_count DESC
|
||||
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
|
||||
SQL
|
||||
end
|
||||
|
||||
def build_date_filter
|
||||
return '' unless start_date || end_date
|
||||
|
||||
conditions = []
|
||||
conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date
|
||||
conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date
|
||||
|
||||
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
|
||||
end
|
||||
end
|
||||
@@ -5,8 +5,6 @@ class Maps::HexagonGrid
|
||||
|
||||
# Constants for configuration
|
||||
DEFAULT_HEX_SIZE = 500 # meters (center to edge)
|
||||
TARGET_HEX_EDGE_PX = 20 # pixels (edge length target)
|
||||
MAX_HEXAGONS_PER_REQUEST = 5000
|
||||
MAX_AREA_KM2 = 250_000 # 500km x 500km
|
||||
|
||||
# Validation error classes
|
||||
@@ -29,9 +27,9 @@ class Maps::HexagonGrid
|
||||
@min_lat = params[:min_lat].to_f
|
||||
@max_lon = params[:max_lon].to_f
|
||||
@max_lat = params[:max_lat].to_f
|
||||
@hex_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
|
||||
@viewport_width = params[:viewport_width]&.to_f
|
||||
@viewport_height = params[:viewport_height]&.to_f
|
||||
@hex_size = calculate_dynamic_hex_size(params)
|
||||
@user_id = params[:user_id]
|
||||
@start_date = params[:start_date]
|
||||
@end_date = params[:end_date]
|
||||
@@ -39,61 +37,12 @@ class Maps::HexagonGrid
|
||||
|
||||
def call
|
||||
validate!
|
||||
|
||||
generate_hexagons
|
||||
end
|
||||
|
||||
def area_km2
|
||||
@area_km2 ||= calculate_area_km2
|
||||
end
|
||||
|
||||
def crosses_dateline?
|
||||
min_lon > max_lon
|
||||
end
|
||||
|
||||
def in_polar_region?
|
||||
max_lat.abs > 85 || min_lat.abs > 85
|
||||
end
|
||||
|
||||
def estimated_hexagon_count
|
||||
# Rough estimation based on area
|
||||
# A 500m radius hexagon covers approximately 0.65 km²
|
||||
hexagon_area_km2 = 0.65 * (hex_size / 500.0)**2
|
||||
(area_km2 / hexagon_area_km2).round
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def calculate_dynamic_hex_size(params)
|
||||
# If viewport dimensions are provided, calculate hex_size for 20px edge length
|
||||
if viewport_width && viewport_height && viewport_width > 0 && viewport_height > 0
|
||||
# Calculate the geographic width of the bounding box in meters
|
||||
avg_lat = (min_lat + max_lat) / 2
|
||||
bbox_width_degrees = (max_lon - min_lon).abs
|
||||
bbox_width_meters = bbox_width_degrees * 111_320 * Math.cos(avg_lat * Math::PI / 180)
|
||||
|
||||
# Calculate how many meters per pixel based on current viewport span (zoom-independent)
|
||||
meters_per_pixel = bbox_width_meters / viewport_width
|
||||
|
||||
# For a regular hexagon, the edge length is approximately 0.866 times the radius (center to vertex)
|
||||
# So if we want a 20px edge, we need: edge_length_meters = 20 * meters_per_pixel
|
||||
# And radius = edge_length / 0.866
|
||||
edge_length_meters = TARGET_HEX_EDGE_PX * meters_per_pixel
|
||||
hex_radius_meters = edge_length_meters / 0.866
|
||||
|
||||
# Clamp to reasonable bounds to prevent excessive computation
|
||||
calculated_size = hex_radius_meters.clamp(50, 10_000)
|
||||
|
||||
Rails.logger.debug "Dynamic hex size calculation: bbox_width=#{bbox_width_meters.round}m, viewport=#{viewport_width}px, meters_per_pixel=#{meters_per_pixel.round(2)}, hex_size=#{calculated_size.round}m"
|
||||
|
||||
calculated_size
|
||||
else
|
||||
# Fallback to provided hex_size or default
|
||||
fallback_size = params[:hex_size]&.to_f || DEFAULT_HEX_SIZE
|
||||
Rails.logger.debug "Using fallback hex size: #{fallback_size}m (no viewport dimensions provided)"
|
||||
fallback_size
|
||||
end
|
||||
end
|
||||
|
||||
def validate_bbox_order
|
||||
errors.add(:base, 'min_lon must be less than max_lon') if min_lon >= max_lon
|
||||
errors.add(:base, 'min_lat must be less than max_lat') if min_lat >= max_lat
|
||||
@@ -105,114 +54,20 @@ class Maps::HexagonGrid
|
||||
errors.add(:base, "Area too large (#{area_km2.round} km²). Maximum allowed: #{MAX_AREA_KM2} km²")
|
||||
end
|
||||
|
||||
def calculate_area_km2
|
||||
width = (max_lon - min_lon).abs
|
||||
height = (max_lat - min_lat).abs
|
||||
|
||||
# Convert degrees to approximate kilometers
|
||||
# 1 degree latitude ≈ 111 km
|
||||
# 1 degree longitude ≈ 111 km * cos(latitude)
|
||||
avg_lat = (min_lat + max_lat) / 2
|
||||
width_km = width * 111 * Math.cos(avg_lat * Math::PI / 180)
|
||||
height_km = height * 111
|
||||
|
||||
width_km * height_km
|
||||
end
|
||||
|
||||
def generate_hexagons
|
||||
sql = build_hexagon_sql
|
||||
query = HexagonQuery.new(
|
||||
min_lon:, min_lat:, max_lon:, max_lat:,
|
||||
hex_size:, user_id:, start_date:, end_date:
|
||||
)
|
||||
|
||||
Rails.logger.debug "Generating hexagons for bbox: #{[min_lon, min_lat, max_lon, max_lat]}"
|
||||
Rails.logger.debug "Estimated hexagon count: #{estimated_hexagon_count}"
|
||||
result = query.call
|
||||
|
||||
result = execute_sql(sql)
|
||||
format_hexagons(result)
|
||||
rescue ActiveRecord::StatementInvalid => e
|
||||
Rails.logger.error "PostGIS error generating hexagons: #{e.message}"
|
||||
raise PostGISError, "Failed to generate hexagon grid: #{e.message}"
|
||||
end
|
||||
message = "Failed to generate hexagon grid: #{e.message}"
|
||||
|
||||
def build_hexagon_sql
|
||||
user_filter = user_id ? "user_id = #{user_id}" : '1=1'
|
||||
date_filter = build_date_filter
|
||||
|
||||
<<~SQL
|
||||
WITH bbox_geom AS (
|
||||
SELECT ST_MakeEnvelope(#{min_lon}, #{min_lat}, #{max_lon}, #{max_lat}, 4326) as geom
|
||||
),
|
||||
bbox_utm AS (
|
||||
SELECT
|
||||
ST_Transform(geom, 3857) as geom_utm,
|
||||
geom as geom_wgs84
|
||||
FROM bbox_geom
|
||||
),
|
||||
user_points AS (
|
||||
SELECT
|
||||
lonlat::geometry as point_geom,
|
||||
ST_Transform(lonlat::geometry, 3857) as point_geom_utm,
|
||||
id,
|
||||
timestamp
|
||||
FROM points
|
||||
WHERE #{user_filter}
|
||||
#{date_filter}
|
||||
AND ST_Intersects(
|
||||
lonlat::geometry,
|
||||
(SELECT geom FROM bbox_geom)
|
||||
)
|
||||
),
|
||||
hex_grid AS (
|
||||
SELECT
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).geom as hex_geom_utm,
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).i as hex_i,
|
||||
(ST_HexagonGrid(#{hex_size}, bbox_utm.geom_utm)).j as hex_j
|
||||
FROM bbox_utm
|
||||
),
|
||||
hexagons_with_points AS (
|
||||
SELECT DISTINCT
|
||||
hex_geom_utm,
|
||||
hex_i,
|
||||
hex_j
|
||||
FROM hex_grid hg
|
||||
INNER JOIN user_points up ON ST_Intersects(hg.hex_geom_utm, up.point_geom_utm)
|
||||
),
|
||||
hexagon_stats AS (
|
||||
SELECT
|
||||
hwp.hex_geom_utm,
|
||||
hwp.hex_i,
|
||||
hwp.hex_j,
|
||||
COUNT(up.id) as point_count,
|
||||
MIN(up.timestamp) as earliest_point,
|
||||
MAX(up.timestamp) as latest_point
|
||||
FROM hexagons_with_points hwp
|
||||
INNER JOIN user_points up ON ST_Intersects(hwp.hex_geom_utm, up.point_geom_utm)
|
||||
GROUP BY hwp.hex_geom_utm, hwp.hex_i, hwp.hex_j
|
||||
)
|
||||
SELECT
|
||||
ST_AsGeoJSON(ST_Transform(hex_geom_utm, 4326)) as geojson,
|
||||
hex_i,
|
||||
hex_j,
|
||||
point_count,
|
||||
earliest_point,
|
||||
latest_point,
|
||||
row_number() OVER (ORDER BY point_count DESC) as id
|
||||
FROM hexagon_stats
|
||||
ORDER BY point_count DESC
|
||||
LIMIT #{MAX_HEXAGONS_PER_REQUEST};
|
||||
SQL
|
||||
end
|
||||
|
||||
def build_date_filter
|
||||
return '' unless start_date || end_date
|
||||
|
||||
conditions = []
|
||||
conditions << "timestamp >= EXTRACT(EPOCH FROM '#{start_date}'::timestamp)" if start_date
|
||||
conditions << "timestamp <= EXTRACT(EPOCH FROM '#{end_date}'::timestamp)" if end_date
|
||||
|
||||
conditions.any? ? "AND #{conditions.join(' AND ')}" : ''
|
||||
end
|
||||
|
||||
def execute_sql(sql)
|
||||
ActiveRecord::Base.connection.execute(sql)
|
||||
ExceptionReporter.call(e, message)
|
||||
raise PostGISError, message
|
||||
end
|
||||
|
||||
def format_hexagons(result)
|
||||
@@ -223,8 +78,8 @@ class Maps::HexagonGrid
|
||||
total_points += point_count
|
||||
|
||||
# Parse timestamps and format dates
|
||||
earliest = row['earliest_point'] ? Time.at(row['earliest_point'].to_f).iso8601 : nil
|
||||
latest = row['latest_point'] ? Time.at(row['latest_point'].to_f).iso8601 : nil
|
||||
earliest = row['earliest_point'] ? Time.zone.at(row['earliest_point'].to_f).iso8601 : nil
|
||||
latest = row['latest_point'] ? Time.zone.at(row['latest_point'].to_f).iso8601 : nil
|
||||
|
||||
{
|
||||
type: 'Feature',
|
||||
@@ -237,14 +92,11 @@ class Maps::HexagonGrid
|
||||
hex_size: hex_size,
|
||||
point_count: point_count,
|
||||
earliest_point: earliest,
|
||||
latest_point: latest,
|
||||
density: calculate_density(point_count)
|
||||
latest_point: latest
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
Rails.logger.info "Generated #{hexagons.count} hexagons containing #{total_points} points for area #{area_km2.round(2)} km²"
|
||||
|
||||
{
|
||||
type: 'FeatureCollection',
|
||||
features: hexagons,
|
||||
@@ -260,20 +112,10 @@ class Maps::HexagonGrid
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_density(point_count)
|
||||
# Calculate points per km² for the hexagon
|
||||
# A hexagon with radius 500m has area ≈ 0.65 km²
|
||||
hexagon_area_km2 = 0.65 * (hex_size / 500.0)**2
|
||||
(point_count / hexagon_area_km2).round(2)
|
||||
end
|
||||
|
||||
def build_date_range_metadata
|
||||
return nil unless start_date || end_date
|
||||
|
||||
{
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
}
|
||||
{ start_date:, end_date: }
|
||||
end
|
||||
|
||||
def validate!
|
||||
@@ -283,4 +125,11 @@ class Maps::HexagonGrid
|
||||
|
||||
raise InvalidCoordinatesError, errors.full_messages.join(', ')
|
||||
end
|
||||
|
||||
def viewport_valid?
|
||||
viewport_width &&
|
||||
viewport_height &&
|
||||
viewport_width.positive? &&
|
||||
viewport_height.positive?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,7 +20,7 @@ FactoryBot.define do
|
||||
end
|
||||
|
||||
trait :with_sharing_enabled do
|
||||
after(:create) do |stat, evaluator|
|
||||
after(:create) do |stat, _evaluator|
|
||||
stat.enable_sharing!(expiration: 'permanent')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,36 +106,36 @@ RSpec.describe Stat, type: :model do
|
||||
describe '#calculate_data_bounds' do
|
||||
let(:stat) { create(:stat, year: 2024, month: 6, user:) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
|
||||
context 'when stat has points' do
|
||||
before do
|
||||
# Create test points within the month (June 2024)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.6,
|
||||
longitude: -74.1,
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.6,
|
||||
longitude: -74.1,
|
||||
timestamp: Time.new(2024, 6, 1, 12, 0).to_i)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.8,
|
||||
longitude: -73.9,
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.8,
|
||||
longitude: -73.9,
|
||||
timestamp: Time.new(2024, 6, 15, 15, 0).to_i)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 30, 18, 0).to_i)
|
||||
|
||||
|
||||
# Points outside the month (should be ignored)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 41.0,
|
||||
longitude: -75.0,
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 41.0,
|
||||
longitude: -75.0,
|
||||
timestamp: Time.new(2024, 5, 31, 23, 59).to_i) # May
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 39.0,
|
||||
longitude: -72.0,
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 39.0,
|
||||
longitude: -72.0,
|
||||
timestamp: Time.new(2024, 7, 1, 0, 1).to_i) # July
|
||||
end
|
||||
|
||||
@@ -155,10 +155,10 @@ RSpec.describe Stat, type: :model do
|
||||
|
||||
before do
|
||||
# Add points from a different user (should be ignored)
|
||||
create(:point,
|
||||
user: other_user,
|
||||
latitude: 50.0,
|
||||
longitude: -80.0,
|
||||
create(:point,
|
||||
user: other_user,
|
||||
latitude: 50.0,
|
||||
longitude: -80.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
|
||||
end
|
||||
|
||||
@@ -178,10 +178,10 @@ RSpec.describe Stat, type: :model do
|
||||
let(:single_point_stat) { create(:stat, year: 2024, month: 7, user: single_point_user) }
|
||||
|
||||
before do
|
||||
create(:point,
|
||||
user: single_point_user,
|
||||
latitude: 45.5,
|
||||
longitude: -122.65,
|
||||
create(:point,
|
||||
user: single_point_user,
|
||||
latitude: 45.5,
|
||||
longitude: -122.65,
|
||||
timestamp: Time.new(2024, 7, 15, 14, 30).to_i)
|
||||
end
|
||||
|
||||
@@ -202,13 +202,13 @@ RSpec.describe Stat, type: :model do
|
||||
|
||||
before do
|
||||
# Test with extreme coordinate values
|
||||
create(:point,
|
||||
user: edge_user,
|
||||
create(:point,
|
||||
user: edge_user,
|
||||
latitude: -90.0, # South Pole
|
||||
longitude: -180.0, # Date Line West
|
||||
timestamp: Time.new(2024, 8, 1, 0, 0).to_i)
|
||||
create(:point,
|
||||
user: edge_user,
|
||||
create(:point,
|
||||
user: edge_user,
|
||||
latitude: 90.0, # North Pole
|
||||
longitude: 180.0, # Date Line East
|
||||
timestamp: Time.new(2024, 8, 31, 23, 59).to_i)
|
||||
@@ -243,15 +243,15 @@ RSpec.describe Stat, type: :model do
|
||||
|
||||
before do
|
||||
# Create points outside the target month
|
||||
create(:point,
|
||||
user: empty_month_user,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
create(:point,
|
||||
user: empty_month_user,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 8, 31, 23, 59).to_i) # August
|
||||
create(:point,
|
||||
user: empty_month_user,
|
||||
latitude: 40.8,
|
||||
longitude: -73.9,
|
||||
create(:point,
|
||||
user: empty_month_user,
|
||||
latitude: 40.8,
|
||||
longitude: -73.9,
|
||||
timestamp: Time.new(2024, 10, 1, 0, 1).to_i) # October
|
||||
end
|
||||
|
||||
|
||||
245
spec/queries/hexagon_query_spec.rb
Normal file
245
spec/queries/hexagon_query_spec.rb
Normal file
@@ -0,0 +1,245 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe HexagonQuery, type: :query do
|
||||
let(:user) { create(:user) }
|
||||
let(:min_lon) { -74.1 }
|
||||
let(:min_lat) { 40.6 }
|
||||
let(:max_lon) { -73.9 }
|
||||
let(:max_lat) { 40.8 }
|
||||
let(:hex_size) { 500 }
|
||||
|
||||
describe '#initialize' do
|
||||
it 'sets required parameters' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size
|
||||
)
|
||||
|
||||
expect(query.min_lon).to eq(min_lon)
|
||||
expect(query.min_lat).to eq(min_lat)
|
||||
expect(query.max_lon).to eq(max_lon)
|
||||
expect(query.max_lat).to eq(max_lat)
|
||||
expect(query.hex_size).to eq(hex_size)
|
||||
end
|
||||
|
||||
it 'sets optional parameters' do
|
||||
start_date = '2024-06-01T00:00:00Z'
|
||||
end_date = '2024-06-30T23:59:59Z'
|
||||
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: start_date,
|
||||
end_date: end_date
|
||||
)
|
||||
|
||||
expect(query.user_id).to eq(user.id)
|
||||
expect(query.start_date).to eq(start_date)
|
||||
expect(query.end_date).to eq(end_date)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:query) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
context 'with no points' do
|
||||
it 'executes without error and returns empty result' do
|
||||
result = query.call
|
||||
expect(result.to_a).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points in bounding box' do
|
||||
before do
|
||||
# Create test points within the bounding box
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i)
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.75,
|
||||
longitude: -73.95,
|
||||
timestamp: Time.new(2024, 6, 16, 14, 0).to_i)
|
||||
end
|
||||
|
||||
it 'returns hexagon results with expected structure' do
|
||||
result = query.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
first_hex = result_array.first
|
||||
expect(first_hex).to have_key('geojson')
|
||||
expect(first_hex).to have_key('hex_i')
|
||||
expect(first_hex).to have_key('hex_j')
|
||||
expect(first_hex).to have_key('point_count')
|
||||
expect(first_hex).to have_key('earliest_point')
|
||||
expect(first_hex).to have_key('latest_point')
|
||||
expect(first_hex).to have_key('id')
|
||||
|
||||
# Verify geojson can be parsed
|
||||
geojson = JSON.parse(first_hex['geojson'])
|
||||
expect(geojson).to have_key('type')
|
||||
expect(geojson).to have_key('coordinates')
|
||||
end
|
||||
|
||||
it 'filters by user_id correctly' do
|
||||
other_user = create(:user)
|
||||
# Create points for a different user (should be excluded)
|
||||
create(:point,
|
||||
user: other_user,
|
||||
latitude: 40.72,
|
||||
longitude: -73.98,
|
||||
timestamp: Time.new(2024, 6, 17, 16, 0).to_i)
|
||||
|
||||
result = query.call
|
||||
result_array = result.to_a
|
||||
|
||||
# Should only include hexagons with the specified user's points
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(2) # Only the 2 points from our user
|
||||
end
|
||||
end
|
||||
|
||||
context 'with date filtering' do
|
||||
let(:query_with_dates) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-15T00:00:00Z',
|
||||
end_date: '2024-06-16T23:59:59Z'
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
# Create points within and outside the date range
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7,
|
||||
longitude: -74.0,
|
||||
timestamp: Time.new(2024, 6, 15, 12, 0).to_i) # Within range
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.71,
|
||||
longitude: -74.01,
|
||||
timestamp: Time.new(2024, 6, 20, 12, 0).to_i) # Outside range
|
||||
end
|
||||
|
||||
it 'filters points by date range' do
|
||||
result = query_with_dates.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
# Should only include the point within the date range
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'without user_id filter' do
|
||||
let(:query_no_user) do
|
||||
described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
user1 = create(:user)
|
||||
user2 = create(:user)
|
||||
|
||||
create(:point, user: user1, latitude: 40.7, longitude: -74.0, timestamp: Time.current.to_i)
|
||||
create(:point, user: user2, latitude: 40.75, longitude: -73.95, timestamp: Time.current.to_i)
|
||||
end
|
||||
|
||||
it 'includes points from all users' do
|
||||
result = query_no_user.call
|
||||
result_array = result.to_a
|
||||
|
||||
expect(result_array).not_to be_empty
|
||||
|
||||
# Should include points from both users
|
||||
total_points = result_array.sum { |row| row['point_count'].to_i }
|
||||
expect(total_points).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_date_filter (private method behavior)' do
|
||||
context 'when testing date filter behavior through query execution' do
|
||||
it 'works correctly with start_date only' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-15T00:00:00Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'works correctly with end_date only' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
|
||||
it 'works correctly with both start_date and end_date' do
|
||||
query = described_class.new(
|
||||
min_lon: min_lon,
|
||||
min_lat: min_lat,
|
||||
max_lon: max_lon,
|
||||
max_lat: max_lat,
|
||||
hex_size: hex_size,
|
||||
user_id: user.id,
|
||||
start_date: '2024-06-01T00:00:00Z',
|
||||
end_date: '2024-06-30T23:59:59Z'
|
||||
)
|
||||
|
||||
# Should execute without SQL syntax errors
|
||||
expect { query.call }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -29,8 +29,8 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
before do
|
||||
# Create test points within the date range and bounding box
|
||||
10.times do |i|
|
||||
create(:point,
|
||||
user:,
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.001), # Slightly different coordinates
|
||||
longitude: -74.0 + (i * 0.001),
|
||||
timestamp: Time.new(2024, 6, 15, 12, i).to_i) # Different times
|
||||
@@ -41,7 +41,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons', params: valid_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to have_key('type')
|
||||
expect(json_response['type']).to eq('FeatureCollection')
|
||||
@@ -51,11 +51,11 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
|
||||
it 'requires all bbox parameters' do
|
||||
incomplete_params = valid_params.except(:min_lon)
|
||||
|
||||
|
||||
get '/api/v1/maps/hexagons', params: incomplete_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Missing required parameters')
|
||||
expect(json_response['error']).to include('min_lon')
|
||||
@@ -63,7 +63,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
|
||||
it 'handles service validation errors' do
|
||||
invalid_params = valid_params.merge(min_lon: 200) # Invalid longitude
|
||||
|
||||
|
||||
get '/api/v1/maps/hexagons', params: invalid_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
@@ -71,7 +71,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
|
||||
it 'uses custom hex_size when provided' do
|
||||
custom_params = valid_params.merge(hex_size: 500)
|
||||
|
||||
|
||||
get '/api/v1/maps/hexagons', params: custom_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
@@ -85,9 +85,9 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
before do
|
||||
# Create test points within the stat's month
|
||||
15.times do |i|
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.002),
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.002),
|
||||
longitude: -74.0 + (i * 0.002),
|
||||
timestamp: Time.new(2024, 6, 20, 10, i).to_i)
|
||||
end
|
||||
@@ -97,7 +97,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to have_key('type')
|
||||
expect(json_response['type']).to eq('FeatureCollection')
|
||||
@@ -107,9 +107,9 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
it 'uses stat date range automatically' do
|
||||
# Points outside the stat's month should not be included
|
||||
5.times do |i|
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.003),
|
||||
create(:point,
|
||||
user:,
|
||||
latitude: 40.7 + (i * 0.003),
|
||||
longitude: -74.0 + (i * 0.003),
|
||||
timestamp: Time.new(2024, 7, 1, 8, i).to_i) # July points
|
||||
end
|
||||
@@ -122,11 +122,11 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
context 'with invalid sharing UUID' do
|
||||
it 'returns not found' do
|
||||
invalid_uuid_params = valid_params.merge(uuid: 'invalid-uuid')
|
||||
|
||||
|
||||
get '/api/v1/maps/hexagons', params: invalid_uuid_params
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Shared stats not found or no longer available')
|
||||
end
|
||||
@@ -139,7 +139,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Shared stats not found or no longer available')
|
||||
end
|
||||
@@ -152,7 +152,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons', params: uuid_params
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Shared stats not found or no longer available')
|
||||
end
|
||||
@@ -199,7 +199,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons/bounds', params: date_params, headers: headers
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
|
||||
expect(json_response['min_lat']).to eq(40.6)
|
||||
@@ -210,12 +210,12 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
end
|
||||
|
||||
it 'returns not found when no points exist in date range' do
|
||||
get '/api/v1/maps/hexagons/bounds',
|
||||
params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' },
|
||||
get '/api/v1/maps/hexagons/bounds',
|
||||
params: { start_date: '2023-01-01T00:00:00Z', end_date: '2023-01-31T23:59:59Z' },
|
||||
headers: headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('No data found for the specified date range')
|
||||
expect(json_response['point_count']).to eq(0)
|
||||
@@ -235,7 +235,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons/bounds', params: { uuid: stat.sharing_uuid }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to include('min_lat', 'max_lat', 'min_lng', 'max_lng', 'point_count')
|
||||
expect(json_response['min_lat']).to eq(41.0)
|
||||
@@ -248,7 +248,7 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
get '/api/v1/maps/hexagons/bounds', params: { uuid: 'invalid-uuid' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Shared stats not found or no longer available')
|
||||
end
|
||||
@@ -257,11 +257,11 @@ RSpec.describe 'Api::V1::Maps::Hexagons', type: :request do
|
||||
|
||||
context 'without authentication' do
|
||||
it 'returns unauthorized' do
|
||||
get '/api/v1/maps/hexagons/bounds',
|
||||
get '/api/v1/maps/hexagons/bounds',
|
||||
params: { start_date: '2024-06-01T00:00:00Z', end_date: '2024-06-30T23:59:59Z' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,31 +81,31 @@ RSpec.describe 'Shared::Stats', type: :request do
|
||||
describe 'PATCH /stats/:year/:month/sharing' do
|
||||
context 'when user is signed in' do
|
||||
let!(:stat_to_share) { create(:stat, user:, year: 2024, month: 6) }
|
||||
|
||||
|
||||
before { sign_in user }
|
||||
|
||||
context 'enabling sharing' do
|
||||
it 'enables sharing and returns success' do
|
||||
patch sharing_stats_path(year: 2024, month: 6),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
patch sharing_stats_path(year: 2024, month: 6),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(true)
|
||||
expect(json_response['sharing_url']).to be_present
|
||||
expect(json_response['message']).to eq('Sharing enabled successfully')
|
||||
|
||||
|
||||
stat_to_share.reload
|
||||
expect(stat_to_share.sharing_enabled?).to be(true)
|
||||
expect(stat_to_share.sharing_uuid).to be_present
|
||||
end
|
||||
|
||||
it 'sets custom expiration when provided' do
|
||||
patch sharing_stats_path(year: 2024, month: 6),
|
||||
params: { enabled: '1', expiration: '1_week' },
|
||||
as: :json
|
||||
patch sharing_stats_path(year: 2024, month: 6),
|
||||
params: { enabled: '1', expiration: '1_week' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
stat_to_share.reload
|
||||
@@ -117,16 +117,16 @@ RSpec.describe 'Shared::Stats', type: :request do
|
||||
let!(:enabled_stat) { create(:stat, :with_sharing_enabled, user:, year: 2024, month: 7) }
|
||||
|
||||
it 'disables sharing and returns success' do
|
||||
patch sharing_stats_path(year: 2024, month: 7),
|
||||
params: { enabled: '0' },
|
||||
as: :json
|
||||
patch sharing_stats_path(year: 2024, month: 7),
|
||||
params: { enabled: '0' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['success']).to be(true)
|
||||
expect(json_response['message']).to eq('Sharing disabled successfully')
|
||||
|
||||
|
||||
enabled_stat.reload
|
||||
expect(enabled_stat.sharing_enabled?).to be(false)
|
||||
end
|
||||
@@ -134,9 +134,9 @@ RSpec.describe 'Shared::Stats', type: :request do
|
||||
|
||||
context 'when stat does not exist' do
|
||||
it 'returns not found' do
|
||||
patch sharing_stats_path(year: 2024, month: 12),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
patch sharing_stats_path(year: 2024, month: 12),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
@@ -145,13 +145,13 @@ RSpec.describe 'Shared::Stats', type: :request do
|
||||
|
||||
context 'when user is not signed in' do
|
||||
it 'returns unauthorized' do
|
||||
patch sharing_stats_path(year: 2024, month: 6),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
patch sharing_stats_path(year: 2024, month: 6),
|
||||
params: { enabled: '1' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,5 +111,4 @@ RSpec.describe '/stats', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -179,19 +179,23 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
|
||||
import_stats = import_service.import
|
||||
|
||||
# Verify all entities were imported correctly
|
||||
expect(import_stats[:places_created]).to eq(original_places_count),
|
||||
"Expected #{original_places_count} places to be created, got #{import_stats[:places_created]}"
|
||||
expect(import_stats[:visits_created]).to eq(original_visits_count),
|
||||
"Expected #{original_visits_count} visits to be created, got #{import_stats[:visits_created]}"
|
||||
expect(import_stats[:places_created]).to \
|
||||
eq(original_places_count),
|
||||
"Expected #{original_places_count} places to be created, got #{import_stats[:places_created]}"
|
||||
expect(import_stats[:visits_created]).to \
|
||||
eq(original_visits_count),
|
||||
"Expected #{original_visits_count} visits to be created, got #{import_stats[:visits_created]}"
|
||||
|
||||
# Verify the imported user has access to all their data
|
||||
imported_places_count = import_user.places.distinct.count
|
||||
imported_visits_count = import_user.visits.count
|
||||
|
||||
expect(imported_places_count).to eq(original_places_count),
|
||||
"Expected user to have access to #{original_places_count} places, got #{imported_places_count}"
|
||||
expect(imported_visits_count).to eq(original_visits_count),
|
||||
"Expected user to have #{original_visits_count} visits, got #{imported_visits_count}"
|
||||
expect(imported_places_count).to \
|
||||
eq(original_places_count),
|
||||
"Expected user to have access to #{original_places_count} places, got #{imported_places_count}"
|
||||
expect(imported_visits_count).to \
|
||||
eq(original_visits_count),
|
||||
"Expected user to have #{original_visits_count} visits, got #{imported_visits_count}"
|
||||
|
||||
# Verify specific visits have their place associations
|
||||
imported_visits = import_user.visits.includes(:place)
|
||||
@@ -211,12 +215,13 @@ RSpec.describe 'Users Export-Import Integration', type: :service do
|
||||
private
|
||||
|
||||
def create_full_user_dataset(user)
|
||||
user.update!(settings: {
|
||||
'distance_unit' => 'km',
|
||||
'timezone' => 'America/New_York',
|
||||
'immich_url' => 'https://immich.example.com',
|
||||
'immich_api_key' => 'test-api-key'
|
||||
})
|
||||
user.update!(settings:
|
||||
{
|
||||
'distance_unit' => 'km',
|
||||
'timezone' => 'America/New_York',
|
||||
'immich_url' => 'https://immich.example.com',
|
||||
'immich_api_key' => 'test-api-key'
|
||||
})
|
||||
|
||||
usa = create(:country, name: 'United States', iso_a2: 'US', iso_a3: 'USA')
|
||||
canada = create(:country, name: 'Canada', iso_a2: 'CA', iso_a3: 'CAN')
|
||||
|
||||
Reference in New Issue
Block a user