mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 01:01: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/)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -135,10 +135,6 @@ GEM
|
|||
geocoder (1.8.3)
|
||||
base64 (>= 0.1.0)
|
||||
csv (>= 3.0.0)
|
||||
geokit (1.14.0)
|
||||
geokit-rails (2.5.0)
|
||||
geokit (~> 1.5)
|
||||
rails (>= 3.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
google_places (2.0.0)
|
||||
|
|
@ -435,8 +431,6 @@ DEPENDENCIES
|
|||
ffaker
|
||||
foreman
|
||||
geocoder
|
||||
geokit
|
||||
geokit-rails
|
||||
google_places
|
||||
importmap-rails
|
||||
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."
|
||||
).call
|
||||
|
||||
StatCreatingJob.perform_later(user_id)
|
||||
schedule_stats_creating(user_id)
|
||||
schedule_visit_suggesting(user_id, import)
|
||||
rescue StandardError => e
|
||||
Notifications::Create.new(
|
||||
user:,
|
||||
|
|
@ -41,4 +42,16 @@ class ImportJob < ApplicationJob
|
|||
when 'gpx' then Gpx::TrackParser
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class VisitSuggestingJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(*args)
|
||||
# Do something later
|
||||
def perform(user_ids: [], start_at: 1.week.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,6 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Area < ApplicationRecord
|
||||
reverse_geocoded_by :latitude, :longitude
|
||||
|
||||
belongs_to :user
|
||||
has_many :visits, dependent: :destroy
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,25 @@
|
|||
# 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, google_places: 1 }
|
||||
|
||||
after_commit :async_reverse_geocode, on: :create
|
||||
|
||||
private
|
||||
|
||||
def async_reverse_geocode
|
||||
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)
|
||||
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
|
||||
|
|
@ -1,17 +1,43 @@
|
|||
# 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: { suggested: 0, confirmed: 1, declined: 2 }
|
||||
|
||||
delegate :name, to: :area, prefix: true
|
||||
|
||||
def coordinates
|
||||
points.pluck(:latitude, :longitude).map { [_1[0].to_f, _1[1].to_f] }
|
||||
end
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -5,61 +5,78 @@ class ReverseGeocoding::Places::FetchData
|
|||
|
||||
def initialize(place_id)
|
||||
@place = Place.find(place_id)
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Rails.logger.error("Place with id #{place_id} not found: #{e.message}")
|
||||
end
|
||||
|
||||
def call
|
||||
return if place.reverse_geocoded?
|
||||
|
||||
if ENV['GOOGLE_PLACES_API_KEY'].blank?
|
||||
if GOOGLE_PLACES_API_KEY.blank?
|
||||
Rails.logger.warn('GOOGLE_PLACES_API_KEY is not set')
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
data = Geocoder.search([place.latitude, place.longitude])
|
||||
google_place_ids = data.map { _1.data['place_id'] }
|
||||
first_place = google_places_client.spot(google_place_ids.shift)
|
||||
# return if place.reverse_geocoded?
|
||||
|
||||
google_places = google_places_client.spots(place.latitude, place.longitude, radius: 10)
|
||||
first_place = google_places.shift
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def update_place(place)
|
||||
def update_place(google_place)
|
||||
place.update!(
|
||||
name: place.name,
|
||||
latitude: place.lat,
|
||||
longitude: place.lng,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
raw_data: place.raw_data,
|
||||
name: google_place.name,
|
||||
latitude: google_place.lat,
|
||||
longitude: google_place.lng,
|
||||
city: google_place.city,
|
||||
country: google_place.country,
|
||||
geodata: google_place.json_result_object,
|
||||
source: :google_places,
|
||||
reverse_geocoded_at: Time.zone.now
|
||||
reverse_geocoded_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_and_create_place(place_id)
|
||||
place_data = google_places_client.spot(place_id)
|
||||
def fetch_and_create_place(google_place)
|
||||
new_place = find_google_place(google_place)
|
||||
|
||||
Place.create!(
|
||||
name: place_data.name,
|
||||
latitude: place_data.lat,
|
||||
longitude: place_data.lng,
|
||||
city: place_data.city,
|
||||
country: place_data.country,
|
||||
raw_data: place_data.raw_data,
|
||||
source: :google_places
|
||||
)
|
||||
new_place.name = google_place.name
|
||||
new_place.city = google_place.city
|
||||
new_place.country = google_place.country
|
||||
new_place.geodata = google_place.json_result_object
|
||||
new_place.source = :google_places
|
||||
|
||||
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)
|
||||
# 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
|
||||
|
|
|
|||
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
|
||||
|
||||
class Visits::Suggest
|
||||
def initialize(start_at: nil, end_at: nil)
|
||||
start_at ||= Date.new(2024, 7, 15).to_datetime.beginning_of_day.to_i
|
||||
end_at ||= Date.new(2024, 7, 19).to_datetime.end_of_day.to_i
|
||||
@points = Point.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||
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.order(timestamp: :asc).where(timestamp: start_at..end_at)
|
||||
@user = user
|
||||
end
|
||||
|
||||
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|
|
||||
day_points.sort_by!(&:timestamp)
|
||||
create_visits_notification(user)
|
||||
|
||||
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
|
||||
day_result = prepare_day_result(grouped_points)
|
||||
result[day] = day_result
|
||||
end
|
||||
return unless reverse_geocoding_enabled?
|
||||
|
||||
result
|
||||
reverse_geocode(visits)
|
||||
end
|
||||
|
||||
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)
|
||||
max_distance = group.map { |point| center_point.distance_to(point) }.max
|
||||
|
||||
(max_distance / 10.0).ceil * 10
|
||||
date
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_day_result(grouped_points)
|
||||
result = {}
|
||||
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),
|
||||
ended_at: Time.zone.at(visit_data[:points].last.timestamp)
|
||||
}
|
||||
|
||||
grouped_points.each do |group|
|
||||
center_point = group.first
|
||||
radius = calculate_radius(center_point, group)
|
||||
key = "#{center_point.latitude},#{center_point.longitude},#{radius}m,#{group.size}"
|
||||
result[key] = group.count
|
||||
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.save!
|
||||
|
||||
visit_data[:points].each { |point| point.update!(visit_id: visit.id) }
|
||||
|
||||
visit
|
||||
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
|
||||
|
|
|
|||
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">
|
||||
<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.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>
|
||||
<%= render partial: 'visit', locals: { visit: visit } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr />
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
|
||||
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'
|
||||
GOOGLE_PLACES_API_KEY = ''
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
config = {
|
||||
# geocoding service request timeout, in seconds (default 3):
|
||||
# timeout: 5,
|
||||
timeout: 10,
|
||||
|
||||
# set default units to kilometers:
|
||||
units: :km,
|
||||
|
|
@ -12,12 +12,13 @@ config = {
|
|||
cache_options: {
|
||||
expiration: 1.day # Defaults to `nil`
|
||||
# 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[:api_key] = ENV['GOOGLE_PLACES_API_KEY']
|
||||
config[:api_key] = GOOGLE_PLACES_API_KEY
|
||||
end
|
||||
|
||||
Geocoder.configure(config)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,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]
|
||||
|
||||
namespace :overland do
|
||||
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.
|
||||
|
||||
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
|
||||
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"
|
||||
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
|
||||
|
|
@ -182,7 +191,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) 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
|
||||
|
|
@ -191,7 +200,9 @@ ActiveRecord::Schema[7.1].define(version: 2024_08_05_150111) 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
|
||||
|
||||
|
|
@ -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 "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
|
||||
|
|
|
|||
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
|
||||
factory :place do
|
||||
name { "MyString" }
|
||||
name { 'MyString' }
|
||||
latitude { 1.5 }
|
||||
longitude { 1.5 }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
|
|
|
|||
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
|
||||
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
|
||||
point.save
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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