Implement area selecting to show visits

This commit is contained in:
Eugene Burmakin 2025-03-07 23:32:56 +01:00
parent adf923542d
commit 52fd54e39f
15 changed files with 580 additions and 155 deletions

View file

@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
TODO:
- Selecting a visit should put it above other visits on the map to make it easier to edit it. If many visits are on the same place, we should be able to click on them
- Do we need to reverse geocode places if we already got their data with address during visit suggestion?
# 0.24.2 - 2025-02-24

View file

@ -75,3 +75,31 @@
.controls-shifted {
right: 338px !important;
}
/* Selection Tool Styles */
.leaflet-control-custom {
background-color: white;
border-radius: 4px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.leaflet-control-custom:hover {
background-color: #f3f4f6;
}
#selection-tool-button.active {
background-color: #60a5fa;
color: white;
}
/* Cancel Selection Button */
#cancel-selection-button {
margin-bottom: 1rem;
width: 100%;
}

View file

@ -2,23 +2,43 @@
class Api::V1::VisitsController < ApiController
def index
start_time = begin
Time.zone.parse(params[:start_at])
rescue StandardError
Time.zone.now.beginning_of_day
end
end_time = begin
Time.zone.parse(params[:end_at])
rescue StandardError
Time.zone.now.end_of_day
end
# If selection is true, filter by coordinates instead of time
if params[:selection] == 'true' && params[:sw_lat].present? && params[:sw_lng].present? && params[:ne_lat].present? && params[:ne_lng].present?
sw_lat = params[:sw_lat].to_f
sw_lng = params[:sw_lng].to_f
ne_lat = params[:ne_lat].to_f
ne_lng = params[:ne_lng].to_f
visits =
Visit
.includes(:place)
.where(user: current_api_user)
.where('started_at >= ? AND ended_at <= ?', start_time, end_time)
.order(started_at: :desc)
# Create the PostGIS bounding box polygon
bounding_box = "ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, 4326)"
visits =
Visit
.includes(:place)
.where(user: current_api_user)
.joins(:place)
.where("ST_Contains(#{bounding_box}, ST_SetSRID(places.lonlat::geometry, 4326))")
.order(started_at: :desc)
else
# Regular time-based filtering
start_time = begin
Time.zone.parse(params[:start_at])
rescue StandardError
Time.zone.now.beginning_of_day
end
end_time = begin
Time.zone.parse(params[:end_at])
rescue StandardError
Time.zone.now.end_of_day
end
visits =
Visit
.includes(:place)
.where(user: current_api_user)
.where('started_at >= ? AND ended_at <= ?', start_time, end_time)
.order(started_at: :desc)
end
serialized_visits = visits.map do |visit|
Api::VisitSerializer.new(visit).call

View file

