Introduce latlon to Points and use it instead of latitude and longitude.

This commit is contained in:
Eugene Burmakin 2025-02-21 23:45:36 +01:00
parent d6cec7725d
commit d9eac91834
50 changed files with 755 additions and 225 deletions

View file

@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.24.2 - 2025-02-15 # 0.24.2 - 2025-02-15
## TODO:
- Data migration to convert `latitude` and `longitude` to `lonlat` column.
- Frontend update to use `lonlat` column.
## Fixed ## Fixed
- Fixed a bug where background jobs to import Immich and Photoprism geolocation data data could not be created by non-admin users. - Fixed a bug where background jobs to import Immich and Photoprism geolocation data data could not be created by non-admin users.
@ -17,6 +22,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Restrict access to Sidekiq in non self-hosted mode. - Restrict access to Sidekiq in non self-hosted mode.
- Restrict access to background jobs in non self-hosted mode. - Restrict access to background jobs in non self-hosted mode.
- Restrict access to users management in non self-hosted mode. - Restrict access to users management in non self-hosted mode.
- Points are now using `lonlat` column for storing longitude and latitude.
- Semantic history points are now being imported much faster.
# 0.24.1 - 2025-02-13 # 0.24.1 - 2025-02-13

View file

@ -9,7 +9,7 @@ gem 'bootsnap', require: false
gem 'chartkick' gem 'chartkick'
gem 'data_migrate' gem 'data_migrate'
gem 'devise' gem 'devise'
gem 'geocoder', git: 'https://github.com/alexreisner/geocoder.git', ref: '04ee293' gem 'geocoder', path: '../../geocoder'
gem 'gpx' gem 'gpx'
gem 'groupdate' gem 'groupdate'
gem 'httparty' gem 'httparty'
@ -19,11 +19,12 @@ gem 'lograge'
gem 'oj' gem 'oj'
gem 'pg' gem 'pg'
gem 'prometheus_exporter' gem 'prometheus_exporter'
gem 'activerecord-postgis-adapter', github: 'StoneGod/activerecord-postgis-adapter', branch: 'rails-8' gem 'activerecord-postgis-adapter'
gem 'puma' gem 'puma'
gem 'pundit' gem 'pundit'
gem 'rails', '~> 8.0' gem 'rails', '~> 8.0'
gem 'rgeo' gem 'rgeo'
gem 'rgeo-activerecord'
gem 'rswag-api' gem 'rswag-api'
gem 'rswag-ui' gem 'rswag-ui'
gem 'shrine', '~> 3.6' gem 'shrine', '~> 3.6'

View file

@ -1,18 +1,7 @@
GIT PATH
remote: https://github.com/StoneGod/activerecord-postgis-adapter.git remote: ../../geocoder
revision: 147fd43191ef703e2a1b3654f31d9139201a87e8
branch: rails-8
specs: specs:
activerecord-postgis-adapter (10.0.1) geocoder (1.8.5)
activerecord (~> 8.0.0)
rgeo-activerecord (~> 8.0.0)
GIT
remote: https://github.com/alexreisner/geocoder.git
revision: 04ee2936a30b30a23ded5231d7faf6cf6c27c099
ref: 04ee293
specs:
geocoder (1.8.3)
base64 (>= 0.1.0) base64 (>= 0.1.0)
csv (>= 3.0.0) csv (>= 3.0.0)
@ -71,6 +60,9 @@ GEM
activemodel (= 8.0.1) activemodel (= 8.0.1)
activesupport (= 8.0.1) activesupport (= 8.0.1)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activerecord-postgis-adapter (11.0.0)
activerecord (~> 8.0.0)
rgeo-activerecord (~> 8.0.0)
activestorage (8.0.1) activestorage (8.0.1)
actionpack (= 8.0.1) actionpack (= 8.0.1)
activejob (= 8.0.1) activejob (= 8.0.1)
@ -192,7 +184,7 @@ GEM
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
language_server-protocol (3.17.0.4) language_server-protocol (3.17.0.4)
logger (1.6.5) logger (1.6.6)
lograge (0.14.0) lograge (0.14.0)
actionpack (>= 4) actionpack (>= 4)
activesupport (>= 4) activesupport (>= 4)
@ -461,7 +453,7 @@ PLATFORMS
x86_64-linux x86_64-linux
DEPENDENCIES DEPENDENCIES
activerecord-postgis-adapter! activerecord-postgis-adapter
bootsnap bootsnap
chartkick chartkick
data_migrate data_migrate
@ -490,6 +482,7 @@ DEPENDENCIES
rails (~> 8.0) rails (~> 8.0)
redis redis
rgeo rgeo
rgeo-activerecord
rspec-rails rspec-rails
rswag-api rswag-api
rswag-specs rswag-specs

View file

@ -0,0 +1,52 @@
# frozen_string_literal: true
module Distanceable
extend ActiveSupport::Concern
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
def distance_to(other_point, unit = :km)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
end
# Extract coordinates based on what type other_point is
other_lonlat = extract_point(other_point)
return nil if other_lonlat.nil?
# Calculate distance in meters using PostGIS
distance_in_meters = self.class.connection.select_value(<<-SQL.squish)
SELECT ST_Distance(
ST_GeomFromEWKT('#{lonlat}')::geography,
ST_GeomFromEWKT('#{other_lonlat}')::geography
)
SQL
# Convert to requested unit
distance_in_meters.to_f / DISTANCE_UNITS[unit.to_sym]
end
private
def extract_point(point)
case point
when Array
unless point.length == 2
raise ArgumentError,
'Coordinates array must contain exactly 2 elements [latitude, longitude]'
end
RGeo::Geographic.spherical_factory(srid: 4326).point(point[1], point[0])
when self.class
point.lonlat
else
nil
end
end
end

View file

