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

View file

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

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." 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

View file

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

View file

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

View file

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

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

View file

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

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

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

View file

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

View file

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

View file

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

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. # 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

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 FactoryBot.define do
factory :place do factory :place do
name { "MyString" } name { 'MyString' }
latitude { 1.5 }
longitude { 1.5 }
end end
end end

View file

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

View file

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

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

View file

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

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