@ -12,6 +12,9 @@ export class VisitsManager {
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
this.currentPopup = null;
this.drawerOpen = false;
this.selectionMode = false;
this.selectionRect = null;
this.isSelectionActive = false;
}
/**
@ -62,6 +65,187 @@ export class VisitsManager {
});
this.map.addControl(new DrawerControl({ position: 'topright' }));
// Add the selection tool button
this.addSelectionButton();
}
/**
* Adds a button to enable/disable the area selection tool
*/
addSelectionButton() {
const SelectionControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom');
button.innerHTML = '<i class="fas fa-draw-polygon"></i>';
button.title = 'Select Area';
button.id = 'selection-tool-button';
button.onclick = () => this.toggleSelectionMode();
return button;
}
});
new SelectionControl({ position: 'topright' }).addTo(this.map);
}
/**
* Toggles the area selection mode
*/
toggleSelectionMode() {
if (this.selectionMode) {
// Disable selection mode
this.selectionMode = false;
this.map.dragging.enable();
document.getElementById('selection-tool-button').classList.remove('active');
this.map.off('mousedown', this.onMouseDown, this);
} else {
// Enable selection mode
this.selectionMode = true;
document.getElementById('selection-tool-button').classList.add('active');
this.map.dragging.disable();
this.map.on('mousedown', this.onMouseDown, this);
showFlashMessage('info', 'Selection mode enabled. Click and drag to select an area.');
}
}
/**
* Handles the mousedown event to start the selection
*/
onMouseDown(e) {
// Clear any existing selection
this.clearSelection();
// Store start point and create rectangle
this.startPoint = e.latlng;
// Add mousemove and mouseup listeners
this.map.on('mousemove', this.onMouseMove, this);
this.map.on('mouseup', this.onMouseUp, this);
}
/**
* Handles the mousemove event to update the selection rectangle
*/
onMouseMove(e) {
if (!this.startPoint) return;
// If we already have a rectangle, update its bounds
if (this.selectionRect) {
const bounds = L.latLngBounds(this.startPoint, e.latlng);
this.selectionRect.setBounds(bounds);
} else {
// Create a new rectangle
this.selectionRect = L.rectangle(
L.latLngBounds(this.startPoint, e.latlng),
{ color: '#3388ff', weight: 2, fillOpacity: 0.1 }
).addTo(this.map);
}
}
/**
* Handles the mouseup event to complete the selection
*/
onMouseUp(e) {
// Remove the mouse event listeners
this.map.off('mousemove', this.onMouseMove, this);
this.map.off('mouseup', this.onMouseUp, this);
if (!this.selectionRect) return;
// Finalize the selection
this.isSelectionActive = true;
// Re-enable map dragging
this.map.dragging.enable();
// Disable selection mode
this.selectionMode = false;
document.getElementById('selection-tool-button').classList.remove('active');
this.map.off('mousedown', this.onMouseDown, this);
// Fetch visits within the selection
this.fetchVisitsInSelection();
}
/**
* Clears the current area selection
*/
clearSelection() {
if (this.selectionRect) {
this.map.removeLayer(this.selectionRect);
this.selectionRect = null;
}
this.isSelectionActive = false;
this.startPoint = null;
// If the drawer is open, refresh with time-based visits
if (this.drawerOpen) {
this.fetchAndDisplayVisits();
}
}
/**
* Fetches visits within the selected area
*/
async fetchVisitsInSelection() {
if (!this.selectionRect) return;
const bounds = this.selectionRect.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
try {
const response = await fetch(
`/api/v1/visits?selection=true&sw_lat=${sw.lat}&sw_lng=${sw.lng}&ne_lat=${ne.lat}&ne_lng=${ne.lng}`,
{
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
}
}
);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const visits = await response.json();
this.displayVisits(visits);
// Make sure the drawer is open
if (!this.drawerOpen) {
this.toggleDrawer();
}
// Add cancel selection button to the drawer
this.addSelectionCancelButton();
} catch (error) {
console.error('Error fetching visits in selection:', error);
showFlashMessage('error', 'Failed to load visits in selected area');
}
}
/**
* Adds a cancel button to the drawer to clear the selection
*/
addSelectionCancelButton() {
const container = document.getElementById('visits-list');
if (!container) return;
// Add cancel button at the top of the drawer if it doesn't exist
if (!document.getElementById('cancel-selection-button')) {
const cancelButton = document.createElement('button');
cancelButton.id = 'cancel-selection-button';
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
cancelButton.textContent = 'Cancel Area Selection';
cancelButton.onclick = () => this.clearSelection();
// Insert at the beginning of the container
container.insertBefore(cancelButton, container.firstChild);
}
}
/**
@ -138,6 +322,12 @@ export class VisitsManager {
*/
async fetchAndDisplayVisits() {
try {
// If there's an active selection, don't perform time-based fetch
if (this.isSelectionActive && this.selectionRect) {
this.fetchVisitsInSelection();
return;
}
// Get current timeframe from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startAt = urlParams.get('start_at') || new Date().toISOString();
@ -753,6 +943,7 @@ export class VisitsManager {
closeButton: true,
closeOnClick: true,
autoClose: true,
closeOnEscapeKey: true,
maxWidth: 450, // Set maximum width
minWidth: 300 // Set minimum width
})

View file

@ -0,0 +1,47 @@
# frozen_string_literal: true
class BulkVisitsSuggestingJob < ApplicationJob
queue_as :default
sidekiq_options retry: false
# Passing timespan of more than 3 years somehow results in duplicated Places
def perform(start_at:, end_at:, user_ids: [])
users = user_ids.any? ? User.where(id: user_ids) : User.all
start_at = start_at.to_datetime
end_at = end_at.to_datetime
time_chunks = time_chunks(start_at:, end_at:)
users.active.find_each do |user|
next if user.tracked_points.empty?
time_chunks.each do |time_chunk|
VisitSuggestingJob.perform_later(
user_id: user.id, start_at: time_chunk.first, end_at: time_chunk.last
)
end
end
end
private
def time_chunks(start_at:, end_at:)
time_chunks = []
# First chunk: from start_at to end of that year
first_end = start_at.end_of_year
time_chunks << (start_at...first_end) if start_at < first_end
# Full-year chunks
current = first_end.beginning_of_year + 1.year # Start from the next full year
while current + 1.year <= end_at.beginning_of_year
time_chunks << (current...current + 1.year)
current += 1.year
end
# Last chunk: from start of the last year to end_at
time_chunks << (current...end_at) if current < end_at
time_chunks
end
end

View file

@ -4,22 +4,14 @@ class VisitSuggestingJob < ApplicationJob
queue_as :visit_suggesting
sidekiq_options retry: false
def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current)
users = user_ids.any? ? User.where(id: user_ids) : User.all
start_at = start_at.to_datetime
end_at = end_at.to_datetime
# Passing timespan of more than 3 years somehow results in duplicated Places
def perform(user_id:, start_at:, end_at:)
user = User.find(user_id)
users.find_each do |user|
next unless user.active?
next if user.tracked_points.empty?
time_chunks = (start_at..end_at).step(1.day).to_a
# Split the time range into 24-hour chunks
# This prevents from places duplicates
time_chunks = (start_at..end_at).step(1.day).to_a
time_chunks.each do |time_chunk|
Visits::Suggest.new(user, start_at: time_chunk, end_at: time_chunk + 1.day).call
end
time_chunks.each do |time_chunk|
Visits::Suggest.new(user, start_at: time_chunk, end_at: time_chunk + 1.day).call
end
end
end