@ -0,0 +1,74 @@
# frozen_string_literal: true
module Nearable
extend ActiveSupport::Concern
DISTANCE_UNITS = {
km: 1000, # to meters
mi: 1609.34, # to meters
m: 1, # already in meters
ft: 0.3048, # to meters
yd: 0.9144 # to meters
}.freeze
class_methods do
# rubocop:disable Metrics/MethodLength
def near(*args)
latitude, longitude, radius, unit = extract_coordinates_and_options(*args)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
end
# Convert radius to meters for ST_DWithin
radius_in_meters = radius * DISTANCE_UNITS[unit.to_sym]
# Create a point from the given coordinates
point = "SRID=4326;POINT(#{longitude} #{latitude})"
where(<<-SQL.squish)
ST_DWithin(
lonlat::geography,
ST_GeomFromEWKT('#{point}')::geography,
#{radius_in_meters}
)
SQL
end
def with_distance(*args)
latitude, longitude, unit = extract_coordinates_and_options(*args)
unless DISTANCE_UNITS.key?(unit.to_sym)
raise ArgumentError, "Invalid unit. Supported units are: #{DISTANCE_UNITS.keys.join(', ')}"
end
point = "SRID=4326;POINT(#{longitude} #{latitude})"
conversion_factor = 1.0 / DISTANCE_UNITS[unit.to_sym]
select(<<-SQL.squish)
#{table_name}.*,
ST_Distance(
lonlat::geography,
ST_GeomFromEWKT('#{point}')::geography
) * #{conversion_factor} as distance_in_#{unit}
SQL
end
# rubocop:enable Metrics/MethodLength
private
def extract_coordinates_and_options(*args)
coords = args.first
if !coords.is_a?(Array) || coords.length != 2
raise ArgumentError,
'First argument must be coordinates array containing exactly 2 elements [latitude, longitude]'
end
[coords[0], coords[1], *args[1..]].tap do |extracted|
# Set default values for missing options
extracted[2] ||= 1 if extracted.length < 3 # default radius
extracted[3] ||= :km if extracted.length < 4 # default unit
end
end
end
end

View file

@ -1,18 +1,20 @@
# frozen_string_literal: true # frozen_string_literal: true
class Point < ApplicationRecord class Point < ApplicationRecord
reverse_geocoded_by :latitude, :longitude include Nearable
include Distanceable
belongs_to :import, optional: true, counter_cache: true belongs_to :import, optional: true, counter_cache: true
belongs_to :visit, optional: true belongs_to :visit, optional: true
belongs_to :user belongs_to :user
validates :latitude, :longitude, :timestamp, presence: true validates :timestamp, :lonlat, presence: true
validates :timestamp, uniqueness: { validates :lonlat, uniqueness: {
scope: %i[latitude longitude user_id], scope: %i[timestamp user_id],
message: 'already has a point at this location and time for this user', message: 'already has a point at this location and time for this user',
index: true index: true
} }
enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true enum :battery_status, { unknown: 0, unplugged: 1, charging: 2, full: 3 }, suffix: true
enum :trigger, { enum :trigger, {
unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3, unknown: 0, background_event: 1, circular_region_event: 2, beacon_event: 3,
@ -47,6 +49,14 @@ class Point < ApplicationRecord
reverse_geocoded_at.present? reverse_geocoded_at.present?
end end
def lon
lonlat.x
end
def lat
lonlat.y
end
private private
def broadcast_coordinates def broadcast_coordinates

View file

@ -29,7 +29,7 @@ class Visit < ApplicationRecord
return area&.radius if area.present? return area&.radius if area.present?
radius = points.map do |point| radius = points.map do |point|
Geocoder::Calculations.distance_between(center, [point.latitude, point.longitude]) Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
end.max end.max
radius && radius >= 15 ? radius : 15 radius && radius >= 15 ? radius : 15

View file

@ -8,8 +8,8 @@ class Api::SlimPointSerializer
def call def call
{ {
id: point.id, id: point.id,
latitude: point.latitude, latitude: point.lat,
longitude: point.longitude, longitude: point.lon,
timestamp: point.timestamp timestamp: point.timestamp
} }
end end

View file

@ -22,8 +22,8 @@ class ExportSerializer
def export_point(point) def export_point(point)
{ {
lat: point.latitude, lat: point.lat,
lon: point.longitude, lon: point.lon,
bs: battery_status(point), bs: battery_status(point),
batt: point.battery, batt: point.battery,
p: point.ping, p: point.ping,

View file

@ -1,14 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
class PointSerializer class PointSerializer
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id id import_id user_id raw_data].freeze EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id id import_id user_id raw_data lonlat].freeze
def initialize(point) def initialize(point)
@point = point @point = point
end end
def call def call
point.attributes.except(*EXCLUDED_ATTRIBUTES) point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes|
attributes['latitude'] = point.lat.to_s
attributes['longitude'] = point.lon.to_s
end
end end
private private

View file

@ -14,7 +14,7 @@ class Points::GeojsonSerializer
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [point.longitude, point.latitude] coordinates: [point.lon.to_s, point.lat.to_s]
}, },
properties: PointSerializer.new(point).call properties: PointSerializer.new(point).call
} }

View file

@ -38,7 +38,7 @@ class Areas::Visits::Create
end end
points = Point.where(user_id: user.id) points = Point.where(user_id: user.id)
.near([area.latitude, area.longitude], area_radius, units: DISTANCE_UNIT) .near([area.latitude, area.longitude], area_radius, DISTANCE_UNIT)
.order(timestamp: :asc) .order(timestamp: :asc)
# check if all points within the area are assigned to a visit # check if all points within the area are assigned to a visit

View file

@ -27,8 +27,7 @@ class Geojson::ImportParser
def point_exists?(params, user_id) def point_exists?(params, user_id)
Point.exists?( Point.exists?(
latitude: params[:latitude], lonlat: params[:lonlat],
longitude: params[:longitude],
timestamp: params[:timestamp], timestamp: params[:timestamp],
user_id: user_id:
) )

View file

