mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-11 01:31:39 -05:00
Add lots of logic
This commit is contained in:
parent
5394e9dd52
commit
382f937f29
45 changed files with 730 additions and 135 deletions
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
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 7 days.
|
||||||
|
3. If you have enabled reverse geocoding and provided Google Places API key, 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 Google Places API key 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.
|
||||||
|
|
||||||
|
- [x] Get places from Google Places API based on visit coordinates
|
||||||
|
- [x] Implement starting visit suggestion process after import of new points
|
||||||
|
- [x] Detect if the visit is an area visit and attach it to the area
|
||||||
|
- [x] Draw visit radius based on radius of points in the visit
|
||||||
|
- [x] Add a possibility to rename the visit
|
||||||
|
- [x] Make it look acceptable
|
||||||
|
- [ ] Create only uniq google places suggestions
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `GOOGLE_PLACES_API_KEY` environment variable to the `docker-compose.yml` file to allow user to set the Google Places API key for reverse geocoding
|
||||||
|
- 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
|
||||||
|
- Tabs to the Visits page to allow user to switch between confirmed, declined and suggested visits
|
||||||
|
|
||||||
## [0.9.9] — 2024-07-30
|
## [0.9.9] — 2024-07-30
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,10 +135,6 @@ GEM
|
||||||
geocoder (1.8.3)
|
geocoder (1.8.3)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
csv (>= 3.0.0)
|
csv (>= 3.0.0)
|
||||||
geokit (1.14.0)
|
|
||||||
geokit-rails (2.5.0)
|
|
||||||
geokit (~> 1.5)
|
|
||||||
rails (>= 3.0)
|
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google_places (2.0.0)
|
google_places (2.0.0)
|
||||||
|
|
@ -435,8 +431,6 @@ DEPENDENCIES
|
||||||
ffaker
|
ffaker
|
||||||
foreman
|
foreman
|
||||||
geocoder
|
geocoder
|
||||||
geokit
|
|
||||||
geokit-rails
|
|
||||||
google_places
|
google_places
|
||||||
importmap-rails
|
importmap-rails
|
||||||
kaminari
|
kaminari
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
30
app/controllers/api/v1/visits_controller.rb
Normal file
30
app/controllers/api/v1/visits_controller.rb
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::VisitsController < ApplicationController
|
||||||
|
skip_forgery_protection
|
||||||
|
before_action :authenticate_api_key
|
||||||
|
|
||||||
|
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
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,8 @@ class ImportJob < ApplicationJob
|
||||||
content: "Import \"#{import.name}\" successfully finished."
|
content: "Import \"#{import.name}\" successfully finished."
|
||||||
).call
|
).call
|
||||||
|
|
||||||
StatCreatingJob.perform_later(user_id)
|
schedule_stats_creating(user_id)
|
||||||
|
schedule_visit_suggesting(user_id, import)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
Notifications::Create.new(
|
Notifications::Create.new(
|
||||||
user:,
|
user:,
|
||||||
|
|
@ -41,4 +42,16 @@ class ImportJob < ApplicationJob
|
||||||
when 'gpx' then Gpx::TrackParser
|
when 'gpx' then Gpx::TrackParser
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class VisitSuggestingJob < ApplicationJob
|
class VisitSuggestingJob < ApplicationJob
|
||||||
queue_as :default
|
queue_as :default
|
||||||
|
|
||||||
def perform(*args)
|
def perform(user_ids: [], start_at: 1.week.ago, end_at: Time.current)
|
||||||
# Do something later
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Area < ApplicationRecord
|
class Area < ApplicationRecord
|
||||||
|
reverse_geocoded_by :latitude, :longitude
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :visits, dependent: :destroy
|
has_many :visits, dependent: :destroy
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,25 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Place < ApplicationRecord
|
class Place < ApplicationRecord
|
||||||
|
DEFAULT_NAME = 'Suggested place'
|
||||||
|
reverse_geocoded_by :latitude, :longitude
|
||||||
|
|
||||||
validates :name, :longitude, :latitude, presence: true
|
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, google_places: 1 }
|
enum source: { manual: 0, google_places: 1 }
|
||||||
|
|
||||||
after_commit :async_reverse_geocode, on: :create
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def async_reverse_geocode
|
def async_reverse_geocode
|
||||||
return unless REVERSE_GEOCODING_ENABLED
|
return unless REVERSE_GEOCODING_ENABLED
|
||||||
|
|
||||||
|
# If place is successfully reverse geocoded, try to add it to corresponding visits as suggested
|
||||||
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
ReverseGeocodingJob.perform_later(self.class.to_s, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reverse_geocoded?
|
||||||
|
geodata.present?
|
||||||
|
end
|
||||||
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
|
||||||
|
|
@ -1,17 +1,43 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Visit < ApplicationRecord
|
class Visit < ApplicationRecord
|
||||||
belongs_to :area
|
belongs_to :area, optional: true
|
||||||
|
belongs_to :place, optional: true
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :points, dependent: :nullify
|
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
|
validates :started_at, :ended_at, :duration, :name, :status, presence: true
|
||||||
|
|
||||||
enum status: { suggested: 0, confirmed: 1, declined: 2 }
|
enum status: { suggested: 0, confirmed: 1, declined: 2 }
|
||||||
|
|
||||||
delegate :name, to: :area, prefix: true
|
|
||||||
|
|
||||||
def coordinates
|
def coordinates
|
||||||
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
|
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
|
||||||
end
|
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 >= 15 ? radius : 15
|
||||||
|
end
|
||||||
|
|
||||||
|
def center
|
||||||
|
area.present? ? [area.latitude, area.longitude] : [place.latitude, place.longitude]
|
||||||
|
end
|
||||||
|
|
||||||
|
def async_reverse_geocode
|
||||||
|
return unless REVERSE_GEOCODING_ENABLED
|
||||||
|
return if place.blank?
|
||||||
|
|
||||||
|
# If place is successfully reverse geocoded, try to add it to corresponding visits as suggested
|
||||||
|
ReverseGeocodingJob.perform_later('place', place_id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,61 +5,78 @@ class ReverseGeocoding::Places::FetchData
|
||||||
|
|
||||||
def initialize(place_id)
|
def initialize(place_id)
|
||||||
@place = Place.find(place_id)
|
@place = Place.find(place_id)
|
||||||
rescue ActiveRecord::RecordNotFound => e
|
|
||||||
Rails.logger.error("Place with id #{place_id} not found: #{e.message}")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
return if place.reverse_geocoded?
|
if GOOGLE_PLACES_API_KEY.blank?
|
||||||
|
|
||||||
if ENV['GOOGLE_PLACES_API_KEY'].blank?
|
|
||||||
Rails.logger.warn('GOOGLE_PLACES_API_KEY is not set')
|
Rails.logger.warn('GOOGLE_PLACES_API_KEY is not set')
|
||||||
|
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
data = Geocoder.search([place.latitude, place.longitude])
|
# return if place.reverse_geocoded?
|
||||||
google_place_ids = data.map { _1.data['place_id'] }
|
|
||||||
first_place = google_places_client.spot(google_place_ids.shift)
|
|
||||||
|
|
||||||
|
google_places = google_places_client.spots(place.latitude, place.longitude, radius: 10)
|
||||||
|
first_place = google_places.shift
|
||||||
update_place(first_place)
|
update_place(first_place)
|
||||||
google_place_ids.each { |google_place_id| fetch_and_create_place(google_place_id) }
|
add_suggested_place_to_a_visit
|
||||||
|
google_places.each { |google_place| fetch_and_create_place(google_place) }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def google_places_client
|
def google_places_client
|
||||||
@google_places_client ||= GooglePlaces::Client.new(ENV['GOOGLE_PLACES_API_KEY'])
|
@google_places_client ||= GooglePlaces::Client.new(GOOGLE_PLACES_API_KEY)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_place(place)
|
def update_place(google_place)
|
||||||
place.update!(
|
place.update!(
|
||||||
name: place.name,
|
name: google_place.name,
|
||||||
latitude: place.lat,
|
latitude: google_place.lat,
|
||||||
longitude: place.lng,
|
longitude: google_place.lng,
|
||||||
city: place.city,
|
city: google_place.city,
|
||||||
country: place.country,
|
country: google_place.country,
|
||||||
raw_data: place.raw_data,
|
geodata: google_place.json_result_object,
|
||||||
source: :google_places,
|
source: :google_places,
|
||||||
reverse_geocoded_at: Time.zone.now
|
reverse_geocoded_at: Time.current
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_and_create_place(place_id)
|
def fetch_and_create_place(google_place)
|
||||||
place_data = google_places_client.spot(place_id)
|
new_place = find_google_place(google_place)
|
||||||
|
|
||||||
Place.create!(
|
new_place.name = google_place.name
|
||||||
name: place_data.name,
|
new_place.city = google_place.city
|
||||||
latitude: place_data.lat,
|
new_place.country = google_place.country
|
||||||
longitude: place_data.lng,
|
new_place.geodata = google_place.json_result_object
|
||||||
city: place_data.city,
|
new_place.source = :google_places
|
||||||
country: place_data.country,
|
|
||||||
raw_data: place_data.raw_data,
|
new_place.save!
|
||||||
source: :google_places
|
|
||||||
)
|
add_suggested_place_to_a_visit(suggested_place: new_place)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reverse_geocoded?
|
def reverse_geocoded?
|
||||||
place.geodata.present?
|
place.geodata.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_suggested_place_to_a_visit(suggested_place: place)
|
||||||
|
# 1. Find all visits that are close to the place
|
||||||
|
# 2. Add the place to the visit as a suggestion
|
||||||
|
visits = Place.near([suggested_place.latitude, suggested_place.longitude], 0.1).flat_map(&:visits)
|
||||||
|
|
||||||
|
# This is a very naive implementation, we should probably check if the place is already suggested
|
||||||
|
visits.each { |visit| visit.suggested_places << suggested_place }
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_google_place(google_place)
|
||||||
|
place = Place.where("geodata ->> 'place_id' = ?", google_place['place_id']).first
|
||||||
|
|
||||||
|
return place if place.present?
|
||||||
|
|
||||||
|
Place.find_or_initialize_by(
|
||||||
|
latitude: google_place['geometry']['location']['lat'],
|
||||||
|
longitude: google_place['geometry']['location']['lng']
|
||||||
|
)
|
||||||
|
end
|
||||||
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
|
||||||
|
|
@ -1,48 +1,112 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Visits::Suggest
|
class Visits::Suggest
|
||||||
def initialize(start_at: nil, end_at: nil)
|
attr_reader :points, :user, :start_at, :end_at
|
||||||
start_at ||= Date.new(2024, 7, 15).to_datetime.beginning_of_day.to_i
|
|
||||||
end_at ||= Date.new(2024, 7, 19).to_datetime.end_of_day.to_i
|
def initialize(user, start_at:, end_at:)
|
||||||
@points = Point.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
@start_at = start_at.to_i
|
||||||
|
@end_at = end_at.to_i
|
||||||
|
@points = user.tracked_points.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||||
|
@user = user
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
points_by_day = @points.group_by { |point| point_date(point) }
|
prepared_visits = Visits::Prepare.new(points).call
|
||||||
|
|
||||||
result = {}
|
visited_places = create_places(prepared_visits)
|
||||||
|
visits = create_visits(visited_places)
|
||||||
|
|
||||||
points_by_day.each do |day, day_points|
|
create_visits_notification(user)
|
||||||
day_points.sort_by!(&:timestamp)
|
|
||||||
|
|
||||||
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
|
return unless reverse_geocoding_enabled?
|
||||||
day_result = prepare_day_result(grouped_points)
|
|
||||||
result[day] = day_result
|
|
||||||
end
|
|
||||||
|
|
||||||
result
|
reverse_geocode(visits)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def point_date(point) = Time.zone.at(point.timestamp).to_date.to_s
|
def create_places(prepared_visits)
|
||||||
|
prepared_visits.flat_map do |date|
|
||||||
|
date[:visits] = handle_visits(date[:visits])
|
||||||
|
|
||||||
def calculate_radius(center_point, group)
|
date
|
||||||
max_distance = group.map { |point| center_point.distance_to(point) }.max
|
end
|
||||||
|
|
||||||
(max_distance / 10.0).ceil * 10
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_day_result(grouped_points)
|
def create_visits(visited_places)
|
||||||
result = {}
|
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),
|
||||||
|
ended_at: Time.zone.at(visit_data[:points].last.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
grouped_points.each do |group|
|
if visit_data[:area].present?
|
||||||
center_point = group.first
|
search_params[:area_id] = visit_data[:area].id
|
||||||
radius = calculate_radius(center_point, group)
|
elsif visit_data[:place].present?
|
||||||
key = "#{center_point.latitude},#{center_point.longitude},#{radius}m,#{group.size}"
|
search_params[:place_id] = visit_data[:place].id
|
||||||
result[key] = group.count
|
end
|
||||||
|
|
||||||
|
visit = Visit.find_or_initialize_by(search_params)
|
||||||
|
visit.name = visit_data[:place]&.name || visit_data[:area]&.name if visit.name.blank?
|
||||||
|
visit.save!
|
||||||
|
|
||||||
|
visit_data[:points].each { |point| point.update!(visit_id: visit.id) }
|
||||||
|
|
||||||
|
visit
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
result
|
def reverse_geocode(places)
|
||||||
|
places.each(&:async_reverse_geocode)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reverse_geocoding_enabled?
|
||||||
|
::REVERSE_GEOCODING_ENABLED && ::GOOGLE_PLACES_API_KEY.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],
|
||||||
|
longitude: visit[:longitude]
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
2
app/views/visits/_buttons.html.erb
Normal file
2
app/views/visits/_buttons.html.erb
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
||||||
|
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
||||||
40
app/views/visits/_modal.html.erb
Normal file
40
app/views/visits/_modal.html.erb
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!-- 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.suggested_places.first.id), class: 'w-full', 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-300 group-hover:opacity-100 flex items-center ml-4">
|
||||||
|
<%= render 'visits/buttons', visit: visit if visit.suggested? %>
|
||||||
|
<!-- 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>
|
||||||
|
|
@ -59,54 +59,7 @@
|
||||||
<div class="<%= index.odd? ? 'timeline-start' : 'timeline-end' %> mb-10 md:text-end">
|
<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>
|
<time class="font-mono italic"><%= date[:date].strftime('%A, %d %B %Y') %></time>
|
||||||
<% date[:visits].each do |visit| %>
|
<% date[:visits].each do |visit| %>
|
||||||
<div class="group relative">
|
<%= render partial: 'visit', locals: { visit: visit } %>
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div class="text-lg font-black <%= 'underline decoration-dotted' if visit.suggested? %>">
|
|
||||||
<%= visit.area.name %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="opacity-0 transition-opacity duration-300 group-hover:opacity-100 flex items-center ml-4">
|
|
||||||
<% if visit.suggested? %>
|
|
||||||
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
|
||||||
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
|
||||||
<% end %>
|
|
||||||
<!-- The button to open modal -->
|
|
||||||
<label for="visit_details_popup_<%= visit.id %>" class='btn btn-xs btn-info'>Map</label>
|
|
||||||
|
|
||||||
<!-- Put this part before </body> tag -->
|
|
||||||
<input type="checkbox" id="visit_details_popup_<%= visit.id %>" class="modal-toggle" />
|
|
||||||
<div class="modal" role="dialog">
|
|
||||||
<div class="modal-box w-10/12">
|
|
||||||
<h3 class="text-lg font-bold">
|
|
||||||
<%= visit.area_name %>, <%= visit.started_at.strftime('%d.%m.%Y') %>, <%= visit.started_at.strftime('%H:%M') %> - <%= visit.ended_at.strftime('%H:%M') %>
|
|
||||||
</h3>
|
|
||||||
<div class="flex justify-between my-5">
|
|
||||||
<div><a class='link'><</a> Cafe, Sterndamm 86D <a class='link'>></a>, <%= visit.points.count %></div>
|
|
||||||
<div class='flex'>
|
|
||||||
<%= button_to 'Confirm', visit_path(visit, 'visit[status]': :confirmed), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-success' %>
|
|
||||||
<%= button_to 'Decline', visit_path(visit, 'visit[status]': :declined), method: :patch, data: { turbo: false }, class: 'btn btn-xs btn-error mx-1' %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class='w-full h-[25rem]'
|
|
||||||
data-controller="visit-modal-map"
|
|
||||||
data-coordinates="<%= visit.coordinates %>"
|
|
||||||
data-radius="<%= visit.area.radius %>"
|
|
||||||
data-center="<%= visit.area.center %>"
|
|
||||||
>
|
|
||||||
<div data-visit-modal-map-target="container" class="h-[25rem] w-auto h-96">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<label class="modal-backdrop" for="visit_details_popup_<%= visit.id %>">Close</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
|
|
||||||
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
|
MIN_MINUTES_SPENT_IN_CITY = ENV.fetch('MIN_MINUTES_SPENT_IN_CITY', 60).to_i
|
||||||
REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'
|
REVERSE_GEOCODING_ENABLED = ENV.fetch('REVERSE_GEOCODING_ENABLED', 'true') == 'true'
|
||||||
|
GOOGLE_PLACES_API_KEY = ''
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
# geocoding service request timeout, in seconds (default 3):
|
# geocoding service request timeout, in seconds (default 3):
|
||||||
# timeout: 5,
|
timeout: 10,
|
||||||
|
|
||||||
# set default units to kilometers:
|
# set default units to kilometers:
|
||||||
units: :km,
|
units: :km,
|
||||||
|
|
@ -12,12 +12,13 @@ config = {
|
||||||
cache_options: {
|
cache_options: {
|
||||||
expiration: 1.day # Defaults to `nil`
|
expiration: 1.day # Defaults to `nil`
|
||||||
# prefix: "another_key:" # Defaults to `geocoder:`
|
# prefix: "another_key:" # Defaults to `geocoder:`
|
||||||
}
|
},
|
||||||
|
always_raise: :all
|
||||||
}
|
}
|
||||||
|
|
||||||
if ENV['GOOGLE_PLACES_API_KEY'].present?
|
if GOOGLE_PLACES_API_KEY.present?
|
||||||
config[:lookup] = :google
|
config[:lookup] = :google
|
||||||
config[:api_key] = ENV['GOOGLE_PLACES_API_KEY']
|
config[:api_key] = GOOGLE_PLACES_API_KEY
|
||||||
end
|
end
|
||||||
|
|
||||||
Geocoder.configure(config)
|
Geocoder.configure(config)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ Rails.application.routes.draw do
|
||||||
namespace :v1 do
|
namespace :v1 do
|
||||||
resources :areas, only: %i[index create update destroy]
|
resources :areas, only: %i[index create update destroy]
|
||||||
resources :points, only: %i[index destroy]
|
resources :points, only: %i[index destroy]
|
||||||
|
resources :visits, only: %i[update]
|
||||||
|
|
||||||
namespace :overland do
|
namespace :overland do
|
||||||
resources :batches, only: :create
|
resources :batches, only: :create
|
||||||
|
|
|
||||||
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
|
||||||
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
|
||||||
18
db/schema.rb
generated
18
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) do
|
ActiveRecord::Schema[7.1].define(version: 2024_08_08_121027) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
|
|
@ -93,6 +93,15 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) do
|
||||||
t.index ["user_id"], name: "index_notifications_on_user_id"
|
t.index ["user_id"], name: "index_notifications_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "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|
|
create_table "places", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||||
|
|
@ -182,7 +191,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "visits", force: :cascade do |t|
|
create_table "visits", force: :cascade do |t|
|
||||||
t.bigint "area_id", null: false
|
t.bigint "area_id"
|
||||||
t.bigint "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
t.datetime "started_at", null: false
|
t.datetime "started_at", null: false
|
||||||
t.datetime "ended_at", null: false
|
t.datetime "ended_at", null: false
|
||||||
|
|
@ -191,7 +200,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) do
|
||||||
t.integer "status", default: 0, null: false
|
t.integer "status", default: 0, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "place_id"
|
||||||
t.index ["area_id"], name: "index_visits_on_area_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"
|
t.index ["user_id"], name: "index_visits_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -199,9 +210,12 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) do
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "areas", "users"
|
add_foreign_key "areas", "users"
|
||||||
add_foreign_key "notifications", "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", "users"
|
||||||
add_foreign_key "points", "visits"
|
add_foreign_key "points", "visits"
|
||||||
add_foreign_key "stats", "users"
|
add_foreign_key "stats", "users"
|
||||||
add_foreign_key "visits", "areas"
|
add_foreign_key "visits", "areas"
|
||||||
|
add_foreign_key "visits", "places"
|
||||||
add_foreign_key "visits", "users"
|
add_foreign_key "visits", "users"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
6
spec/factories/place_visits.rb
Normal file
6
spec/factories/place_visits.rb
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :place_visit do
|
||||||
|
place { nil }
|
||||||
|
visit { nil }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :place do
|
factory :place do
|
||||||
name { "MyString" }
|
name { 'MyString' }
|
||||||
|
latitude { 1.5 }
|
||||||
|
longitude { 1.5 }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe VisitSuggestingJob, type: :job do
|
RSpec.describe VisitSuggestingJob, type: :job do
|
||||||
pending "add some examples to (or delete) #{__FILE__}"
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Place, type: :model do
|
RSpec.describe Place, type: :model do
|
||||||
pending "add some examples to (or delete) #{__FILE__}"
|
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 google_places]) }
|
||||||
|
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
|
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,10 +46,6 @@ RSpec.describe Point, type: :model do
|
||||||
describe '#async_reverse_geocode' do
|
describe '#async_reverse_geocode' do
|
||||||
let(:point) { build(:point) }
|
let(:point) { build(:point) }
|
||||||
|
|
||||||
it 'enqueues ReverseGeocodeJob' do
|
|
||||||
expect { point.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'enqueues ReverseGeocodeJob with correct arguments' do
|
it 'enqueues ReverseGeocodeJob with correct arguments' do
|
||||||
point.save
|
point.save
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Visit, type: :model do
|
RSpec.describe Visit, type: :model do
|
||||||
describe 'associations' 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 belong_to(:user) }
|
||||||
it { is_expected.to have_many(:points).dependent(:nullify) }
|
it { is_expected.to have_many(:points).dependent(:nullify) }
|
||||||
end
|
end
|
||||||
|
|
|
||||||
7
spec/requests/api/v1/visits_spec.rb
Normal file
7
spec/requests/api/v1/visits_spec.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe "Api::V1::Visits", type: :request do
|
||||||
|
describe "GET /index" do
|
||||||
|
pending "add some examples (or delete) #{__FILE__}"
|
||||||
|
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
|
||||||
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
|
||||||
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
|
||||||
8
spec/services/visits/group_points_spec.rb
Normal file
8
spec/services/visits/group_points_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Visits::GroupPoints do
|
||||||
|
describe '#call' do
|
||||||
|
end
|
||||||
|
end
|
||||||
8
spec/services/visits/group_spec.rb
Normal file
8
spec/services/visits/group_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Visits::Group do
|
||||||
|
describe '#call' do
|
||||||
|
end
|
||||||
|
end
|
||||||
8
spec/services/visits/prepare_spec.rb
Normal file
8
spec/services/visits/prepare_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Visits::Prepare do
|
||||||
|
describe '#call' do
|
||||||
|
end
|
||||||
|
end
|
||||||
8
spec/services/visits/suggest_spec.rb
Normal file
8
spec/services/visits/suggest_spec.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Visits::Suggest do
|
||||||
|
describe '#call' do
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in a new issue