View file

@ -37,9 +37,9 @@ class Visit < ApplicationRecord
def center
if area.present?
area.to_coordinates
[area.lat, area.lon]
elsif place.present?
place.to_coordinates
[place.lat, place.lon]
else
center_from_points
end

View file

@ -19,7 +19,6 @@ class ReverseGeocoding::Places::FetchData
first_place = reverse_geocoded_places.shift
update_place(first_place)
add_suggested_place_to_a_visit
reverse_geocoded_places.each { |reverse_geocoded_place| fetch_and_create_place(reverse_geocoded_place) }
end
@ -52,24 +51,12 @@ class ReverseGeocoding::Places::FetchData
new_place.source = :photon
new_place.save!
add_suggested_place_to_a_visit(suggested_place: new_place)
end
def reverse_geocoded?
place.geodata.present?
end
def add_suggested_place_to_a_visit(suggested_place: place)
visits = Place.near([suggested_place.latitude, suggested_place.longitude], 0.1).flat_map(&:visits)
visits.each do |visit|
next if visit.suggested_places.include?(suggested_place)
visit.suggested_places << suggested_place
end
end
def find_place(place_data)
found_place = Place.where(
"geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s

View file

@ -11,31 +11,65 @@ module Visits
def create_visits(visits)
visits.map do |visit_data|
# Variables to store data outside the transaction
visit_instance = nil
place_data = nil
# First transaction to create the visit
ActiveRecord::Base.transaction do
# Try to find matching area or place
area = find_matching_area(visit_data)
place = area ? nil : PlaceFinder.new(user).find_or_create_place(visit_data)
visit = Visit.create!(
# Only find/create place if no area was found
place_data = PlaceFinder.new(user).find_or_create_place(visit_data) unless area
main_place = place_data&.dig(:main_place)
visit_instance = Visit.create!(
user: user,
area: area,
place: place,
place: main_place,
started_at: Time.zone.at(visit_data[:start_time]),
ended_at: Time.zone.at(visit_data[:end_time]),
duration: visit_data[:duration] / 60, # Convert to minutes
name: generate_visit_name(area, place, visit_data[:suggested_name]),
name: generate_visit_name(area, main_place, visit_data[:suggested_name]),
status: :suggested
)
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit.id)
visit
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit_instance.id)
end
# Associate suggested places outside the main transaction
# to avoid deadlocks when multiple processes run simultaneously
if place_data&.dig(:suggested_places).present?
associate_suggested_places(visit_instance, place_data[:suggested_places])
end
visit_instance
end
end
private
# Create place_visits records directly to avoid deadlocks
def associate_suggested_places(visit, suggested_places)
existing_place_ids = visit.place_visits.pluck(:place_id)
# Only create associations that don't already exist
place_ids_to_add = suggested_places.map(&:id) - existing_place_ids
# Skip if there's nothing to add
return if place_ids_to_add.empty?
# Batch create place_visit records
place_visits_attrs = place_ids_to_add.map do |place_id|
{ visit_id: visit.id, place_id: place_id, created_at: Time.current, updated_at: Time.current }
end
# Use insert_all for efficient bulk insertion without callbacks
PlaceVisit.insert_all(place_visits_attrs) if place_visits_attrs.any?
end
def find_matching_area(visit_data)
user.areas.find do |area|
near_area?([visit_data[:center_lat], visit_data[:center_lon]], area)
@ -45,7 +79,8 @@ module Visits
def near_area?(center, area)
distance = Geocoder::Calculations.distance_between(
center,
[area.latitude, area.longitude]
[area.latitude, area.longitude],
units: :km
)
distance * 1000 <= area.radius # Convert to meters
end

