mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 09:41:40 -05:00
commit
b063d80bdf
73 changed files with 1452 additions and 90 deletions
|
|
@ -1 +1 @@
|
|||
0.11.2
|
||||
0.12.0
|
||||
|
|
|
|||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
app/controllers/api/v1/visits_controller.rb
Normal file
27
app/controllers/api/v1/visits_controller.rb
Normal 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
|
||||
18
app/controllers/api_controller.rb
Normal file
18
app/controllers/api_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
22
app/controllers/places_controller.rb
Normal file
22
app/controllers/places_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
module ExportsHelper
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
46
app/javascript/controllers/visit_modal_places_controller.js
Normal file
46
app/javascript/controllers/visit_modal_places_controller.js
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
84
app/javascript/controllers/visit_name_controller.js
Normal file
84
app/javascript/controllers/visit_name_controller.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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:,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
app/jobs/visit_suggesting_job.rb
Normal file
11
app/jobs/visit_suggesting_job.rb
Normal 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
|
||||
|
|
@ -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
24
app/models/place.rb
Normal 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
|
||||
6
app/models/place_visit.rb
Normal file
6
app/models/place_visit.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class PlaceVisit < ApplicationRecord
|
||||
belongs_to :place
|
||||
belongs_to :visit
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class Jobs::Create
|
||||
class InvalidJobName < StandardError; end
|
||||
|
||||
attr_reader :job_name, :user
|
||||
|
||||
def initialize(job_name, user_id)
|
||||
|
|
|
|||
106
app/services/reverse_geocoding/places/fetch_data.rb
Normal file
106
app/services/reverse_geocoding/places/fetch_data.rb
Normal 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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ReverseGeocoding::FetchData
|
||||
class ReverseGeocoding::Points::FetchData
|
||||
attr_reader :point
|
||||
|
||||
def initialize(point_id)
|
||||
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/prepare.rb
Normal file
48
app/services/visits/prepare.rb
Normal 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
|
||||
112
app/services/visits/suggest.rb
Normal file
112
app/services/visits/suggest.rb
Normal 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
|
||||
52
app/views/places/index.html.erb
Normal file
52
app/views/places/index.html.erb
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
2
app/views/visits/_buttons.html.erb
Normal file
2
app/views/visits/_buttons.html.erb
Normal 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' %>
|
||||
47
app/views/visits/_modal.html.erb
Normal file
47
app/views/visits/_modal.html.erb
Normal 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>
|
||||
23
app/views/visits/_name.html.erb
Normal file
23
app/views/visits/_name.html.erb
Normal 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>
|
||||
15
app/views/visits/_visit.html.erb
Normal file
15
app/views/visits/_visit.html.erb
Normal 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>
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
db/data/20240808133112_run_initial_visit_suggestion.rb
Normal file
14
db/data/20240808133112_run_initial_visit_suggestion.rb
Normal 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
|
||||
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
|
||||
7
db/migrate/20240808102348_add_place_id_to_visits.rb
Normal file
7
db/migrate/20240808102348_add_place_id_to_visits.rb
Normal 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
|
||||
|
|
@ -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
|
||||
12
db/migrate/20240808121027_create_place_visits.rb
Normal file
12
db/migrate/20240808121027_create_place_visits.rb
Normal 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
28
db/schema.rb
generated
|
|
@ -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
|
||||
|
|
|
|||
8
spec/factories/place_visits.rb
Normal file
8
spec/factories/place_visits.rb
Normal 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
9
spec/factories/places.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
spec/jobs/visit_suggesting_job_spec.rb
Normal file
22
spec/jobs/visit_suggesting_job_spec.rb
Normal 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
31
spec/models/place_spec.rb
Normal 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
|
||||
10
spec/models/place_visit_spec.rb
Normal file
10
spec/models/place_visit_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
spec/requests/api/v1/visits_spec.rb
Normal file
50
spec/requests/api/v1/visits_spec.rb
Normal 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
|
||||
39
spec/requests/places_spec.rb
Normal file
39
spec/requests/places_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
8
spec/services/google_maps/records_parser_spec.rb
Normal file
8
spec/services/google_maps/records_parser_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GoogleMaps::RecordsParser do
|
||||
describe '#call' do
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GoogleMaps::SemanticHistoryParser do
|
||||
describe '#call' do
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
8
spec/services/notifications/create_spec.rb
Normal file
8
spec/services/notifications/create_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Notifications::Create do
|
||||
describe '#call' do
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ReverseGeocoding::Places::FetchData do
|
||||
describe '#call' do
|
||||
end
|
||||
end
|
||||
|
|
@ -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) }
|
||||
8
spec/services/visits/calculate_spec.rb
Normal file
8
spec/services/visits/calculate_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Calculate do
|
||||
describe '#call' do
|
||||
end
|
||||
end
|
||||
30
spec/services/visits/group_points_spec.rb
Normal file
30
spec/services/visits/group_points_spec.rb
Normal 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
|
||||
45
spec/services/visits/group_spec.rb
Normal file
45
spec/services/visits/group_spec.rb
Normal 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
|
||||
46
spec/services/visits/prepare_spec.rb
Normal file
46
spec/services/visits/prepare_spec.rb
Normal 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
|
||||
64
spec/services/visits/suggest_spec.rb
Normal file
64
spec/services/visits/suggest_spec.rb
Normal 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
|
||||
Loading…
Reference in a new issue