@ -33,8 +33,7 @@ class Geojson::Params
def build_point(feature) def build_point(feature)
{ {
latitude: feature[:geometry][:coordinates][1], lonlat: "POINT(#{feature[:geometry][:coordinates][0]} #{feature[:geometry][:coordinates][1]})",
longitude: feature[:geometry][:coordinates][0],
battery_status: feature[:properties][:battery_state], battery_status: feature[:properties][:battery_state],
battery: battery_level(feature[:properties][:battery_level]), battery: battery_level(feature[:properties][:battery_level]),
timestamp: timestamp(feature), timestamp: timestamp(feature),
@ -64,8 +63,7 @@ class Geojson::Params
def build_line_point(point) def build_line_point(point)
{ {
latitude: point[1], lonlat: "POINT(#{point[0]} #{point[1]})",
longitude: point[0],
timestamp: timestamp(point), timestamp: timestamp(point),
raw_data: point raw_data: point
} }

View file

@ -16,14 +16,12 @@ class GoogleMaps::PhoneTakeoutParser
points_data.compact.each.with_index(1) do |point_data, index| points_data.compact.each.with_index(1) do |point_data, index|
next if Point.exists?( next if Point.exists?(
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
latitude: point_data[:latitude], lonlat: point_data[:lonlat],
longitude: point_data[:longitude],
user_id: user_id:
) )
Point.create( Point.create(
latitude: point_data[:latitude], lonlat: point_data[:lonlat],
longitude: point_data[:longitude],
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data], raw_data: point_data[:raw_data],
accuracy: point_data[:accuracy], accuracy: point_data[:accuracy],
@ -72,8 +70,7 @@ class GoogleMaps::PhoneTakeoutParser
def point_hash(lat, lon, timestamp, raw_data) def point_hash(lat, lon, timestamp, raw_data)
{ {
latitude: lat.to_f, lonlat: "POINT(#{lon.to_f} #{lat.to_f})",
longitude: lon.to_f,
timestamp:, timestamp:,
raw_data:, raw_data:,
accuracy: raw_data['accuracyMeters'], accuracy: raw_data['accuracyMeters'],

View file

@ -25,8 +25,7 @@ class GoogleMaps::RecordsImporter
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def prepare_location_data(location) def prepare_location_data(location)
{ {
latitude: location['latitudeE7'].to_f / 10**7, lonlat: "POINT(#{location['longitudeE7'].to_f / 10**7} #{location['latitudeE7'].to_f / 10**7})",
longitude: location['longitudeE7'].to_f / 10**7,
timestamp: parse_timestamp(location), timestamp: parse_timestamp(location),
altitude: location['altitude'], altitude: location['altitude'],
velocity: location['velocity'], velocity: location['velocity'],
@ -47,7 +46,7 @@ class GoogleMaps::RecordsImporter
# rubocop:disable Rails/SkipsModelValidations # rubocop:disable Rails/SkipsModelValidations
Point.upsert_all( Point.upsert_all(
unique_batch, unique_batch,
unique_by: %i[latitude longitude timestamp user_id], unique_by: %i[lonlat timestamp user_id],
returning: false, returning: false,
on_duplicate: :skip on_duplicate: :skip
) )
@ -59,8 +58,7 @@ class GoogleMaps::RecordsImporter
def deduplicate_batch(batch) def deduplicate_batch(batch)
batch.uniq do |record| batch.uniq do |record|
[ [
record[:latitude].round(7), record[:lonlat],
record[:longitude].round(7),
record[:timestamp], record[:timestamp],
record[:user_id] record[:user_id]
] ]

View file

@ -3,87 +3,135 @@
class GoogleMaps::SemanticHistoryParser class GoogleMaps::SemanticHistoryParser
include Imports::Broadcaster include Imports::Broadcaster
BATCH_SIZE = 1000
attr_reader :import, :user_id attr_reader :import, :user_id
def initialize(import, user_id) def initialize(import, user_id)
@import = import @import = import
@user_id = user_id @user_id = user_id
@current_index = 0
end end
def call def call
points_data = parse_json points_data = parse_json
points_data.each.with_index(1) do |point_data, index| points_data.each_slice(BATCH_SIZE) do |batch|
next if Point.exists?( @current_index += batch.size
timestamp: point_data[:timestamp], process_batch(batch)
latitude: point_data[:latitude], broadcast_import_progress(import, @current_index)
longitude: point_data[:longitude],
user_id:
)
Point.create(
latitude: point_data[:latitude],
longitude: point_data[:longitude],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
topic: 'Google Maps Timeline Export',
tracker_id: 'google-maps-timeline-export',
import_id: import.id,
user_id:
)
broadcast_import_progress(import, index)
end end
end end
private private
def process_batch(batch)
records = batch.map { |point_data| prepare_point_data(point_data) }
# rubocop:disable Rails/SkipsModelValidations
Point.upsert_all(
records,
unique_by: %i[lonlat timestamp user_id],
returning: false,
on_duplicate: :skip
)
# rubocop:enable Rails/SkipsModelValidations
rescue StandardError => e
create_notification("Failed to process location batch: #{e.message}")
end
def prepare_point_data(point_data)
{
lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp],
raw_data: point_data[:raw_data],
topic: 'Google Maps Timeline Export',
tracker_id: 'google-maps-timeline-export',
import_id: import.id,
user_id: user_id,
created_at: Time.current,
updated_at: Time.current
}
end
def create_notification(message)
Notification.create!(
user_id: user_id,
title: 'Google Maps Timeline Import Error',
content: message,
kind: :error
)
end
def parse_json def parse_json
import.raw_data['timelineObjects'].flat_map do |timeline_object| import.raw_data['timelineObjects'].flat_map do |timeline_object|
if timeline_object['activitySegment'].present? parse_timeline_object(timeline_object)
if timeline_object['activitySegment']['startLocation'].blank? end.compact
next if timeline_object['activitySegment']['waypointPath'].blank? end
timeline_object['activitySegment']['waypointPath']['waypoints'].map do |waypoint| def parse_timeline_object(timeline_object)
{ if timeline_object['activitySegment'].present?
latitude: waypoint['latE7'].to_f / 10**7, parse_activity_segment(timeline_object['activitySegment'])
longitude: waypoint['lngE7'].to_f / 10**7, elsif timeline_object['placeVisit'].present?
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']), parse_place_visit(timeline_object['placeVisit'])
raw_data: timeline_object end
} end
end
else
{
latitude: timeline_object['activitySegment']['startLocation']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['activitySegment']['startLocation']['longitudeE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['activitySegment']['duration']['startTimestamp'] || timeline_object['activitySegment']['duration']['startTimestampMs']),
raw_data: timeline_object
}
end
elsif timeline_object['placeVisit'].present?
if timeline_object.dig('placeVisit', 'location', 'latitudeE7').present? &&
timeline_object.dig('placeVisit', 'location', 'longitudeE7').present?
{
latitude: timeline_object['placeVisit']['location']['latitudeE7'].to_f / 10**7,
longitude: timeline_object['placeVisit']['location']['longitudeE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']),
raw_data: timeline_object
}
elsif timeline_object.dig('placeVisit', 'otherCandidateLocations')&.any?
point = timeline_object['placeVisit']['otherCandidateLocations'][0]
next unless point['latitudeE7'].present? && point['longitudeE7'].present? def parse_activity_segment(activity)
if activity['startLocation'].blank?
parse_waypoints(activity)
else
build_point_from_location(
longitude: activity['startLocation']['longitudeE7'],
latitude: activity['startLocation']['latitudeE7'],
timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
raw_data: activity
)
end
end
{ def parse_waypoints(activity)
latitude: point['latitudeE7'].to_f / 10**7, return if activity['waypointPath'].blank?
longitude: point['longitudeE7'].to_f / 10**7,
timestamp: Timestamps.parse_timestamp(timeline_object['placeVisit']['duration']['startTimestamp'] || timeline_object['placeVisit']['duration']['startTimestampMs']), activity['waypointPath']['waypoints'].map do |waypoint|
raw_data: timeline_object build_point_from_location(
} longitude: waypoint['lngE7'],
else latitude: waypoint['latE7'],
next timestamp: activity['duration']['startTimestamp'] || activity['duration']['startTimestampMs'],
end raw_data: activity
end )
end.reject(&:blank?) end
end
def parse_place_visit(place_visit)
if place_visit.dig('location', 'latitudeE7').present? &&
place_visit.dig('location', 'longitudeE7').present?
build_point_from_location(
longitude: place_visit['location']['longitudeE7'],
latitude: place_visit['location']['latitudeE7'],
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
raw_data: place_visit
)
elsif (candidate = place_visit.dig('otherCandidateLocations', 0))
parse_candidate_location(candidate, place_visit)
end
end
def parse_candidate_location(candidate, place_visit)
return unless candidate['latitudeE7'].present? && candidate['longitudeE7'].present?
build_point_from_location(
longitude: candidate['longitudeE7'],
latitude: candidate['latitudeE7'],
timestamp: place_visit['duration']['startTimestamp'] || place_visit['duration']['startTimestampMs'],
raw_data: place_visit
)
end
def build_point_from_location(longitude:, latitude:, timestamp:, raw_data:)
{
lonlat: "POINT(#{longitude.to_f / 10**7} #{latitude.to_f / 10**7})",
timestamp: Timestamps.parse_timestamp(timestamp),
raw_data: raw_data
}
end end
end end

View file

@ -36,8 +36,7 @@ class Gpx::TrackParser
return if point_exists?(point) return if point_exists?(point)
Point.create( Point.create(
latitude: point['lat'].to_d, lonlat: "POINT(#{point['lon'].to_d} #{point['lat'].to_d})",
longitude: point['lon'].to_d,
altitude: point['ele'].to_i, altitude: point['ele'].to_i,
timestamp: Time.parse(point['time']).to_i, timestamp: Time.parse(point['time']).to_i,
import_id: import.id, import_id: import.id,
@ -51,8 +50,7 @@ class Gpx::TrackParser
def point_exists?(point) def point_exists?(point)
Point.exists?( Point.exists?(
latitude: point['lat'].to_d, lonlat: "POINT(#{point['lon'].to_d} #{point['lat'].to_d})",
longitude: point['lon'].to_d,
timestamp: Time.parse(point['time']).to_i, timestamp: Time.parse(point['time']).to_i,
user_id: user_id:
) )

View file

@ -16,9 +16,8 @@ class OwnTracks::ExportParser
points_data.each.with_index(1) do |point_data, index| points_data.each.with_index(1) do |point_data, index|
next if Point.exists?( next if Point.exists?(
lonlat: point_data[:lonlat],
timestamp: point_data[:timestamp], timestamp: point_data[:timestamp],
latitude: point_data[:latitude],
longitude: point_data[:longitude],
user_id: user_id:
) )

View file

@ -7,10 +7,11 @@ class OwnTracks::Params
@params = params.to_h.deep_symbolize_keys @params = params.to_h.deep_symbolize_keys
end end
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/AbcSize
def call def call
{ {
latitude: params[:lat], lonlat: "POINT(#{params[:lon]} #{params[:lat]})",
longitude: params[:lon],
battery: params[:batt], battery: params[:batt],
ping: params[:p], ping: params[:p],
altitude: params[:alt], altitude: params[:alt],
@ -30,6 +31,8 @@ class OwnTracks::Params
raw_data: params.deep_stringify_keys raw_data: params.deep_stringify_keys
} }
end end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
private private

View file

@ -20,8 +20,7 @@ class Photos::ImportParser
return 0 if point_exists?(point, point['timestamp']) return 0 if point_exists?(point, point['timestamp'])
Point.create( Point.create(
latitude: point['latitude'].to_d, lonlat: "POINT(#{point['longitude']} #{point['latitude']})",
longitude: point['longitude'].to_d,
timestamp: point['timestamp'], timestamp: point['timestamp'],
raw_data: point, raw_data: point,
import_id: import.id, import_id: import.id,
@ -33,8 +32,7 @@ class Photos::ImportParser
def point_exists?(point, timestamp) def point_exists?(point, timestamp)
Point.exists?( Point.exists?(
latitude: point['latitude'].to_d, lonlat: "POINT(#{point['longitude']} #{point['latitude']})",
longitude: point['longitude'].to_d,
timestamp:, timestamp:,
user_id: user_id:
) )

View file

@ -18,7 +18,7 @@ class ReverseGeocoding::Points::FetchData
private private
def update_point_with_geocoding_data def update_point_with_geocoding_data
response = Geocoder.search([point.latitude, point.longitude]).first response = Geocoder.search([point.lat, point.lon]).first
return if response.blank? || response.data['error'].present? return if response.blank? || response.data['error'].present?
point.update!( point.update!(

View file

@ -16,7 +16,9 @@ class Visits::Prepare
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
day_result = prepare_day_result(grouped_points) day_result = prepare_day_result(grouped_points)
# Iterate through the day_result, check if there are any points outside of visits that are between two consecutive visits. If there are none, merge the visits. # Iterate through the day_result, check if there are any points outside
# of visits that are between two consecutive visits. If there are none,
# merge the visits.
day_result.each_cons(2) do |visit1, visit2| day_result.each_cons(2) do |visit1, visit2|
next if visit1[:points].last == visit2[:points].first next if visit1[:points].last == visit2[:points].first
@ -65,8 +67,8 @@ class Visits::Prepare
center_point = group.first center_point = group.first
{ {
latitude: center_point.latitude, latitude: center_point.lat,
longitude: center_point.longitude, longitude: center_point.lon,
radius: calculate_radius(center_point, group), radius: calculate_radius(center_point, group),
points: group, points: group,
duration: (group.last.timestamp - group.first.timestamp).to_i / 60, duration: (group.last.timestamp - group.first.timestamp).to_i / 60,

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddLonlatToPoints < ActiveRecord::Migration[8.0]
def change
add_column :points, :lonlat, :st_point, geographic: true
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddLonlatIndex < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :points, :lonlat, using: :gist, algorithm: :concurrently
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class AddUniqueLonLatIndexToPoints < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
return if index_exists?(:points, %i[lonlat timestamp user_id], name: 'index_points_on_lonlat_timestamp_user_id')
add_index :points, %i[lonlat timestamp user_id], unique: true,
name: 'index_points_on_lonlat_timestamp_user_id',
algorithm: :concurrently
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class RemovePointsLatitudeLongitudeUniquenessIndex < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def up
return unless index_exists?(
:points, %i[latitude longitude timestamp user_id],
name: 'unique_points_lat_long_timestamp_user_id_index'
)
remove_index :points,
name: 'unique_points_lat_long_timestamp_user_id_index',
algorithm: :concurrently
end
def down
return if index_exists?(
:points, %i[latitude longitude timestamp user_id],
name: 'unique_points_lat_long_timestamp_user_id_index'
)
add_index :points, %i[latitude longitude timestamp user_id],
unique: true,
name: 'unique_points_lat_long_timestamp_user_id_index',
algorithm: :concurrently
end
end

10
db/schema.rb generated
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do ActiveRecord::Schema[8.0].define(version: 2025_02_21_151930) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
enable_extension "postgis" enable_extension "postgis"
@ -160,6 +160,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.decimal "course", precision: 8, scale: 5 t.decimal "course", precision: 8, scale: 5
t.decimal "course_accuracy", precision: 8, scale: 5 t.decimal "course_accuracy", precision: 8, scale: 5
t.string "external_track_id" t.string "external_track_id"
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
t.index ["altitude"], name: "index_points_on_altitude" t.index ["altitude"], name: "index_points_on_altitude"
t.index ["battery"], name: "index_points_on_battery" t.index ["battery"], name: "index_points_on_battery"
t.index ["battery_status"], name: "index_points_on_battery_status" t.index ["battery_status"], name: "index_points_on_battery_status"
@ -169,8 +170,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.index ["external_track_id"], name: "index_points_on_external_track_id" t.index ["external_track_id"], name: "index_points_on_external_track_id"
t.index ["geodata"], name: "index_points_on_geodata", using: :gin t.index ["geodata"], name: "index_points_on_geodata", using: :gin
t.index ["import_id"], name: "index_points_on_import_id" t.index ["import_id"], name: "index_points_on_import_id"
t.index ["latitude", "longitude", "timestamp", "user_id"], name: "unique_points_lat_long_timestamp_user_id_index", unique: true
t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude" t.index ["latitude", "longitude"], name: "index_points_on_latitude_and_longitude"
t.index ["lonlat", "timestamp", "user_id"], name: "index_points_on_lonlat_timestamp_user_id", unique: true
t.index ["lonlat"], name: "index_points_on_lonlat", using: :gist
t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at" t.index ["reverse_geocoded_at"], name: "index_points_on_reverse_geocoded_at"
t.index ["timestamp"], name: "index_points_on_timestamp" t.index ["timestamp"], name: "index_points_on_timestamp"
t.index ["trigger"], name: "index_points_on_trigger" t.index ["trigger"], name: "index_points_on_trigger"
@ -201,7 +203,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.geometry "path", limit: {:srid=>3857, :type=>"line_string"} t.geometry "path", limit: {srid: 3857, type: "line_string"}
t.index ["user_id"], name: "index_trips_on_user_id" t.index ["user_id"], name: "index_trips_on_user_id"
end end
@ -215,7 +217,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_01_23_151657) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "api_key", default: "", null: false t.string "api_key", default: "", null: false
t.string "theme", default: "dark", null: false t.string "theme", default: "dark", null: false
t.jsonb "settings", default: {"fog_of_war_meters"=>"100", "meters_between_routes"=>"1000", "minutes_between_routes"=>"60"} t.jsonb "settings", default: {"fog_of_war_meters" => "100", "meters_between_routes" => "1000", "minutes_between_routes" => "60"}
t.boolean "admin", default: false t.boolean "admin", default: false
t.integer "sign_in_count", default: 0, null: false t.integer "sign_in_count", default: 0, null: false
t.datetime "current_sign_in_at" t.datetime "current_sign_in_at"

View file

@ -29,11 +29,11 @@ FactoryBot.define do
course { nil } course { nil }
course_accuracy { nil } course_accuracy { nil }
external_track_id { nil } external_track_id { nil }
lonlat { "POINT(#{FFaker::Geolocation.lng} #{FFaker::Geolocation.lat})" }
user user
trait :with_known_location do trait :with_known_location do
latitude { 55.755826 } lonlat { 'POINT(37.6173 55.755826)' }
longitude { 37.6173 }
end end
trait :with_geodata do trait :with_geodata do

File diff suppressed because one or more lines are too long

View file

@ -7,7 +7,7 @@ RSpec.describe Trips::CreatePathJob, type: :job do
let(:points) { trip.points } let(:points) { trip.points }
let(:trip_path) do let(:trip_path) do
"LINESTRING (#{points.map do |point| "LINESTRING (#{points.map do |point|
"#{point.longitude.to_f.round(5)} #{point.latitude.to_f.round(5)}" "#{point.lon.to_f.round(5)} #{point.lat.to_f.round(5)}"
end.join(', ')})" end.join(', ')})"
end end

View file

@ -12,6 +12,7 @@ RSpec.describe Point, type: :model do
it { is_expected.to validate_presence_of(:latitude) } it { is_expected.to validate_presence_of(:latitude) }
it { is_expected.to validate_presence_of(:longitude) } it { is_expected.to validate_presence_of(:longitude) }
it { is_expected.to validate_presence_of(:timestamp) } it { is_expected.to validate_presence_of(:timestamp) }
it { is_expected.to validate_presence_of(:lonlat) }
end end
describe 'scopes' do describe 'scopes' do

View file

@ -25,8 +25,8 @@ RSpec.describe Stat, type: :model do
context 'when there are points' do context 'when there are points' do
let!(:points) do let!(:points) do
create(:point, user:, latitude: 1, longitude: 1, timestamp: DateTime.new(year, 1, 1, 1)) create(:point, user:, lonlat: 'POINT(1 1)', timestamp: DateTime.new(year, 1, 1, 1))
create(:point, user:, latitude: 2, longitude: 2, timestamp: DateTime.new(year, 1, 1, 2)) create(:point, user:, lonlat: 'POINT(2 2)', timestamp: DateTime.new(year, 1, 1, 2))
end end
before { expected_distance[0][1] = 157.23 } before { expected_distance[0][1] = 157.23 }

View file

@ -18,8 +18,8 @@ RSpec.describe ExportSerializer do
user_email => { user_email => {
'dawarich-export' => [ 'dawarich-export' => [
{ {
lat: points.first.latitude, lat: points.first.lat,
lon: points.first.longitude, lon: points.first.lon,
bs: 'u', bs: 'u',
batt: points.first.battery, batt: points.first.battery,
p: points.first.ping, p: points.first.ping,
@ -39,8 +39,8 @@ RSpec.describe ExportSerializer do
raw_data: points.first.raw_data raw_data: points.first.raw_data
}, },
{ {
lat: points.second.latitude, lat: points.second.lat,
lon: points.second.longitude, lon: points.second.lon,
bs: 'u', bs: 'u',
batt: points.second.battery, batt: points.second.battery,
p: points.second.ping, p: points.second.ping,

View file

@ -20,7 +20,7 @@ RSpec.describe Points::GeojsonSerializer do
type: 'Feature', type: 'Feature',
geometry: { geometry: {
type: 'Point', type: 'Point',
coordinates: [point.longitude, point.latitude] coordinates: [point.lon, point.lat]
}, },
properties: PointSerializer.new(point).call properties: PointSerializer.new(point).call
} }

View file

@ -24,8 +24,8 @@ RSpec.describe Points::GpxSerializer do
serializer.tracks[0].points.each_with_index do |track_point, index| serializer.tracks[0].points.each_with_index do |track_point, index|
point = points[index] point = points[index]
expect(track_point.lat).to eq(point.latitude) expect(track_point.lat).to eq(point.lat)
expect(track_point.lon).to eq(point.longitude) expect(track_point.lon).to eq(point.lon)
expect(track_point.time).to eq(point.recorded_at) expect(track_point.time).to eq(point.recorded_at)
end end
end end

View file

@ -23,14 +23,14 @@ RSpec.describe Areas::Visits::Create do
context 'when there are points' do context 'when there are points' do
let(:home_visit_date) { DateTime.new(2021, 1, 1, 10, 0, 0, Time.zone.formatted_offset) } let(:home_visit_date) { DateTime.new(2021, 1, 1, 10, 0, 0, Time.zone.formatted_offset) }
let!(:home_point1) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date) } let!(:home_point1) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date) }
let!(:home_point2) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 10.minutes) } let!(:home_point2) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date + 10.minutes) }
let!(:home_point3) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 20.minutes) } let!(:home_point3) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date + 20.minutes) }
let(:work_visit_date) { DateTime.new(2021, 1, 1, 12, 0, 0, Time.zone.formatted_offset) } let(:work_visit_date) { DateTime.new(2021, 1, 1, 12, 0, 0, Time.zone.formatted_offset) }
let!(:work_point1) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date) } let!(:work_point1) { create(:point, user:, lonlat: 'POINT(1 1)', timestamp: work_visit_date) }
let!(:work_point2) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date + 10.minutes) } let!(:work_point2) { create(:point, user:, lonlat: 'POINT(1 1)', timestamp: work_visit_date + 10.minutes) }
let!(:work_point3) { create(:point, user:, latitude: 1, longitude: 1, timestamp: work_visit_date + 20.minutes) } let!(:work_point3) { create(:point, user:, lonlat: 'POINT(1 1)', timestamp: work_visit_date + 20.minutes) }
it 'creates visits' do it 'creates visits' do
expect { create_visits }.to change { Visit.count }.by(2) expect { create_visits }.to change { Visit.count }.by(2)
@ -47,7 +47,7 @@ RSpec.describe Areas::Visits::Create do
end end
context 'when there are points outside the time threshold' do context 'when there are points outside the time threshold' do
let(:home_point4) { create(:point, user:, latitude: 0, longitude: 0, timestamp: home_visit_date + 40.minutes) } let(:home_point4) { create(:point, user:, lonlat: 'POINT(0 0)', timestamp: home_visit_date + 40.minutes) }
it 'does not create visits' do it 'does not create visits' do
expect { create_visits }.to change { Visit.count }.by(2) expect { create_visits }.to change { Visit.count }.by(2)