View file

@ -3,10 +3,9 @@
module Visits
# Detects potential visits from a collection of tracked points
class Detector
MINIMUM_VISIT_DURATION = 5.minutes
MINIMUM_VISIT_DURATION = 3.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
attr_reader :points
@ -103,7 +102,7 @@ module Visits
def calculate_visit_radius(points, center)
max_distance = points.map do |point|
Geocoder::Calculations.distance_between(center, [point.lat, point.lon])
Geocoder::Calculations.distance_between(center, [point.lat, point.lon], units: :km)
end.max
# Convert to meters and ensure minimum radius
@ -140,7 +139,7 @@ module Visits
.transform_values(&:size)
most_common_name = name_counts.max_by { |_, count| count }&.first
return unless most_common_name.present?
return if most_common_name.blank?
# If we have a name, try to get additional context
feature = common_features.find { |f| f.dig('properties', 'name') == most_common_name }

View file

@ -18,7 +18,7 @@ module Visits
merged = []
current_merged = visits.first
visits[1..-1].each do |visit|
visits[1..].each do |visit|
if can_merge_visits?(current_merged, visit)
# Merge the visits
current_merged[:end_time] = visit[:end_time]
@ -70,7 +70,8 @@ module Visits
max_distance = between_points.map do |point|
Geocoder::Calculations.distance_between(
visit_center,
[point.lat, point.lon]
[point.lat, point.lon],
units: :km
)
end.max

View file

