Merge pull request #160 from Freika/visit_detection

Visit detection
This commit is contained in:
Evgenii Burmakin 2024-08-25 21:54:31 +03:00 committed by GitHub
commit b063d80bdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1452 additions and 90 deletions

View file

@ -1 +1 @@
0.11.2
0.12.0

View file

@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.12.0] — 2024-08-25
### The visit suggestion release
1. With this release deployment, data migration will work, starting visits suggestion process for all users.
2. After initial visit suggestion process, new suggestions will be calculated every 24 hours, based on points for last 24 hours.
3. If you have enabled reverse geocoding and (optionally) provided Photon Api Host, Dawarich will try to reverse geocode your visit and suggest specific places you might have visited, such as cafes, restaurants, parks, etc. If reverse geocoding is not enabled, or Photon Api Host is not provided, Dawarich will not try to suggest places but you'll be able to rename the visit yourself.
4. You can confirm or decline the visit suggestion. If you confirm the visit, it will be added to your timeline. If you decline the visit, it will be removed from your timeline. You'll be able to see all your confirmed, declined and suggested visits on the Visits page.
### Added
- A "Map" button to each visit on the Visits page to allow user to see the visit on the map
- Visits suggestion functionality. Read more on that in the release description
- Click on the visit name allows user to rename the visit
- Tabs to the Visits page to allow user to switch between confirmed, declined and suggested visits
- Places page to see and delete places suggested by Dawarich's visit suggestion process
- Importing a file will now trigger the visit suggestion process for the user
## [0.11.2] — 2024-08-22
### Changed

View file

@ -117,6 +117,7 @@ Feel free to change them both in the Account section.
| APPLICATION_HOSTS | list of host of the application, e.g. `localhost,dawarich.example.com` |
| BACKGROUND_PROCESSING_CONCURRENCY (only for dawarich_sidekiq service) | Number of simultaneously processed background jobs, default is 10 |
| REVERSE_GEOCODING_ENABLED | `true` or `false`, this env var allows you to disable reverse geocoding feature entirely |
| PHOTON_API_HOST | Photon reverse geocoding api host. Useful, if you're running your own Photon instance |
## Star History

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,6 @@
# frozen_string_literal: true
class Api::V1::AreasController < ApplicationController
skip_forgery_protection
before_action :authenticate_api_key
class Api::V1::AreasController < ApiController
before_action :set_area, only: %i[update destroy]
def index

View file

@ -1,9 +1,6 @@
# frozen_string_literal: true
class Api::V1::Overland::BatchesController < ApplicationController
skip_forgery_protection
before_action :authenticate_api_key
class Api::V1::Overland::BatchesController < ApiController
def create
Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id)

View file

@ -1,9 +1,6 @@
# frozen_string_literal: true
class Api::V1::Owntracks::PointsController < ApplicationController
skip_forgery_protection
before_action :authenticate_api_key
class Api::V1::Owntracks::PointsController < ApiController
def create
Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id)

View file

@ -1,9 +1,6 @@
# frozen_string_literal: true
class Api::V1::PointsController < ApplicationController
skip_forgery_protection
before_action :authenticate_api_key
class Api::V1::PointsController < ApiController
def index
start_at = params[:start_at]&.to_datetime&.to_i
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i

View file

@ -1,9 +1,6 @@
# frozen_string_literal: true
class Api::V1::StatsController < ApplicationController
skip_forgery_protection
before_action :authenticate_api_key
class Api::V1::StatsController < ApiController
def index
render json: StatsSerializer.new(current_api_user).call
end

View file

@ -0,0 +1,27 @@
# frozen_string_literal: true
class Api::V1::VisitsController < ApiController
def update
visit = current_api_user.visits.find(params[:id])
visit = update_visit(visit)
render json: visit
end
private
def visit_params
params.require(:visit).permit(:name, :place_id)
end
def update_visit(visit)
visit_params.each do |key, value|
visit[key] = value
visit.name = visit.place.name if visit_params[:place_id].present?
end
visit.save!
visit
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class ApiController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authenticate_api_key
private
def authenticate_api_key
return head :unauthorized unless current_api_user
true
end
def current_api_user
@current_api_user ||= User.find_by(api_key: params[:api_key])
end
end

View file

@ -18,14 +18,4 @@ class ApplicationController < ActionController::Base
redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other
end
def authenticate_api_key
return head :unauthorized unless current_api_user
true
end
def current_api_user
@current_api_user ||= User.find_by(api_key: params[:api_key])
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
class PlacesController < ApplicationController
before_action :authenticate_user!
before_action :set_place, only: :destroy
def index
@places = current_user.places.page(params[:page]).per(20)
end
def destroy
@place.destroy!
redirect_to places_url, notice: 'Place was successfully destroyed.', status: :see_other
end
private
def set_place
@place = current_user.places.find(params[:id])
end
end

