Add lots of logic

This commit is contained in:
Eugene Burmakin 2024-08-12 22:18:11 +02:00
parent 5394e9dd52
commit 382f937f29
45 changed files with 730 additions and 135 deletions

View file

@ -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

View file

@ -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

View 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

View file

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

View file

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

View file

@ -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

View file

@ -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

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Area < ApplicationRecord
reverse_geocoded_by :latitude, :longitude
belongs_to :user
has_many :visits, dependent: :destroy

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
class Visits::Prepare
attr_reader :points
def initialize(points)
@points = points
end
def call
points_by_day = points.group_by { |point| point_date(point) }
points_by_day.map do |day, day_points|
day_points.sort_by!(&:timestamp)
grouped_points = Visits::GroupPoints.new(day_points).group_points_by_radius
day_result = prepare_day_result(grouped_points)
next if day_result.blank?
{ date: day, visits: day_result }
end.compact
end
private
def point_date(point) = Time.zone.at(point.timestamp).to_date.to_s
def calculate_radius(center_point, group)
max_distance = group.map { |point| center_point.distance_to(point) }.max
(max_distance / 10.0).ceil * 10
end
def prepare_day_result(grouped_points)
grouped_points.map do |group|
center_point = group.first
{
latitude: center_point.latitude,
longitude: center_point.longitude,
radius: calculate_radius(center_point, group),
points: group,
duration: (group.last.timestamp - group.first.timestamp).to_i / 60
}
end
end
end

View file

@ -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
result
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
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

View 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' %>

View 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>

View file

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

View file

@ -0,0 +1,15 @@
<div class="group relative">
<div class="flex items-center justify-between">
<div>
<%= render 'visits/name', visit: visit %>
<div><%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %></div>
</div>
<div class="opacity-0 transition-opacity duration-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>

View file

@ -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 />

View file

@ -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 = ''

View file

@ -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)

View file

@ -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

View file

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

View file

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

View file

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

View file

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

18
db/schema.rb generated
View file

@ -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

View file

@ -0,0 +1,6 @@
FactoryBot.define do
factory :place_visit do
place { nil }
visit { nil }
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -46,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

View file

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

View file

@ -0,0 +1,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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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