@ -5,6 +5,9 @@ module Visits
class PlaceFinder
attr_reader :user
SEARCH_RADIUS = 500 # meters
SIMILARITY_RADIUS = 50 # meters
def initialize(user)
@user = user
end
@ -12,119 +15,234 @@ module Visits
def find_or_create_place(visit_data)
lat = visit_data[:center_lat].round(5)
lon = visit_data[:center_lon].round(5)
name = visit_data[:suggested_name]
# Define the search radius in meters
search_radius = 100 # Adjust this value as needed
# First check if there's an existing place
existing_place = find_existing_place(lat, lon, visit_data[:suggested_name])
existing_place = Place.where(name: name)
.near([lon, lat], search_radius, :m)
.first
return existing_place if existing_place
# Use a database transaction with a lock to prevent race conditions
Place.transaction do
# Check again within transaction to prevent race conditions
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 50)', lon, lat)
.lock(true)
.first
return existing_place if existing_place
create_new_place(lat, lon, visit_data[:suggested_name])
# If we found an exact match, return it
if existing_place
return {
main_place: existing_place,
suggested_places: find_suggested_places(lat, lon, existing_place.id)
}
end
# Get potential places from all sources
potential_places = collect_potential_places(visit_data)
# Find or create the main place
main_place = select_or_create_main_place(potential_places, lat, lon, visit_data[:suggested_name])
# Get suggested places including our main place
all_suggested_places = potential_places.presence || [main_place]
{
main_place: main_place,
suggested_places: all_suggested_places.uniq { |place| place.name }
}
end
private
def create_new_place(lat, lon, suggested_name)
# If no existing place is found, create a new one
place = Place.new(
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon
)
# Step 1: Find existing place
def find_existing_place(lat, lon, name)
# Try to find existing place by location first
existing_by_location = Place.near([lat, lon], SIMILARITY_RADIUS, :m).first
return existing_by_location if existing_by_location
# Get reverse geocoding data
geocoded_data = Geocoder.search([lat, lon])
# Then try by name if available
return nil unless name.present?
if geocoded_data.present?
first_result = geocoded_data.first
data = first_result.data.with_indifferent_access
properties = data['properties'] || {}
Place.where(name: name)
.near([lat, lon], SEARCH_RADIUS, :m)
.first
end
# Build a descriptive name from available components
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.uniq
# Step 2: Collect potential places from all sources
def collect_potential_places(visit_data)
lat = visit_data[:center_lat].round(5)
lon = visit_data[:center_lon].round(5)
place.name = name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
place.city = properties['city']
place.country = properties['country']
place.geodata = data
place.source = :photon
# Get places from points' geodata
places_from_points = extract_places_from_points(visit_data[:points], lat, lon)
place.save!
# Get places from external API
places_from_api = fetch_places_from_api(lat, lon)
# Process nearby organizations outside the main transaction
process_nearby_organizations(geocoded_data.drop(1))
else
place.name = suggested_name || Place::DEFAULT_NAME
place.source = :manual
place.save!
# Combine and deduplicate by name
combined_places = []
# Add API places first (usually better quality)
places_from_api.each do |api_place|
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
end
# Add places from points if name doesn't already exist
places_from_points.each do |point_place|
combined_places << point_place unless place_name_exists?(combined_places, point_place.name)
end
combined_places
end
# Step 3: Extract places from points
def extract_places_from_points(points, center_lat, center_lon)
return [] if points.blank?
# Filter points with geodata
points_with_geodata = points.select { |point| point.geodata.present? }
return [] if points_with_geodata.empty?
# Process each point to create or find places
places = []
points_with_geodata.each do |point|
place = create_place_from_point(point)
places << place if place
end
places.uniq { |place| place.name }
end
# Step 4: Create place from point
def create_place_from_point(point)
return nil unless point.geodata.is_a?(Hash)
properties = point.geodata['properties'] || {}
return nil if properties.blank?
# Get or build a name
name = build_place_name(properties)
return nil if name == Place::DEFAULT_NAME
# Look for existing place with this name
existing = Place.where(name: name)
.near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m)
.first
return existing if existing
# Create new place
place = Place.new(
name: name,
lonlat: "POINT(#{point.longitude} #{point.latitude})",
latitude: point.latitude,
longitude: point.longitude,
city: properties['city'],
country: properties['country'],
geodata: point.geodata,
source: :photon
)
place.save!
place
rescue ActiveRecord::RecordInvalid
nil
end
# Step 5: Fetch places from API
def fetch_places_from_api(lat, lon)
# Get broader search results from Geocoder
geocoder_results = Geocoder.search([lat, lon], radius: (SEARCH_RADIUS / 1000.0), units: :km)
return [] if geocoder_results.blank?
places = []
geocoder_results.each do |result|
place = create_place_from_api_result(result)
places << place if place
end
places
end
# Step 6: Create place from API result
def create_place_from_api_result(result)
return nil unless result && result.data.is_a?(Hash)
properties = result.data['properties'] || {}
return nil if properties.blank?
# Get or build a name
name = build_place_name(properties)
return nil if name == Place::DEFAULT_NAME
# Look for existing place with this name
existing = Place.where(name: name)
.near([result.latitude, result.longitude], SIMILARITY_RADIUS, :m)
.first
return existing if existing
# Create new place
place = Place.new(
name: name,
lonlat: "POINT(#{result.longitude} #{result.latitude})",
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: result.data,
source: :photon
)
place.save!
place
rescue ActiveRecord::RecordInvalid
nil
end
# Step 7: Select or create main place
def select_or_create_main_place(potential_places, lat, lon, suggested_name)
return create_default_place(lat, lon, suggested_name) if potential_places.blank?
# Choose the closest place as the main one
sorted_places = potential_places.sort_by do |place|
place.distance_to([lat, lon], :m)
end
sorted_places.first
end
# Step 8: Create default place when no other options
def create_default_place(lat, lon, suggested_name)
name = suggested_name.presence || Place::DEFAULT_NAME
place = Place.new(
name: name,
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon,
source: :manual
)
place.save!
place
end
def process_nearby_organizations(geocoded_data)
# Fetch nearby organizations
nearby_organizations = fetch_nearby_organizations(geocoded_data)
# Save each organization as a possible place
nearby_organizations.each do |org|
lon = org[:longitude]
lat = org[:latitude]
# Check if a similar place already exists
existing = Place.where(name: org[:name])
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat)
.first
next if existing
Place.create!(
name: org[:name],
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon,
city: org[:city],
country: org[:country],
geodata: org[:geodata],
source: :photon
)
end
# Step 9: Find suggested places
def find_suggested_places(lat, lon, exclude_id = nil)
query = Place.near([lat, lon], SEARCH_RADIUS, :m).with_distance([lat, lon], :m)
query = query.where.not(id: exclude_id) if exclude_id
query.limit(5)
end
def fetch_nearby_organizations(geocoded_data)
geocoded_data.map do |result|
data = result.data
properties = data['properties'] || {}
# Helper methods
{
name: properties['name'] || 'Unknown Organization',
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: data
}
end
def build_place_name(properties)
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.reject(&:empty?).uniq
name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
end
def place_name_exists?(places, name)
places.any? { |place| place.name == name }
end
end
end

View file

@ -3,11 +3,9 @@
module Visits
# Coordinates the process of detecting and creating visits from tracked points
class SmartDetect
MINIMUM_VISIT_DURATION = 5.minutes
MINIMUM_VISIT_DURATION = 3.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_PLACE_VISITS = 2 # Number of visits to consider a place significant
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
attr_reader :user, :start_at, :end_at, :points

View file

@ -14,12 +14,19 @@ class Visits::Suggest
def call
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
create_visits_notification(user) if visits.any?
# create_visits_notification(user) if visits.any?
return nil unless DawarichSettings.reverse_geocoding_enabled?
visits.each(&:async_reverse_geocode)
visits
rescue StandardError => e
# create a notification with stacktrace and what arguments were used
user.notifications.create!(
kind: :error,
title: 'Error suggesting visits',
content: "Error suggesting visits: #{e.message}\n#{e.backtrace.join("\n")}"
)
end
private

View file

@ -10,6 +10,7 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.14/dist/full.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>