mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Add visits detection
This commit is contained in:
parent
1e207f297c
commit
5394e9dd52
34 changed files with 473 additions and 112 deletions
|
|
@ -4,3 +4,4 @@ DATABASE_PASSWORD=password
|
||||||
DATABASE_NAME=dawarich_development
|
DATABASE_NAME=dawarich_development
|
||||||
DATABASE_PORT=5432
|
DATABASE_PORT=5432
|
||||||
REDIS_URL=redis://localhost:6379/1
|
REDIS_URL=redis://localhost:6379/1
|
||||||
|
GOOGLE_PLACES_API_KEY=''
|
||||||
|
|
|
||||||
3
Gemfile
3
Gemfile
|
|
@ -9,8 +9,7 @@ gem 'chartkick'
|
||||||
gem 'data_migrate'
|
gem 'data_migrate'
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
gem 'geocoder'
|
gem 'geocoder'
|
||||||
gem 'geokit'
|
gem 'google_places'
|
||||||
gem 'geokit-rails'
|
|
||||||
gem 'importmap-rails'
|
gem 'importmap-rails'
|
||||||
gem 'kaminari'
|
gem 'kaminari'
|
||||||
gem 'lograge'
|
gem 'lograge'
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,13 @@ GEM
|
||||||
rails (>= 3.0)
|
rails (>= 3.0)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
|
google_places (2.0.0)
|
||||||
|
httparty (>= 0.13.1)
|
||||||
hashdiff (1.1.0)
|
hashdiff (1.1.0)
|
||||||
|
httparty (0.22.0)
|
||||||
|
csv
|
||||||
|
mini_mime (>= 1.0.0)
|
||||||
|
multi_xml (>= 0.5.2)
|
||||||
i18n (1.14.5)
|
i18n (1.14.5)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
importmap-rails (2.0.1)
|
importmap-rails (2.0.1)
|
||||||
|
|
@ -186,6 +192,8 @@ GEM
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.24.1)
|
minitest (5.24.1)
|
||||||
msgpack (1.7.2)
|
msgpack (1.7.2)
|
||||||
|
multi_xml (0.7.1)
|
||||||
|
bigdecimal (~> 3.1)
|
||||||
mutex_m (0.2.0)
|
mutex_m (0.2.0)
|
||||||
net-imap (0.4.12)
|
net-imap (0.4.12)
|
||||||
date
|
date
|
||||||
|
|
@ -429,6 +437,7 @@ DEPENDENCIES
|
||||||
geocoder
|
geocoder
|
||||||
geokit
|
geokit
|
||||||
geokit-rails
|
geokit-rails
|
||||||
|
google_places
|
||||||
importmap-rails
|
importmap-rails
|
||||||
kaminari
|
kaminari
|
||||||
lograge
|
lograge
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -6,15 +6,17 @@ class VisitsController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
order_by = params[:order_by] || 'asc'
|
order_by = params[:order_by] || 'asc'
|
||||||
|
status = params[:status] || 'confirmed'
|
||||||
|
|
||||||
visits = current_user
|
visits = current_user
|
||||||
.visits
|
.visits
|
||||||
.where(status: :pending)
|
.where(status:)
|
||||||
.or(current_user.visits.where(status: :confirmed))
|
|
||||||
.order(started_at: order_by)
|
.order(started_at: order_by)
|
||||||
.group_by { |visit| visit.started_at.to_date }
|
.group_by { |visit| visit.started_at.to_date }
|
||||||
.map { |k, v| { date: k, visits: v } }
|
.map { |k, v| { date: k, visits: v } }
|
||||||
|
|
||||||
|
@suggested_visits_count = current_user.visits.suggested.count
|
||||||
|
|
||||||
@visits = Kaminari.paginate_array(visits).page(params[:page]).per(10)
|
@visits = Kaminari.paginate_array(visits).page(params[:page]).per(10)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
32
app/javascript/controllers/visit_modal_map_controller.js
Normal file
32
app/javascript/controllers/visit_modal_map_controller.js
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import L, { latLng } from "leaflet";
|
||||||
|
import { osmMapLayer } from "../maps/layers";
|
||||||
|
|
||||||
|
// Connects to data-controller="visit-modal-map"
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["container"];
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
console.log("Visits maps controller connected");
|
||||||
|
this.coordinates = JSON.parse(this.element.dataset.coordinates);
|
||||||
|
this.center = JSON.parse(this.element.dataset.center);
|
||||||
|
this.radius = this.element.dataset.radius;
|
||||||
|
this.map = L.map(this.containerTarget).setView([this.center[0], this.center[1]], 17);
|
||||||
|
|
||||||
|
osmMapLayer(this.map),
|
||||||
|
this.addMarkers();
|
||||||
|
|
||||||
|
L.circle([this.center[0], this.center[1]], {
|
||||||
|
radius: this.radius,
|
||||||
|
color: 'red',
|
||||||
|
fillColor: '#f03',
|
||||||
|
fillOpacity: 0.5
|
||||||
|
}).addTo(this.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
addMarkers() {
|
||||||
|
this.coordinates.forEach((coordinate) => {
|
||||||
|
L.circleMarker([coordinate[0], coordinate[1]], { radius: 4 }).addTo(this.map);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,15 @@
|
||||||
class ReverseGeocodingJob < ApplicationJob
|
class ReverseGeocodingJob < ApplicationJob
|
||||||
queue_as :reverse_geocoding
|
queue_as :reverse_geocoding
|
||||||
|
|
||||||
def perform(point_id)
|
def perform(klass, id)
|
||||||
return unless REVERSE_GEOCODING_ENABLED
|
return unless REVERSE_GEOCODING_ENABLED
|
||||||
|
|
||||||
ReverseGeocoding::FetchData.new(point_id).call
|
data_fetcher(klass, id).call
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def data_fetcher(klass, id)
|
||||||
|
"ReverseGeocoding::#{klass.pluralize.camelize}::FetchData".constantize.new(id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
7
app/jobs/visit_suggesting_job.rb
Normal file
7
app/jobs/visit_suggesting_job.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
class VisitSuggestingJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(*args)
|
||||||
|
# Do something later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -5,4 +5,6 @@ class Area < ApplicationRecord
|
||||||
has_many :visits, dependent: :destroy
|
has_many :visits, dependent: :destroy
|
||||||
|
|
||||||
validates :name, :latitude, :longitude, :radius, presence: true
|
validates :name, :latitude, :longitude, :radius, presence: true
|
||||||
|
|
||||||
|
def center = [latitude.to_f, longitude.to_f]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
17
app/models/place.rb
Normal file
17
app/models/place.rb
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Place < ApplicationRecord
|
||||||
|
validates :name, :longitude, :latitude, presence: true
|
||||||
|
|
||||||
|
enum source: { manual: 0, google_places: 1 }
|
||||||
|
|
||||||
|
after_commit :async_reverse_geocode, on: :create
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def async_reverse_geocode
|
||||||
|
return unless REVERSE_GEOCODING_ENABLED
|
||||||
|
|
||||||
|
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -33,6 +33,6 @@ class Point < ApplicationRecord
|
||||||
def async_reverse_geocode
|
def async_reverse_geocode
|
||||||
return unless REVERSE_GEOCODING_ENABLED
|
return unless REVERSE_GEOCODING_ENABLED
|
||||||
|
|
||||||
ReverseGeocodingJob.perform_later(id)
|
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,11 @@ class Visit < ApplicationRecord
|
||||||
|
|
||||||
validates :started_at, :ended_at, :duration, :name, :status, presence: true
|
validates :started_at, :ended_at, :duration, :name, :status, presence: true
|
||||||
|
|
||||||
enum status: { pending: 0, confirmed: 1, declined: 2 }
|
enum status: { suggested: 0, confirmed: 1, declined: 2 }
|
||||||
|
|
||||||
|
delegate :name, to: :area, prefix: true
|
||||||
|
|
||||||
|
def coordinates
|
||||||
|
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ class Areas::Visits::Create
|
||||||
v.name = "#{area.name}, #{time_range}"
|
v.name = "#{area.name}, #{time_range}"
|
||||||
v.ended_at = Time.zone.at(visit_points.last.timestamp)
|
v.ended_at = Time.zone.at(visit_points.last.timestamp)
|
||||||
v.duration = (visit_points.last.timestamp - visit_points.first.timestamp) / 60
|
v.duration = (visit_points.last.timestamp - visit_points.first.timestamp) / 60
|
||||||
v.status = :pending
|
v.status = :suggested
|
||||||
end
|
end
|
||||||
|
|
||||||
visit.save!
|
visit.save!
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Jobs::Create
|
class Jobs::Create
|
||||||
class InvalidJobName < StandardError; end
|
class InvalidJobName < StandardError; end
|
||||||
|
|
||||||
attr_reader :job_name, :user
|
attr_reader :job_name, :user
|
||||||
|
|
||||||
def initialize(job_name, user_id)
|
def initialize(job_name, user_id)
|
||||||
|
|
|
||||||
65
app/services/reverse_geocoding/places/fetch_data.rb
Normal file
65
app/services/reverse_geocoding/places/fetch_data.rb
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ReverseGeocoding::Places::FetchData
|
||||||
|
attr_reader :place
|
||||||
|
|
||||||
|
def initialize(place_id)
|
||||||
|
@place = Place.find(place_id)
|
||||||
|
rescue ActiveRecord::RecordNotFound => e
|
||||||
|
Rails.logger.error("Place with id #{place_id} not found: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return if place.reverse_geocoded?
|
||||||
|
|
||||||
|
if ENV['GOOGLE_PLACES_API_KEY'].blank?
|
||||||
|
Rails.logger.warn('GOOGLE_PLACES_API_KEY is not set')
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
data = Geocoder.search([place.latitude, place.longitude])
|
||||||
|
google_place_ids = data.map { _1.data['place_id'] }
|
||||||
|
first_place = google_places_client.spot(google_place_ids.shift)
|
||||||
|
|
||||||
|
update_place(first_place)
|
||||||
|
google_place_ids.each { |google_place_id| fetch_and_create_place(google_place_id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def google_places_client
|
||||||
|
@google_places_client ||= GooglePlaces::Client.new(ENV['GOOGLE_PLACES_API_KEY'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_place(place)
|
||||||
|
place.update!(
|
||||||
|
name: place.name,
|
||||||
|
latitude: place.lat,
|
||||||
|
longitude: place.lng,
|
||||||
|
city: place.city,
|
||||||
|
country: place.country,
|
||||||
|
raw_data: place.raw_data,
|
||||||
|
source: :google_places,
|
||||||
|
reverse_geocoded_at: Time.zone.now
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_and_create_place(place_id)
|
||||||
|
place_data = google_places_client.spot(place_id)
|
||||||
|
|
||||||
|
Place.create!(
|
||||||
|
name: place_data.name,
|
||||||
|
latitude: place_data.lat,
|
||||||
|
longitude: place_data.lng,
|
||||||
|
city: place_data.city,
|
||||||
|
country: place_data.country,
|
||||||
|
raw_data: place_data.raw_data,
|
||||||
|
source: :google_places
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reverse_geocoded?
|
||||||
|
place.geodata.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class ReverseGeocoding::FetchData
|
class ReverseGeocoding::Points::FetchData
|
||||||
attr_reader :point
|
attr_reader :point
|
||||||
|
|
||||||
def initialize(point_id)
|
def initialize(point_id)
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
require 'geokit'
|
|
||||||
|
|
||||||
class Visits::Detect
|
|
||||||
def initialize
|
|
||||||
a = 5.days.ago.beginning_of_month
|
|
||||||
b = 5.days.ago.end_of_month
|
|
||||||
@points = Point.order(timestamp: :asc).where(timestamp: a..b)
|
|
||||||
end
|
|
||||||
|
|
||||||
def call
|
|
||||||
# Group points by day
|
|
||||||
points_by_day = @points.group_by { |point| point_date(point) }
|
|
||||||
|
|
||||||
# Iterate through each day's points
|
|
||||||
points_by_day.each do |day, day_points|
|
|
||||||
# Sort points by timestamp
|
|
||||||
day_points.sort_by! { |point| point.timestamp }
|
|
||||||
|
|
||||||
# Call the method for each day's points
|
|
||||||
grouped_points = group_points_by_radius(day_points)
|
|
||||||
|
|
||||||
# Print the grouped points for the day
|
|
||||||
puts "Day: #{day}"
|
|
||||||
grouped_points.each_with_index do |group, index|
|
|
||||||
puts "Group #{index + 1}:"
|
|
||||||
group.each do |point|
|
|
||||||
puts point
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# Method to convert timestamp to date
|
|
||||||
def point_date(point)
|
|
||||||
Time.zone.at(point.timestamp).to_date
|
|
||||||
end
|
|
||||||
|
|
||||||
# Method to check if two points are within a certain radius (in meters)
|
|
||||||
def within_radius?(point1, point2, radius)
|
|
||||||
loc1 = Geokit::LatLng.new(point1.latitude, point1.longitude)
|
|
||||||
loc2 = Geokit::LatLng.new(point2.latitude, point2.longitude)
|
|
||||||
loc1.distance_to(loc2, units: :kms) * 1000 <= radius
|
|
||||||
end
|
|
||||||
|
|
||||||
# Method to group points by increasing radius
|
|
||||||
def group_points_by_radius(day_points, initial_radius = 30, max_radius = 100, step = 10)
|
|
||||||
grouped = []
|
|
||||||
remaining_points = day_points.dup
|
|
||||||
|
|
||||||
while remaining_points.any?
|
|
||||||
point = remaining_points.shift
|
|
||||||
group = [point]
|
|
||||||
radius = initial_radius
|
|
||||||
|
|
||||||
while radius <= max_radius
|
|
||||||
remaining_points.each do |next_point|
|
|
||||||
group << next_point if within_radius?(point, next_point, radius)
|
|
||||||
end
|
|
||||||
|
|
||||||
if group.size > 1
|
|
||||||
remaining_points -= group
|
|
||||||
grouped << group
|
|
||||||
break
|
|
||||||
else
|
|
||||||
radius += step
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
grouped
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Execute the detection
|
|
||||||
# Visits::Detect.new.call
|
|
||||||
58
app/services/visits/group_points.rb
Normal file
58
app/services/visits/group_points.rb
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Visits::GroupPoints
|
||||||
|
INITIAL_RADIUS = 30 # meters
|
||||||
|
MAX_RADIUS = 100 # meters
|
||||||
|
RADIUS_STEP = 10 # meters
|
||||||
|
MIN_VISIT_DURATION = 3 * 60 # 3 minutes in seconds
|
||||||
|
|
||||||
|
attr_reader :day_points, :initial_radius, :max_radius, :step
|
||||||
|
|
||||||
|
def initialize(day_points, initial_radius = INITIAL_RADIUS, max_radius = MAX_RADIUS, step = RADIUS_STEP)
|
||||||
|
@day_points = day_points
|
||||||
|
@initial_radius = initial_radius
|
||||||
|
@max_radius = max_radius
|
||||||
|
@step = step
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_points_by_radius
|
||||||
|
grouped = []
|
||||||
|
remaining_points = day_points.dup
|
||||||
|
|
||||||
|
while remaining_points.any?
|
||||||
|
point = remaining_points.shift
|
||||||
|
radius = initial_radius
|
||||||
|
|
||||||
|
while radius <= max_radius
|
||||||
|
new_group = [point]
|
||||||
|
|
||||||
|
remaining_points.each do |next_point|
|
||||||
|
break unless within_radius?(new_group.first, next_point, radius)
|
||||||
|
|
||||||
|
new_group << next_point
|
||||||
|
end
|
||||||
|
|
||||||
|
if new_group.size > 1
|
||||||
|
group_duration = new_group.last.timestamp - new_group.first.timestamp
|
||||||
|
|
||||||
|
if group_duration >= MIN_VISIT_DURATION
|
||||||
|
remaining_points -= new_group
|
||||||
|
grouped << new_group
|
||||||
|
end
|
||||||
|
|
||||||
|
break
|
||||||
|
else
|
||||||
|
radius += step
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
grouped
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def within_radius?(point1, point2, radius)
|
||||||
|
point1.distance_to(point2) * 1000 <= radius
|
||||||
|
end
|
||||||
|
end
|
||||||
48
app/services/visits/suggest.rb
Normal file
48
app/services/visits/suggest.rb
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Visits::Suggest
|
||||||
|
def initialize(start_at: nil, end_at: nil)
|
||||||
|
start_at ||= Date.new(2024, 7, 15).to_datetime.beginning_of_day.to_i
|
||||||
|
end_at ||= Date.new(2024, 7, 19).to_datetime.end_of_day.to_i
|
||||||
|
@points = Point.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
points_by_day = @points.group_by { |point| point_date(point) }
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
points_by_day.each do |day, day_points|
|
||||||
|
day_points.sort_by!(&:timestamp)
|
||||||
|
|
||||||
|
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
|
||||||
|
day_result = prepare_day_result(grouped_points)
|
||||||
|
result[day] = day_result
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def point_date(point) = Time.zone.at(point.timestamp).to_date.to_s
|
||||||
|
|
||||||
|
def calculate_radius(center_point, group)
|
||||||
|
max_distance = group.map { |point| center_point.distance_to(point) }.max
|
||||||
|
|
||||||
|
(max_distance / 10.0).ceil * 10
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_day_result(grouped_points)
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
grouped_points.each do |group|
|
||||||
|
center_point = group.first
|
||||||
|
radius = calculate_radius(center_point, group)
|
||||||
|
key = "#{center_point.latitude},#{center_point.longitude},#{radius}m,#{group.size}"
|
||||||
|
result[key] = group.count
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -3,6 +3,19 @@
|
||||||
|
|
||||||
<div class="flex justify-between my-5">
|
<div class="flex justify-between my-5">
|
||||||
<h1 class="font-bold text-4xl">Visits</h1>
|
<h1 class="font-bold text-4xl">Visits</h1>
|
||||||
|
<div role="tablist" class="tabs tabs-boxed">
|
||||||
|
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',
|
||||||
|
class: "tab #{active_tab?(visits_path(status: :confirmed))}" %>
|
||||||
|
<%= link_to visits_path(status: :suggested), role: 'tab',
|
||||||
|
class: "tab #{active_tab?(visits_path(status: :suggested))}" do %>
|
||||||
|
Suggested
|
||||||
|
<% if @suggested_visits_count.positive? %>
|
||||||
|
<span class="badge badge-sm badge-primary mx-1"><%= @suggested_visits_count %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to 'Declined', visits_path(status: :declined), role: 'tab',
|
||||||
|
class: "tab #{active_tab?(visits_path(status: :declined))}" %>
|
||||||
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="mr-2">Order by:</span>
|
<span class="mr-2">Order by:</span>
|
||||||
<%= link_to 'Newest', visits_path(order_by: :desc), class: 'btn btn-xs btn-primary mx-1' %>
|
<%= link_to 'Newest', visits_path(order_by: :desc), class: 'btn btn-xs btn-primary mx-1' %>
|
||||||
|
|
@ -16,7 +29,7 @@
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||||
<p class="py-6">
|
<p class="py-6">
|
||||||
Here you'll find your visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
|
Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -49,19 +62,49 @@
|
||||||
<div class="group relative">
|
<div class="group relative">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.pending? %>">
|
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.suggested? %>">
|
||||||
<%= visit.area.name %>
|
<%= visit.area.name %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
|
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% if visit.pending? %>
|
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
|
||||||
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
|
<% if visit.suggested? %>
|
||||||
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success mr-1' %>
|
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
||||||
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error' %>
|
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
||||||
|
<% end %>
|
||||||
|
<!-- The button to open modal -->
|
||||||
|
<label for="visit_details_popup_<%= visit.id %>" class='btn btn-xs btn-info'>Map</label>
|
||||||
|
|
||||||
|
<!-- Put this part before </body> tag -->
|
||||||
|
<input type="checkbox" id="visit_details_popup_<%= visit.id %>" class="modal-toggle" />
|
||||||
|
<div class="modal" role="dialog">
|
||||||
|
<div class="modal-box w-10/12">
|
||||||
|
<h3 class="text-lg font-bold">
|
||||||
|
<%= visit.area_name %>, <%= visit.started_at.strftime('%d.%m.%Y') %>, <%= visit.started_at.strftime('%H:%M') %> - <%= visit.ended_at.strftime('%H:%M') %>
|
||||||
|
</h3>
|
||||||
|
<div class="flex justify-between my-5">
|
||||||
|
<div><a class='link'><</a> Cafe, Sterndamm 86D <a class='link'>></a>, <%= visit.points.count %></div>
|
||||||
|
<div class='flex'>
|
||||||
|
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
||||||
|
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class='w-full h-[25rem]'
|
||||||
|
data-controller="visit-modal-map"
|
||||||
|
data-coordinates="<%= visit.coordinates %>"
|
||||||
|
data-radius="<%= visit.area.radius %>"
|
||||||
|
data-center="<%= visit.area.center %>"
|
||||||
|
>
|
||||||
|
<div data-visit-modal-map-target="container" class="h-[25rem] w-auto h-96">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<label class="modal-backdrop" for="visit_details_popup_<%= visit.id %>">Close</label>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
Geocoder.configure(
|
config = {
|
||||||
# geocoding service request timeout, in seconds (default 3):
|
# geocoding service request timeout, in seconds (default 3):
|
||||||
# timeout: 5,
|
# timeout: 5,
|
||||||
|
|
||||||
|
|
@ -13,4 +13,11 @@ Geocoder.configure(
|
||||||
expiration: 1.day # Defaults to `nil`
|
expiration: 1.day # Defaults to `nil`
|
||||||
# prefix: "another_key:" # Defaults to `geocoder:`
|
# prefix: "another_key:" # Defaults to `geocoder:`
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
if ENV['GOOGLE_PLACES_API_KEY'].present?
|
||||||
|
config[:lookup] = :google
|
||||||
|
config[:api_key] = ENV['GOOGLE_PLACES_API_KEY']
|
||||||
|
end
|
||||||
|
|
||||||
|
Geocoder.configure(config)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
# config/schedule.yml
|
# config/schedule.yml
|
||||||
|
|
||||||
stat_creating_job:
|
stat_creating_job:
|
||||||
cron: "0 */6 * * *"
|
cron: "0 */6 * * *" # every 6 hours
|
||||||
class: "StatCreatingJob"
|
class: "StatCreatingJob"
|
||||||
queue: default
|
queue: default
|
||||||
|
|
||||||
area_visits_calculation_scheduling_job:
|
area_visits_calculation_scheduling_job:
|
||||||
cron: "0 * * * *" # every hour
|
cron: "0 0 * * *" # every day at 0:00
|
||||||
class: "AreaVisitsCalculationSchedulingJob"
|
class: "AreaVisitsCalculationSchedulingJob"
|
||||||
queue: default
|
queue: default
|
||||||
|
|
||||||
|
visit_suggesting_job:
|
||||||
|
cron: "0 1 * * *" # every day at 1:00
|
||||||
|
class: "VisitSuggestingJob"
|
||||||
|
queue: default
|
||||||
|
|
|
||||||
18
db/migrate/20240805150111_create_places.rb
Normal file
18
db/migrate/20240805150111_create_places.rb
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreatePlaces < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
create_table :places do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.decimal :longitude, precision: 10, scale: 6, null: false
|
||||||
|
t.decimal :latitude, precision: 10, scale: 6, null: false
|
||||||
|
t.string :city
|
||||||
|
t.string :country
|
||||||
|
t.integer :source, default: 0
|
||||||
|
t.jsonb :geodata, default: {}, null: false
|
||||||
|
t.datetime :reverse_geocoded_at
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
18
db/schema.rb
generated
18
db/schema.rb
generated
|
|
@ -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[7.1].define(version: 2024_07_21_183116) do
|
ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) 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 "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
|
|
@ -53,6 +53,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
|
||||||
t.index ["user_id"], name: "index_areas_on_user_id"
|
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||||
|
end
|
||||||
|
|
||||||
create_table "exports", force: :cascade do |t|
|
create_table "exports", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "url"
|
t.string "url"
|
||||||
|
|
@ -90,6 +93,19 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_21_183116) do
|
||||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "places", force: :cascade do |t|
|
||||||
|
t.string "name", null: false
|
||||||
|
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||||
|
t.decimal "latitude", precision: 10, scale: 6, null: false
|
||||||
|
t.string "city"
|
||||||
|
t.string "country"
|
||||||
|
t.integer "source", default: 0
|
||||||
|
t.jsonb "geodata", default: {}, null: false
|
||||||
|
t.datetime "reverse_geocoded_at"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
|
||||||
create_table "points", force: :cascade do |t|
|
create_table "points", force: :cascade do |t|
|
||||||
t.integer "battery_status"
|
t.integer "battery_status"
|
||||||
t.string "ping"
|
t.string "ping"
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ services:
|
||||||
APPLICATION_HOSTS: localhost
|
APPLICATION_HOSTS: localhost
|
||||||
TIME_ZONE: Europe/London
|
TIME_ZONE: Europe/London
|
||||||
APPLICATION_PROTOCOL: http
|
APPLICATION_PROTOCOL: http
|
||||||
|
GOOGLE_PLACES_API_KEY: ''
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
|
|
@ -79,6 +80,7 @@ services:
|
||||||
APPLICATION_HOSTS: localhost
|
APPLICATION_HOSTS: localhost
|
||||||
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
BACKGROUND_PROCESSING_CONCURRENCY: 10
|
||||||
APPLICATION_PROTOCOL: http
|
APPLICATION_PROTOCOL: http
|
||||||
|
GOOGLE_PLACES_API_KEY: ''
|
||||||
logging:
|
logging:
|
||||||
driver: "json-file"
|
driver: "json-file"
|
||||||
options:
|
options:
|
||||||
|
|
|
||||||
5
spec/factories/places.rb
Normal file
5
spec/factories/places.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :place do
|
||||||
|
name { "MyString" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -8,6 +8,6 @@ FactoryBot.define do
|
||||||
ended_at { Time.zone.now + 1.hour }
|
ended_at { Time.zone.now + 1.hour }
|
||||||
duration { 1.hour }
|
duration { 1.hour }
|
||||||
name { 'Visit' }
|
name { 'Visit' }
|
||||||
status { 'pending' }
|
status { 'suggested' }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe ReverseGeocodingJob, type: :job do
|
RSpec.describe ReverseGeocodingJob, type: :job do
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
subject(:perform) { described_class.new.perform(point.id) }
|
subject(:perform) { described_class.new.perform('Point', point.id) }
|
||||||
|
|
||||||
let(:point) { create(:point) }
|
let(:point) { create(:point) }
|
||||||
|
|
||||||
|
|
@ -19,12 +19,12 @@ RSpec.describe ReverseGeocodingJob, type: :job do
|
||||||
expect { perform }.not_to(change { point.reload.city })
|
expect { perform }.not_to(change { point.reload.city })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not call ReverseGeocoding::FetchData' do
|
it 'does not call ReverseGeocoding::Points::FetchData' do
|
||||||
allow(ReverseGeocoding::FetchData).to receive(:new).and_call_original
|
allow(ReverseGeocoding::Points::FetchData).to receive(:new).and_call_original
|
||||||
|
|
||||||
perform
|
perform
|
||||||
|
|
||||||
expect(ReverseGeocoding::FetchData).not_to have_received(:new)
|
expect(ReverseGeocoding::Points::FetchData).not_to have_received(:new)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -35,11 +35,11 @@ RSpec.describe ReverseGeocodingJob, type: :job do
|
||||||
|
|
||||||
it 'calls Geocoder' do
|
it 'calls Geocoder' do
|
||||||
allow(Geocoder).to receive(:search).and_return([stubbed_geocoder])
|
allow(Geocoder).to receive(:search).and_return([stubbed_geocoder])
|
||||||
allow(ReverseGeocoding::FetchData).to receive(:new).and_call_original
|
allow(ReverseGeocoding::Points::FetchData).to receive(:new).and_call_original
|
||||||
|
|
||||||
perform
|
perform
|
||||||
|
|
||||||
expect(ReverseGeocoding::FetchData).to have_received(:new).with(point.id)
|
expect(ReverseGeocoding::Points::FetchData).to have_received(:new).with(point.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
5
spec/jobs/visit_suggesting_job_spec.rb
Normal file
5
spec/jobs/visit_suggesting_job_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe VisitSuggestingJob, type: :job do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
||||||
5
spec/models/place_spec.rb
Normal file
5
spec/models/place_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Place, type: :model do
|
||||||
|
pending "add some examples to (or delete) #{__FILE__}"
|
||||||
|
end
|
||||||
|
|
@ -49,6 +49,13 @@ RSpec.describe Point, type: :model do
|
||||||
it 'enqueues ReverseGeocodeJob' do
|
it 'enqueues ReverseGeocodeJob' do
|
||||||
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'enqueues ReverseGeocodeJob with correct arguments' do
|
||||||
|
point.save
|
||||||
|
|
||||||
|
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
||||||
|
.with('Point', point.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,83 @@ RSpec.describe '/visits', type: :request do
|
||||||
|
|
||||||
expect(response).to be_successful
|
expect(response).to be_successful
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with confirmed visits' do
|
||||||
|
let!(:confirmed_visits) { create_list(:visit, 3, user:, status: :confirmed) }
|
||||||
|
|
||||||
|
it 'returns confirmed visits' do
|
||||||
|
get visits_url
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).to match_array(confirmed_visits)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with suggested visits' do
|
||||||
|
let!(:suggested_visits) { create_list(:visit, 3, user:, status: :suggested) }
|
||||||
|
|
||||||
|
it 'does not return suggested visits' do
|
||||||
|
get visits_url
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).not_to include(suggested_visits)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns suggested visits' do
|
||||||
|
get visits_url, params: { status: 'suggested' }
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).to match_array(suggested_visits)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with declined visits' do
|
||||||
|
let!(:declined_visits) { create_list(:visit, 3, user:, status: :declined) }
|
||||||
|
|
||||||
|
it 'does not return declined visits' do
|
||||||
|
get visits_url
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).not_to include(declined_visits)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns declined visits' do
|
||||||
|
get visits_url, params: { status: 'declined' }
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).to match_array(declined_visits)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with suggested visits' do
|
||||||
|
let!(:suggested_visits) { create_list(:visit, 3, user:, status: :suggested) }
|
||||||
|
|
||||||
|
it 'does not return suggested visits' do
|
||||||
|
get visits_url
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).not_to include(suggested_visits)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns suggested visits' do
|
||||||
|
get visits_url, params: { status: 'suggested' }
|
||||||
|
|
||||||
|
expect(@controller.instance_variable_get(:@visits).map do |v|
|
||||||
|
v[:visits]
|
||||||
|
end.flatten).to match_array(suggested_visits)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'PATCH /update' do
|
describe 'PATCH /update' do
|
||||||
context 'with valid parameters' do
|
context 'with valid parameters' do
|
||||||
let(:visit) { create(:visit, user:, status: :pending) }
|
let(:visit) { create(:visit, user:, status: :suggested) }
|
||||||
|
|
||||||
it 'confirms the requested visit' do
|
it 'confirms the requested visit' do
|
||||||
patch visit_url(visit), params: { visit: { status: :confirmed } }
|
patch visit_url(visit), params: { visit: { status: :confirmed } }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ RSpec.describe Jobs::Create do
|
||||||
described_class.new(job_name, user.id).call
|
described_class.new(job_name, user.id).call
|
||||||
|
|
||||||
points.each do |point|
|
points.each do |point|
|
||||||
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.id)
|
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.class.to_s, point.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -33,7 +33,7 @@ RSpec.describe Jobs::Create do
|
||||||
described_class.new(job_name, user.id).call
|
described_class.new(job_name, user.id).call
|
||||||
|
|
||||||
points_without_address.each do |point|
|
points_without_address.each do |point|
|
||||||
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.id)
|
expect(ReverseGeocodingJob).to have_received(:perform_later).with(point.class.to_s, point.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe ReverseGeocoding::FetchData do
|
RSpec.describe ReverseGeocoding::Points::FetchData do
|
||||||
subject(:fetch_data) { described_class.new(point.id).call }
|
subject(:fetch_data) { described_class.new(point.id).call }
|
||||||
|
|
||||||
let(:point) { create(:point) }
|
let(:point) { create(:point) }
|
||||||
Loading…
Reference in a new issue