View file

@ -6,21 +6,23 @@ class VisitsController < ApplicationController
def index
order_by = params[:order_by] || 'asc'
status = params[:status] || 'confirmed'
visits = current_user
.visits
.where(status: :pending)
.or(current_user.visits.where(status: :confirmed))
.where(status:)
.order(started_at: order_by)
.group_by { |visit| visit.started_at.to_date }
.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)
end
def update
if @visit.update(visit_params)
redirect_to visits_url, notice: 'Visit was successfully updated.', status: :see_other
redirect_back(fallback_location: visits_path(status: :suggested))
else
render :edit, status: :unprocessable_entity
end

View file

@ -1,2 +0,0 @@
module ExportsHelper
end

View 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);
});
}
}

View file

@ -0,0 +1,46 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
connect() {
this.visitId = this.element.dataset.id;
this.apiKey = this.element.dataset.api_key;
}
// Action to handle selection change
selectPlace(event) {
const selectedPlaceId = event.target.value; // Get the selected place ID
// Send PATCH request to update the place for the visit
this.updateVisitPlace(selectedPlaceId);
}
updateVisitPlace(placeId) {
const url = `/api/v1/visits/${this.visitId}?api_key=${this.apiKey}`;
fetch(url, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ place_id: placeId })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Success:', data);
this.updateVisitNameOnPage(data.name);
})
.catch((error) => {
console.error('Error:', error);
});
}
updateVisitNameOnPage(newName) {
document.querySelectorAll(`[data-visit-name="${this.visitId}"]`).forEach(element => {
element.textContent = newName;
});
}
}

View file