View file

@ -18,8 +18,7 @@ RSpec.describe Geojson::Params do
it 'returns the correct data for each point' do it 'returns the correct data for each point' do
expect(subject.first).to eq( expect(subject.first).to eq(
latitude: '0.0', lonlat: 'POINT(0.1 0.1)',
longitude: '0.0',
battery_status: nil, battery_status: nil,
battery: nil, battery: nil,
timestamp: Time.zone.at(1_609_459_201), timestamp: Time.zone.at(1_609_459_201),
@ -34,8 +33,8 @@ RSpec.describe Geojson::Params do
'geometry' => { 'geometry' => {
'type' => 'Point', 'type' => 'Point',
'coordinates' => [ 'coordinates' => [
'0.0', '0.1',
'0.0' '0.1'
] ]
}, },
'properties' => { 'properties' => {
@ -72,8 +71,7 @@ RSpec.describe Geojson::Params do
it 'returns the correct data for each point' do it 'returns the correct data for each point' do
expect(subject.first).to eq( expect(subject.first).to eq(
latitude: 10.758321212464024, lonlat: 'POINT(106.64234449272531 10.758321212464024)',
longitude: 106.64234449272531,
battery_status: nil, battery_status: nil,
battery: nil, battery: nil,
timestamp: Time.parse('2024-11-03T16:30:11.331+07:00').to_i, timestamp: Time.parse('2024-11-03T16:30:11.331+07:00').to_i,

View file

@ -9,6 +9,7 @@ RSpec.describe GoogleMaps::PhoneTakeoutParser do
let(:user) { create(:user) } let(:user) { create(:user) }
context 'when file content is an object' do context 'when file content is an object' do
# This file contains 3 duplicates
let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout.json') } let(:file_path) { Rails.root.join('spec/fixtures/files/google/phone-takeout.json') }
let(:raw_data) { JSON.parse(File.read(file_path)) } let(:raw_data) { JSON.parse(File.read(file_path)) }
let(:import) { create(:import, user:, name: 'phone_takeout.json', raw_data:) } let(:import) { create(:import, user:, name: 'phone_takeout.json', raw_data:) }
@ -21,6 +22,7 @@ RSpec.describe GoogleMaps::PhoneTakeoutParser do
end end
context 'when file content is an array' do context 'when file content is an array' do
# This file contains 4 duplicates
let(:file_path) { Rails.root.join('spec/fixtures/files/google/location-history.json') } let(:file_path) { Rails.root.join('spec/fixtures/files/google/location-history.json') }
let(:raw_data) { JSON.parse(File.read(file_path)) } let(:raw_data) { JSON.parse(File.read(file_path)) }
let(:import) { create(:import, user:, name: 'phone_takeout.json', raw_data:) } let(:import) { create(:import, user:, name: 'phone_takeout.json', raw_data:) }
@ -33,12 +35,12 @@ RSpec.describe GoogleMaps::PhoneTakeoutParser do
it 'creates points with correct data' do it 'creates points with correct data' do
parser parser
expect(Point.all[6].latitude).to eq(27.696576.to_d) expect(Point.all[6].lat).to eq(27.696576)
expect(Point.all[6].longitude).to eq(-97.376949.to_d) expect(Point.all[6].lon).to eq(-97.376949)
expect(Point.all[6].timestamp).to eq(1_693_180_140) expect(Point.all[6].timestamp).to eq(1_693_180_140)
expect(Point.last.latitude).to eq(27.709617.to_d) expect(Point.last.lat).to eq(27.709617)
expect(Point.last.longitude).to eq(-97.375988.to_d) expect(Point.last.lon).to eq(-97.375988)
expect(Point.last.timestamp).to eq(1_693_180_320) expect(Point.last.timestamp).to eq(1_693_180_320)
end end
end end

View file

@ -56,8 +56,7 @@ RSpec.describe GoogleMaps::RecordsImporter do
:point, :point,
user: import.user, user: import.user,
import: import, import: import,
latitude: 12.3456789, lonlat: 'POINT(12.3456789 12.3456789)',
longitude: 12.3456789,
timestamp: time.to_i timestamp: time.to_i
) )
end end

View file

@ -80,7 +80,7 @@ RSpec.describe GoogleMaps::SemanticHistoryParser do
{ {
'activitySegment' => { 'activitySegment' => {
'startLocation' => { 'latitudeE7' => 123_456_789, 'longitudeE7' => 123_456_789 }, 'startLocation' => { 'latitudeE7' => 123_456_789, 'longitudeE7' => 123_456_789 },
'duration' => { 'startTimestamp' => (time.to_i).to_s } 'duration' => { 'startTimestamp' => time.to_i.to_s }
} }
} }
end end

View file

@ -53,8 +53,8 @@ RSpec.describe Gpx::TrackParser do
it 'creates points with correct data' do it 'creates points with correct data' do
parser parser
expect(Point.first.latitude).to eq(37.17221.to_d) expect(Point.first.lat).to eq(37.1722103)
expect(Point.first.longitude).to eq(-3.55468.to_d) expect(Point.first.lon).to eq(-3.55468)
expect(Point.first.altitude).to eq(1066) expect(Point.first.altitude).to eq(1066)
expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i) expect(Point.first.timestamp).to eq(Time.zone.parse('2024-04-21T10:19:55Z').to_i)
expect(Point.first.velocity).to eq('2.9') expect(Point.first.velocity).to eq('2.9')
@ -67,8 +67,8 @@ RSpec.describe Gpx::TrackParser do
it 'creates points with correct data' do it 'creates points with correct data' do
parser parser
expect(Point.first.latitude).to eq(10.758321.to_d) expect(Point.first.lat).to eq(10.758321212464024)
expect(Point.first.longitude).to eq(106.642344.to_d) expect(Point.first.lon).to eq(106.64234449272531)
expect(Point.first.altitude).to eq(17) expect(Point.first.altitude).to eq(17)
expect(Point.first.timestamp).to eq(1_730_626_211) expect(Point.first.timestamp).to eq(1_730_626_211)
expect(Point.first.velocity).to eq('2.8') expect(Point.first.velocity).to eq('2.8')

View file

@ -17,9 +17,10 @@ RSpec.describe OwnTracks::ExportParser do
it 'correctly writes attributes' do it 'correctly writes attributes' do
parser parser
expect(Point.first.attributes).to include( point = Point.first
'latitude' => 52.225, expect(point.lonlat.x).to be_within(0.001).of(13.332)
'longitude' => 13.332, expect(point.lonlat.y).to be_within(0.001).of(52.225)
expect(point.attributes.except('lonlat')).to include(
'battery_status' => 'charging', 'battery_status' => 'charging',
'battery' => 94, 'battery' => 94,
'ping' => '100.266', 'ping' => '100.266',

View file

@ -13,8 +13,7 @@ RSpec.describe OwnTracks::Params do
let(:expected_json) do let(:expected_json) do
{ {
latitude: 52.225, lonlat: 'POINT(13.332 52.225)',
longitude: 13.332,
battery: 94, battery: 94,
ping: 100.266, ping: 100.266,
altitude: 36, altitude: 36,

View file

@ -23,13 +23,13 @@ RSpec.describe Photos::ImportParser do
it 'creates points with correct attributes' do it 'creates points with correct attributes' do
service service
expect(Point.first.latitude.to_f).to eq(59.0000) expect(Point.first.lat.to_f).to eq(59.0000)
expect(Point.first.longitude.to_f).to eq(30.0000) expect(Point.first.lon.to_f).to eq(30.0000)
expect(Point.first.timestamp).to eq(978_296_400) expect(Point.first.timestamp).to eq(978_296_400)
expect(Point.first.import_id).to eq(import.id) expect(Point.first.import_id).to eq(import.id)
expect(Point.second.latitude.to_f).to eq(55.0001) expect(Point.second.lat.to_f).to eq(55.0001)
expect(Point.second.longitude.to_f).to eq(37.0001) expect(Point.second.lon.to_f).to eq(37.0001)
expect(Point.second.timestamp).to eq(978_296_400) expect(Point.second.timestamp).to eq(978_296_400)
expect(Point.second.import_id).to eq(import.id) expect(Point.second.import_id).to eq(import.id)
end end
@ -37,7 +37,7 @@ RSpec.describe Photos::ImportParser do
context 'when there are points with the same coordinates' do context 'when there are points with the same coordinates' do
let!(:existing_point) do let!(:existing_point) do
create(:point, latitude: 59.0000, longitude: 30.0000, timestamp: 978_296_400, user:) create(:point, lonlat: 'POINT(30.0000 59.0000)', timestamp: 978_296_400, user:)
end end
it 'creates only new points' do it 'creates only new points' do

View file

@ -27,7 +27,7 @@ RSpec.describe ReverseGeocoding::Points::FetchData do
it 'calls Geocoder' do it 'calls Geocoder' do
fetch_data fetch_data
expect(Geocoder).to have_received(:search).with([point.latitude, point.longitude]) expect(Geocoder).to have_received(:search).with([point.lat, point.lon])
end end
end end

View file

@ -26,24 +26,21 @@ RSpec.describe Stats::CalculateMonth do
user:, user:,
import:, import:,
timestamp: timestamp1, timestamp: timestamp1,
latitude: 52.107902115161316, lonlat: 'POINT(14.452712811406352 52.107902115161316)')
longitude: 14.452712811406352)
end end
let!(:point2) do let!(:point2) do
create(:point, create(:point,
user:, user:,
import:, import:,
timestamp: timestamp2, timestamp: timestamp2,
latitude: 51.9746598171507, lonlat: 'POINT(12.291519487061901 51.9746598171507)')
longitude: 12.291519487061901)
end end
let!(:point3) do let!(:point3) do
create(:point, create(:point,
user:, user:,
import:, import:,
timestamp: timestamp3, timestamp: timestamp3,
latitude: 52.72859111523629, lonlat: 'POINT(9.77973105800526 52.72859111523629)')
longitude: 9.77973105800526)
end end
context 'when units are kilometers' do context 'when units are kilometers' do

View file

@ -6,17 +6,17 @@ RSpec.describe Visits::GroupPoints do
describe '#group_points_by_radius' do describe '#group_points_by_radius' do
it 'groups points by radius' do it 'groups points by radius' do
day_points = [ day_points = [
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago), build(:point, lonlat: 'POINT(0 0)', timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 1.minute), build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: 1.day.ago + 1.minute),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 2.minutes), build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: 1.day.ago + 2.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 3.minutes), build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: 1.day.ago + 3.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 4.minutes), build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: 1.day.ago + 4.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 5.minutes), build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 6.minutes), build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: 1.day.ago + 6.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 7.minutes), build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: 1.day.ago + 7.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 8.minutes), build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: 1.day.ago + 8.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 9.minutes), build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: 1.day.ago + 9.minutes),
build(:point, latitude: 0.0001, longitude: 0.0009, timestamp: 1.day.ago + 9.minutes) build(:point, lonlat: 'POINT(0.001 0.001)', timestamp: 1.day.ago + 10.minutes)
] ]
grouped_points = described_class.new(day_points).group_points_by_radius grouped_points = described_class.new(day_points).group_points_by_radius