@ -0,0 +1,84 @@
// app/javascript/controllers/visit_name_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["name", "input"];
connect() {
this.apiKey = this.element.dataset.api_key;
this.visitId = this.element.dataset.id;
// Listen for custom event to update all instances
this.element.addEventListener("visit-name:updated", this.updateAll.bind(this));
}
edit() {
this.nameTargets.forEach((nameTarget, index) => {
nameTarget.classList.add("hidden");
this.inputTargets[index].classList.remove("hidden");
this.inputTargets[index].focus();
});
}
save() {
const newName = this.inputTargets[0].value; // Assuming both inputs have the same value
fetch(`/api/v1/visits/${this.visitId}?api_key=${this.apiKey}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ visit: { name: newName } })
})
.then(response => {
if (response.ok) {
this.updateAllInstances(newName);
} else {
return response.json().then(errors => Promise.reject(errors));
}
})
.catch(() => {
alert("Error updating visit name.");
});
}
updateAllInstances(newName) {
// Dispatch a custom event that other instances of this controller can listen to
const event = new CustomEvent("visit-name:updated", { detail: { newName } });
document.querySelectorAll(`[data-id="${this.visitId}"]`).forEach(element => {
element.dispatchEvent(event);
});
}
updateAll(event) {
const newName = event.detail.newName;
// Update all name displays
this.nameTargets.forEach(nameTarget => {
nameTarget.textContent = newName;
nameTarget.classList.remove("hidden");
});
// Update all input fields
this.inputTargets.forEach(inputTarget => {
inputTarget.value = newName;
inputTarget.classList.add("hidden");
});
}
cancel() {
this.nameTargets.forEach((nameTarget, index) => {
nameTarget.classList.remove("hidden");
this.inputTargets[index].classList.add("hidden");
});
}
handleEnter(event) {
if (event.key === "Enter") {
this.save();
} else if (event.key === "Escape") {
this.cancel();
}
}
}

View file

@ -15,7 +15,8 @@ class ImportJob < ApplicationJob
create_import_finished_notification(import, user)
StatCreatingJob.perform_later(user_id)
schedule_stats_creating(user_id)
schedule_visit_suggesting(user_id, import)
rescue StandardError => e
create_import_failed_notification(import, user, e)
end
@ -34,6 +35,18 @@ class ImportJob < ApplicationJob
end
end
def schedule_stats_creating(user_id)
StatCreatingJob.perform_later(user_id)
end
def schedule_visit_suggesting(user_id, import)
points = import.points.order(:timestamp)
start_at = Time.zone.at(points.first.timestamp)
end_at = Time.zone.at(points.last.timestamp)
VisitSuggestingJob.perform_later(user_ids: [user_id], start_at:, end_at:)
end
def create_import_finished_notification(import, user)
Notifications::Create.new(
user:,

View file

@ -3,9 +3,15 @@
class ReverseGeocodingJob < ApplicationJob
queue_as :reverse_geocoding
def perform(point_id)
def perform(klass, id)
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

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class VisitSuggestingJob < ApplicationJob
queue_as :default
def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current)
users = user_ids.any? ? User.where(id: user_ids) : User.all
users.find_each { Visits::Suggest.new(_1, start_at:, end_at:).call }
end
end

View file

@ -1,8 +1,12 @@
# frozen_string_literal: true
class Area < ApplicationRecord
reverse_geocoded_by :latitude, :longitude
belongs_to :user
has_many :visits, dependent: :destroy
validates :name, :latitude, :longitude, :radius, presence: true
def center = [latitude.to_f, longitude.to_f]
end

24
app/models/place.rb Normal file
View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class Place < ApplicationRecord
DEFAULT_NAME = 'Suggested place'
reverse_geocoded_by :latitude, :longitude
validates :name, :longitude, :latitude, presence: true
has_many :visits, dependent: :destroy
has_many :place_visits, dependent: :destroy
has_many :suggested_visits, through: :place_visits, source: :visit
enum source: { manual: 0, photon: 1 }
def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
def reverse_geocoded?
geodata.present?
end
end

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
class PlaceVisit < ApplicationRecord
belongs_to :place
belongs_to :visit
end

View file

@ -19,6 +19,8 @@ class Point < ApplicationRecord
scope :reverse_geocoded, -> { where.not(geodata: {}) }
scope :not_reverse_geocoded, -> { where(geodata: {}) }
scope :visited, -> { where.not(visit_id: nil) }
scope :not_visited, -> { where(visit_id: nil) }
after_create :async_reverse_geocode
@ -33,6 +35,6 @@ class Point < ApplicationRecord
def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
ReverseGeocodingJob.perform_later(id)
ReverseGeocodingJob.perform_later(self.class.to_s, id)
end
end

View file

@ -6,14 +6,15 @@ class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :imports, dependent: :destroy
has_many :points, through: :imports
has_many :stats, dependent: :destroy
has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :exports, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :visits, dependent: :destroy
has_many :imports, dependent: :destroy
has_many :stats, dependent: :destroy
has_many :exports, dependent: :destroy
has_many :notifications, dependent: :destroy
has_many :areas, dependent: :destroy
has_many :visits, dependent: :destroy
has_many :points, through: :imports
has_many :places, through: :visits
after_create :create_api_key

View file

@ -1,11 +1,42 @@
# frozen_string_literal: true
class Visit < ApplicationRecord
belongs_to :area
belongs_to :area, optional: true
belongs_to :place, optional: true
belongs_to :user
has_many :points, dependent: :nullify
has_many :place_visits, dependent: :destroy
has_many :suggested_places, through: :place_visits, source: :place
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 }
def coordinates
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
end
def default_name
name || area&.name || place&.name
end
# in meters
def default_radius
return area&.radius if area.present?
radius = points.map { Geocoder::Calculations.distance_between(center, [_1.latitude, _1.longitude]) }.max
radius && radius >= 15 ? radius : 15
end
def center
area.present? ? area.to_coordinates : place.to_coordinates
end
def async_reverse_geocode
return unless REVERSE_GEOCODING_ENABLED
return if place.blank?
ReverseGeocodingJob.perform_later('place', place_id)
end
end

View file

@ -64,7 +64,7 @@ class Areas::Visits::Create
v.name = "#{area.name}, #{time_range}"
v.ended_at = Time.zone.at(visit_points.last.timestamp)
v.duration = (visit_points.last.timestamp - visit_points.first.timestamp) / 60
v.status = :pending
v.status = :suggested
end
visit.save!

View file

@ -2,6 +2,7 @@
class Jobs::Create
class InvalidJobName < StandardError; end
attr_reader :job_name, :user
def initialize(job_name, user_id)

View file

@ -0,0 +1,106 @@
# frozen_string_literal: true
# This class uses Komoot's Photon API
class ReverseGeocoding::Places::FetchData
attr_reader :place
IGNORED_OSM_VALUES = %w[house residential yes detached].freeze
IGNORED_OSM_KEYS = %w[highway railway].freeze
def initialize(place_id)
@place = Place.find(place_id)
end
def call
if ::PHOTON_API_HOST.blank?
Rails.logger.warn('PHOTON_API_HOST is not set')
return
end
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
private
def update_place(reverse_geocoded_place)
return if reverse_geocoded_place.nil?
data = reverse_geocoded_place.data
place.update!(
name: place_name(data),
latitude: data['geometry']['coordinates'][1],
longitude: data['geometry']['coordinates'][0],
city: data['properties']['city'],
country: data['properties']['country'],
geodata: data,
source: Place.sources[:photon],
reverse_geocoded_at: Time.current
)
end
def fetch_and_create_place(reverse_geocoded_place)
data = reverse_geocoded_place.data
new_place = find_place(data)
new_place.name = place_name(data)
new_place.city = data['properties']['city']
new_place.country = data['properties']['country']
new_place.geodata = data
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
).first
return found_place if found_place.present?
Place.find_or_initialize_by(
latitude: place_data['geometry']['coordinates'][1].to_f.round(5),
longitude: place_data['geometry']['coordinates'][0].to_f.round(5)
)
end
def place_name(data)
name = data.dig('properties', 'name')
type = data.dig('properties', 'osm_value')&.capitalize&.gsub('_', ' ')
address = "#{data.dig('properties', 'postcode')} #{data.dig('properties', 'street')}"
address += " #{data.dig('properties', 'housenumber')}" if data.dig('properties', 'housenumber').present?
name ||= address
"#{name} (#{type})"
end
def reverse_geocoded_places
data = Geocoder.search([place.latitude, place.longitude], limit: 10, distance_sort: true)
data.reject do |place|
place.data['properties']['osm_value'].in?(IGNORED_OSM_VALUES) ||
place.data['properties']['osm_key'].in?(IGNORED_OSM_KEYS)
end
end
end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
class ReverseGeocoding::FetchData
class ReverseGeocoding::Points::FetchData
attr_reader :point
def initialize(point_id)

View 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

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
class Visits::Prepare
attr_reader :points
def initialize(points)
@points = points
end
def call
points_by_day = points.group_by { |point| point_date(point) }
points_by_day.map 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)
next if day_result.blank?
{ date: day, visits: day_result }
end.compact
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)
grouped_points.map do |group|
center_point = group.first
{
latitude: center_point.latitude,
longitude: center_point.longitude,
radius: calculate_radius(center_point, group),
points: group,
duration: (group.last.timestamp - group.first.timestamp).to_i / 60
}
end
end
end

View file

@ -0,0 +1,112 @@
# frozen_string_literal: true
class Visits::Suggest
attr_reader :points, :user, :start_at, :end_at
def initialize(user, start_at:, end_at:)
@start_at = start_at.to_i
@end_at = end_at.to_i
@points = user.tracked_points.not_visited.order(timestamp: :asc).where(timestamp: start_at..end_at)
@user = user
end
def call
prepared_visits = Visits::Prepare.new(points).call
visited_places = create_places(prepared_visits)
visits = create_visits(visited_places)
create_visits_notification(user)
nil unless reverse_geocoding_enabled?
reverse_geocode(visits)
end
private
def create_places(prepared_visits)
prepared_visits.flat_map do |date|
date[:visits] = handle_visits(date[:visits])
date
end
end
def create_visits(visited_places)
visited_places.flat_map do |date|
date[:visits].map do |visit_data|
ActiveRecord::Base.transaction do
search_params = {
user_id: user.id,
duration: visit_data[:duration],
started_at: Time.zone.at(visit_data[:points].first.timestamp)
}
if visit_data[:area].present?
search_params[:area_id] = visit_data[:area].id
elsif visit_data[:place].present?
search_params[:place_id] = visit_data[:place].id
end
visit = Visit.find_or_initialize_by(search_params)
visit.name = visit_data[:place]&.name || visit_data[:area]&.name if visit.name.blank?
visit.ended_at = Time.zone.at(visit_data[:points].last.timestamp)
visit.save!
visit_data[:points].each { |point| point.update!(visit_id: visit.id) }
visit
end
end
end
end
def reverse_geocode(visits)
visits.each(&:async_reverse_geocode)
end
def reverse_geocoding_enabled?
::REVERSE_GEOCODING_ENABLED && ::PHOTON_API_HOST.present?
end
def create_visits_notification(user)
content = <<~CONTENT
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them in the <%= link_to 'Visits', visits_path %> section.
CONTENT
user.notifications.create!(
kind: :info,
title: 'New visits suggested',
content:
)
end
def create_place(visit)
place = Place.find_or_initialize_by(
latitude: visit[:latitude].to_f.round(5),
longitude: visit[:longitude].to_f.round(5)
)
place.name = Place::DEFAULT_NAME
place.source = Place.sources[:manual]
place.save!
place
end
def handle_visits(visits)
visits.map do |visit|
area = Area.near([visit[:latitude], visit[:longitude]], 0.100).first
if area.present?
visit.merge(area:)
else
place = create_place(visit)
visit.merge(place:)
end
end
end
end

View file

@ -0,0 +1,52 @@
<% content_for :title, "Places" %>
<div class="w-full">
<div class="flex justify-center my-5">
<h1 class="font-bold text-4xl">Places</h1>
</div>
<div id="places" class="min-w-full">
<% if @places.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<p class="py-6">
Here you'll find your places, created by Visits suggestion process. But now there are none.
</p>
</div>
</div>
</div>
<% else %>
<div class="flex justify-center my-5">
<div class='flex'>
<%= paginate @places %>
</div>
</div>
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Created at</th>
<th>Coordinates</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @places.each do |place| %>
<tr>
<td><%= place.name %></td>
<td><%= place.created_at.strftime('%Y-%m-%d %H:%M:%S') %></td>
<td><%= place.to_coordinates.map(&:to_f) %></td>
<td>
<%= link_to 'Delete', place, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>

View file

@ -8,7 +8,8 @@
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url, class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Places', places_url, class: "#{active_class?(places_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
@ -42,7 +43,8 @@
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url, class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Visits<sup>β</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Places', places_url, class: "#{active_class?(places_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>

View file

@ -0,0 +1,2 @@
<%= link_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-success' %>
<%= link_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo_method: :patch }, class: 'btn btn-xs btn-error mx-1' %>

View file

@ -0,0 +1,47 @@
<!-- 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 max-w-5xl">
<h3 class="text-lg font-bold">
<span data-visit-name="<%= visit.id %>">
<%= render 'visits/name', visit: visit %>
</span>,
<%= 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>
<div class='w-full'
data-api_key="<%= current_user.api_key %>"
data-controller="visit-modal-places"
data-id="<%= visit.id %>">
<% if visit.suggested_places.any? %>
<%= select_tag :place_id,
options_for_select(
visit.suggested_places.map { |place| [place.name, place.id] },
(visit.place_id || visit.suggested_places.first.id)
),
class: 'w-full select select-bordered',
data: { action: 'change->visit-modal-places#selectPlace' }
%>
<% end %>
</div>
</div>
<div class='flex'>
<%= render 'visits/buttons', visit: visit %>
</div>
</div>
<div class='w-full h-[25rem]'
data-controller="visit-modal-map"
data-coordinates="<%= visit.coordinates %>"
data-radius="<%= visit.default_radius %>"
data-center="<%= visit.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>

View file

@ -0,0 +1,23 @@
<span class="text-lg font-black <%= 'underline decoration-dotted' if visit.suggested? %>"
data-controller="visit-name"
data-api_key="<%= current_user.api_key %>"
data-id="<%= visit.id %>">
<!-- Visit Name Display -->
<span
data-visit-name="<%= visit.id %>"
data-visit-name-target="name"
data-action="click->visit-name#edit"
data-tip="Click to change visit name"
class='hover:cursor-pointer tooltip'>
<%= visit.default_name %>
</span>
<!-- Visit Name Input -->
<input
type="text"
value="<%= visit.default_name %>"
data-visit-name-target="input"
data-action="blur->visit-name#save keydown->visit-name#handleEnter"
class="hidden input input-sm input-bordered w-full max-w-xs"
/>
</span>

View file

@ -0,0 +1,15 @@
<div class="group relative">
<div class="flex items-center justify-between">
<div>
<%= render 'visits/name', visit: visit %>
<div><%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %></div>
</div>
<div class="opacity-0 transition-opacity duration-200 group-hover:opacity-100 flex items-center ml-4">
<%= render 'visits/buttons', visit: visit %>
<!-- The button to open modal -->
<label for="visit_details_popup_<%= visit.id %>" class='btn btn-xs btn-info'>Map</label>
<%= render 'visits/modal', visit: visit %>
</div>
</div>
</div>

View file

@ -3,6 +3,19 @@
<div class="flex justify-between my-5">
<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">
<span class="mr-2">Order by:</span>
<%= link_to 'Newest', visits_path(order_by: :desc), class: 'btn btn-xs btn-primary mx-1' %>
@ -10,13 +23,28 @@
</div>
</div>
<div role="alert" class="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>Visits suggestion feature is currently in beta stage. Expect bugs and problems and don't hesitate to report them to <a href='https://github.com/Freika/dawarich/issues' class='link'>Github Issues</a>.</span>
</div>
<% if @visits.empty? %>
<div class="hero min-h-80 bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there!</h1>
<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>
</div>
</div>
@ -46,24 +74,7 @@
<div class="<%= index.odd? ? 'timeline-start' : 'timeline-end' %> mb-10 md:text-end">
<time class="font-mono italic"><%= date[:date].strftime('%A, %d %B %Y') %></time>
<% date[:visits].each do |visit| %>
<div class="group relative">
<div class="flex items-center justify-between">
<div>
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.pending? %>">
<%= visit&.area&.name %>
</div>
<div>
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
</div>
</div>
<% if visit.pending? %>
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success mr-1' %>
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error' %>
</div>
<% end %>
</div>
</div>
<%= render partial: 'visit', locals: { visit: visit } %>
<% end %>
</div>
<hr />

View file

@ -27,6 +27,7 @@ Rails.application.routes.draw do
resources :imports
resources :visits, only: %i[index update]
resources :places, only: %i[index destroy]
resources :exports, only: %i[index create destroy]
resources :points, only: %i[index] do
collection do
@ -57,6 +58,7 @@ Rails.application.routes.draw do
namespace :v1 do
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[index destroy]
resources :visits, only: %i[update]
resources :stats, only: :index
namespace :overland do

View file

@ -6,6 +6,11 @@ stat_creating_job:
queue: default
area_visits_calculation_scheduling_job:
cron: "0 * * * *" # every hour
cron: "0 0 * * *" # every day at 0:00
class: "AreaVisitsCalculationSchedulingJob"
queue: default
visit_suggesting_job:
cron: "0 1 * * *" # every day at 1:00
class: "VisitSuggestingJob"
queue: default

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
class RunInitialVisitSuggestion < ActiveRecord::Migration[7.1]
def up
start_at = 30.years.ago
end_at = Time.current
VisitSuggestingJob.perform_in(3.minutes, start_at:, end_at:)
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View 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

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddPlaceIdToVisits < ActiveRecord::Migration[7.1]
def change
add_reference :visits, :place, foreign_key: true
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class MakeAreaIdOptionalInVisits < ActiveRecord::Migration[7.1]
def change
change_column_null :visits, :area_id, true
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
class CreatePlaceVisits < ActiveRecord::Migration[7.1]
def change
create_table :place_visits do |t|
t.references :place, null: false, foreign_key: true
t.references :visit, null: false, foreign_key: true
t.timestamps
end
end
end

28
db/schema.rb generated
View file

@ -94,6 +94,27 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_22_092405) do
t.index ["user_id"], name: "index_notifications_on_user_id"
end
create_table "place_visits", force: :cascade do |t|
t.bigint "place_id", null: false
t.bigint "visit_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["place_id"], name: "index_place_visits_on_place_id"
t.index ["visit_id"], name: "index_place_visits_on_visit_id"
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|
t.integer "battery_status"
@ -171,7 +192,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_22_092405) do
end
create_table "visits", force: :cascade do |t|
t.bigint "area_id", null: false
t.bigint "area_id"
t.bigint "user_id", null: false
t.datetime "started_at", null: false
t.datetime "ended_at", null: false
@ -180,7 +201,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_22_092405) do
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "place_id"
t.index ["area_id"], name: "index_visits_on_area_id"
t.index ["place_id"], name: "index_visits_on_place_id"
t.index ["user_id"], name: "index_visits_on_user_id"
end
@ -188,9 +211,12 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_22_092405) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "areas", "users"
add_foreign_key "notifications", "users"
add_foreign_key "place_visits", "places"
add_foreign_key "place_visits", "visits"
add_foreign_key "points", "users"
add_foreign_key "points", "visits"
add_foreign_key "stats", "users"
add_foreign_key "visits", "areas"
add_foreign_key "visits", "places"
add_foreign_key "visits", "users"
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :place_visit do
place
visit
end
end

9
spec/factories/places.rb Normal file
View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
FactoryBot.define do
factory :place do
name { 'MyString' }
latitude { 1.5 }
longitude { 1.5 }
end
end

View file

@ -8,6 +8,6 @@ FactoryBot.define do
ended_at { Time.zone.now + 1.hour }
duration { 1.hour }
name { 'Visit' }
status { 'pending' }
status { 'suggested' }
end
end

View file

@ -4,7 +4,7 @@ require 'rails_helper'
RSpec.describe ReverseGeocodingJob, type: :job 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) }
@ -19,12 +19,12 @@ RSpec.describe ReverseGeocodingJob, type: :job do
expect { perform }.not_to(change { point.reload.city })
end
it 'does not call ReverseGeocoding::FetchData' do
allow(ReverseGeocoding::FetchData).to receive(:new).and_call_original
it 'does not call ReverseGeocoding::Points::FetchData' do
allow(ReverseGeocoding::Points::FetchData).to receive(:new).and_call_original
perform
expect(ReverseGeocoding::FetchData).not_to have_received(:new)
expect(ReverseGeocoding::Points::FetchData).not_to have_received(:new)
end
end
@ -35,11 +35,11 @@ RSpec.describe ReverseGeocodingJob, type: :job do
it 'calls Geocoder' do
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
expect(ReverseGeocoding::FetchData).to have_received(:new).with(point.id)
expect(ReverseGeocoding::Points::FetchData).to have_received(:new).with(point.id)
end
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe VisitSuggestingJob, type: :job do
describe '#perform' do
let!(:users) { [create(:user)] }
subject { described_class.perform_now }
before do
allow(Visits::Suggest).to receive(:new).and_call_original
allow_any_instance_of(Visits::Suggest).to receive(:call)
end
it 'suggests visits' do
subject
expect(Visits::Suggest).to have_received(:new)
end
end
end

31
spec/models/place_spec.rb Normal file
View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Place, type: :model do
describe 'associations' do
it { is_expected.to have_many(:visits).dependent(:destroy) }
it { is_expected.to have_many(:place_visits).dependent(:destroy) }
it { is_expected.to have_many(:suggested_visits).through(:place_visits) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:latitude) }
it { is_expected.to validate_presence_of(:longitude) }
end
describe 'enums' do
it { is_expected.to define_enum_for(:source).with_values(%i[manual photon]) }
end
describe 'methods' do
describe '#async_reverse_geocode' do
let(:place) { create(:place) }
it 'updates address' do
expect { place.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob).with('Place', place.id)
end
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PlaceVisit, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:place) }
it { is_expected.to belong_to(:visit) }
end
end

View file

@ -46,8 +46,11 @@ RSpec.describe Point, type: :model do
describe '#async_reverse_geocode' do
let(:point) { build(:point) }
it 'enqueues ReverseGeocodeJob' do
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

View file

@ -12,6 +12,7 @@ RSpec.describe User, type: :model do
it { is_expected.to have_many(:notifications).dependent(:destroy) }
it { is_expected.to have_many(:areas).dependent(:destroy) }
it { is_expected.to have_many(:visits).dependent(:destroy) }
it { is_expected.to have_many(:places).through(:visits) }
end
describe 'callbacks' do

View file

@ -4,7 +4,8 @@ require 'rails_helper'
RSpec.describe Visit, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:area) }
it { is_expected.to belong_to(:area).optional }
it { is_expected.to belong_to(:place).optional }
it { is_expected.to belong_to(:user) }
it { is_expected.to have_many(:points).dependent(:nullify) }
end

View file

@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Visits', type: :request do
let(:user) { create(:user) }
let(:api_key) { user.api_key }
describe 'PUT /api/v1/visits/:id' do
let(:visit) { create(:visit, user:) }
let(:valid_attributes) do
{
visit: {
name: 'New name'
}
}
end
let(:invalid_attributes) do
{
visit: {
name: nil
}
}
end
context 'with valid parameters' do
it 'updates the requested visit' do
put api_v1_visit_url(visit, api_key:), params: valid_attributes
expect(visit.reload.name).to eq('New name')
end
it 'renders a JSON response with the visit' do
put api_v1_visit_url(visit, api_key:), params: valid_attributes
expect(response).to have_http_status(:ok)
end
end
context 'with invalid parameters' do
it 'renders a JSON response with errors for the visit' do
put api_v1_visit_url(visit, api_key:), params: invalid_attributes
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end

View file

@ -0,0 +1,39 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe '/places', type: :request do
let(:user) { create(:user) }
before do
stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
sign_in user
end
describe 'GET /index' do
it 'renders a successful response' do
get places_url
expect(response).to be_successful
end
end
describe 'DELETE /destroy' do
let!(:place) { create(:place) }
let!(:visit) { create(:visit, place:, user:) }
it 'destroys the requested place' do
expect do
delete place_url(place)
end.to change(Place, :count).by(-1)
end
it 'redirects to the places list' do
delete place_url(place)
expect(response).to redirect_to(places_url)
end
end
end

View file

@ -17,11 +17,83 @@ RSpec.describe '/visits', type: :request do
expect(response).to be_successful
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
describe 'PATCH /update' 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
patch visit_url(visit), params: { visit: { status: :confirmed } }
@ -35,10 +107,10 @@ RSpec.describe '/visits', type: :request do
expect(visit.reload.status).to eq('declined')
end
it 'redirects to the visit index page' do
it 'redirects to the visits index page' do
patch visit_url(visit), params: { visit: { status: :confirmed } }
expect(response).to redirect_to(visits_url)
expect(response).to redirect_to(visits_url(status: :suggested))
end
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe GoogleMaps::RecordsParser do
describe '#call' do
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe GoogleMaps::SemanticHistoryParser do
describe '#call' do
end
end

View file

@ -15,7 +15,7 @@ RSpec.describe Jobs::Create do
described_class.new(job_name, user.id).call
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
@ -33,7 +33,7 @@ RSpec.describe Jobs::Create do
described_class.new(job_name, user.id).call
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

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Notifications::Create do
describe '#call' do
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ReverseGeocoding::Places::FetchData do
describe '#call' do
end
end

View file

@ -2,7 +2,7 @@
require 'rails_helper'
RSpec.describe ReverseGeocoding::FetchData do
RSpec.describe ReverseGeocoding::Points::FetchData do
subject(:fetch_data) { described_class.new(point.id).call }
let(:point) { create(:point) }

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::Calculate do
describe '#call' do
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::GroupPoints do
describe '#group_points_by_radius' do
it 'groups points by radius' do
day_points = [
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 1.minute),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 2.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 3.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 4.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 6.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 7.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 8.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 9.minutes),
build(:point, latitude: 0.0001, longitude: 0.0009, timestamp: 1.day.ago + 9.minutes)
]
grouped_points = described_class.new(day_points).group_points_by_radius
expect(grouped_points.size).to eq(1)
expect(grouped_points.first.size).to eq(10)
# The last point is too far from the first point
expect(grouped_points.first).not_to include(day_points.last)
end
end
end

View file

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::Group do
describe '#call' do
let(:time_threshold_minutes) { 30 }
let(:merge_threshold_minutes) { 15 }
subject(:group) do
described_class.new(time_threshold_minutes:, merge_threshold_minutes:)
end
context 'when points are too far apart' do
it 'groups points into separate visits' do
points = [
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 10.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 15.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 20.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 25.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 30.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 35.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 40.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 45.minutes),
build(:point, latitude: 0.0001, longitude: 0.0001, timestamp: 1.day.ago + 50.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 55.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 95.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 100.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 105.minutes)
]
expect(group.call(points)).to \
eq({
"#{time_formatter(1.day.ago)} - #{time_formatter(1.day.ago + 55.minutes)}" => points[0..11],
"#{time_formatter(1.day.ago + 95.minutes)} - #{time_formatter(1.day.ago + 105.minutes)}" => points[12..-1]
})
end
end
end
def time_formatter(time)
Time.zone.at(time).strftime('%Y-%m-%d %H:%M')
end
end

View file

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::Prepare do
describe '#call' do
let(:points) do
[
build(:point, latitude: 0, longitude: 0, timestamp: 1.day.ago),
build(:point, latitude: 0.00001, longitude: 0.00001, timestamp: 1.day.ago + 5.minutes),
build(:point, latitude: 0.00002, longitude: 0.00002, timestamp: 1.day.ago + 10.minutes),
build(:point, latitude: 0.00003, longitude: 0.00003, timestamp: 1.day.ago + 15.minutes),
build(:point, latitude: 0.00004, longitude: 0.00004, timestamp: 1.day.ago + 20.minutes),
build(:point, latitude: 0.00005, longitude: 0.00005, timestamp: 1.day.ago + 25.minutes),
build(:point, latitude: 0.00006, longitude: 0.00006, timestamp: 1.day.ago + 30.minutes),
build(:point, latitude: 0.00007, longitude: 0.00007, timestamp: 1.day.ago + 35.minutes),
build(:point, latitude: 0.00008, longitude: 0.00008, timestamp: 1.day.ago + 40.minutes),
build(:point, latitude: 0.00009, longitude: 0.00009, timestamp: 1.day.ago + 45.minutes),
build(:point, latitude: 0.0001, longitude: 0.0001, timestamp: 1.day.ago + 50.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 55.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 95.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 100.minutes),
build(:point, latitude: 0.00011, longitude: 0.00011, timestamp: 1.day.ago + 105.minutes)
]
end
subject { described_class.new(points).call }
it 'returns correct visits' do
expect(subject).to eq [
{
date: 1.day.ago.to_date.to_s,
visits: [
{
latitude: 0.0,
longitude: 0.0,
radius: 10,
points:,
duration: 105
}
]
}
]
end
end
end

View file

@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::Suggest do
describe '#call' do
let!(:user) { create(:user) }
let(:start_at) { 1.week.ago }
let(:end_at) { Time.current }
let!(:points) do
[
create(:point, user:, timestamp: start_at),
create(:point, user:, timestamp: start_at + 5.minutes),
create(:point, user:, timestamp: start_at + 10.minutes),
create(:point, user:, timestamp: start_at + 15.minutes),
create(:point, user:, timestamp: start_at + 20.minutes),
create(:point, user:, timestamp: start_at + 25.minutes),
create(:point, user:, timestamp: start_at + 30.minutes),
create(:point, user:, timestamp: start_at + 35.minutes),
create(:point, user:, timestamp: start_at + 40.minutes),
create(:point, user:, timestamp: start_at + 45.minutes),
create(:point, user:, timestamp: start_at + 50.minutes),
create(:point, user:, timestamp: start_at + 55.minutes),
create(:point, user:, timestamp: start_at + 95.minutes),
create(:point, user:, timestamp: start_at + 100.minutes),
create(:point, user:, timestamp: start_at + 105.minutes)
]
end
subject { described_class.new(user, start_at:, end_at:).call }
it 'creates places' do
expect { subject }.to change(Place, :count).by(1)
end
it 'creates visits' do
expect { subject }.to change(Visit, :count).by(1)
end
it 'creates visits notification' do
expect { subject }.to change(Notification, :count).by(1)
end
it 'reverse geocodes visits' do
expect_any_instance_of(Visit).to receive(:async_reverse_geocode).and_call_original
subject
end
context 'when reverse geocoding is enabled' do
before do
stub_const('REVERSE_GEOCODING_ENABLED', true)
stub_const('PHOTON_API_HOST', 'http://localhost:2323')
end
it 'reverse geocodes visits' do
expect_any_instance_of(Visit).to receive(:async_reverse_geocode).and_call_original
subject
end
end
end
end