View file

@ -14,21 +14,21 @@ RSpec.describe Visits::Group do
context 'when points are too far apart' do context 'when points are too far apart' do
it 'groups points into separate visits' do it 'groups points into separate visits' do
points = [ points = [
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago), build(:point, lonlat: 'POINT(0 0)', timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 5.minutes), build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 10.minutes), build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: 1.day.ago + 10.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 15.minutes), build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: 1.day.ago + 15.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 20.minutes), build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: 1.day.ago + 20.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 25.minutes), build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: 1.day.ago + 25.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 30.minutes), build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: 1.day.ago + 30.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 35.minutes), build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: 1.day.ago + 35.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 40.minutes), build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: 1.day.ago + 40.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 45.minutes), build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: 1.day.ago + 45.minutes),
build(:point, latitude: 0.0001, longitude: 0.0001, timestamp: 1.day.ago + 50.minutes), build(:point, lonlat: 'POINT(0.0001 0.0001)', timestamp: 1.day.ago + 50.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 55.minutes), build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 55.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 95.minutes), build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 95.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 100.minutes), build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 100.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 105.minutes) build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: 1.day.ago + 105.minutes)
] ]
expect(group.call(points)).to \ expect(group.call(points)).to \
eq({ eq({

View file

@ -7,21 +7,21 @@ RSpec.describe Visits::Prepare do
let(:static_time) { Time.zone.local(2021, 1, 1, 0, 0, 0) } let(:static_time) { Time.zone.local(2021, 1, 1, 0, 0, 0) }
let(:points) do let(:points) do
[ [
build(:point, latitude: 0, longitude: 0, timestamp: static_time), build(:point, lonlat: 'POINT(0 0)', timestamp: static_time),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: static_time + 5.minutes), build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: static_time + 5.minutes),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: static_time + 10.minutes), build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: static_time + 10.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: static_time + 15.minutes), build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: static_time + 15.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: static_time + 20.minutes), build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: static_time + 20.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: static_time + 25.minutes), build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: static_time + 25.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: static_time + 30.minutes), build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: static_time + 30.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: static_time + 35.minutes), build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: static_time + 35.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: static_time + 40.minutes), build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: static_time + 40.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: static_time + 45.minutes), build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: static_time + 45.minutes),
build(:point, latitude: 0.0001, longitude: 0.0001, timestamp: static_time + 50.minutes), build(:point, lonlat: 'POINT(0.0001 0.0001)', timestamp: static_time + 50.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 55.minutes), build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 55.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 95.minutes), build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 95.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 100.minutes), build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 100.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: static_time + 105.minutes) build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 105.minutes)
] ]
end end