mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 21:28:01 -05:00
Merge remote-tracking branch 'origin' into feature/subscription
This commit is contained in:
@@ -1 +1 @@
|
||||
0.24.2
|
||||
0.25.0
|
||||
|
||||
64
CHANGELOG.md
64
CHANGELOG.md
@@ -4,11 +4,63 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
# 0.24.2 - 2025-02-24
|
||||
# 0.25.0 - 2025-03-09
|
||||
|
||||
This release is focused on improving the visits experience.
|
||||
|
||||
Since previous implementation of visits was not working as expected, this release introduces a new approach. It is recommended to remove all _non-confirmed_ visits before or after updating to this version.
|
||||
|
||||
There is a known issue when data migrations are not being run automatically on some systems. If you're experiencing issues when opening map page, trips page or when trying to see visits, try executing the following command in the [Console](https://dawarich.app/docs/FAQ/#how-to-enter-dawarich-console):
|
||||
|
||||
```ruby
|
||||
User.includes(:tracked_points, visits: :places).find_each do |user|
|
||||
places_to_update = user.places.where(lonlat: nil)
|
||||
|
||||
# For each place, set the lonlat value based on longitude and latitude
|
||||
places_to_update.find_each do |place|
|
||||
next if place.longitude.nil? || place.latitude.nil?
|
||||
|
||||
# Set the lonlat to a PostGIS point with the proper SRID
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
place.update_column(:lonlat, "SRID=4326;POINT(#{place.longitude} #{place.latitude})")
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
user.tracked_points.update_all('lonlat = ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)')
|
||||
end
|
||||
```
|
||||
|
||||
With any errors, don't hesitate to ask for help in the [Discord server](https://discord.gg/pHsBjpt5J8).
|
||||
|
||||
|
||||
|
||||
## Added
|
||||
|
||||
- A new button to open the visits drawer.
|
||||
- User can now confirm or decline visits directly from the visits drawer.
|
||||
- Visits are now being shown on the map: orange circles for suggested visits and slightly bigger blue circles for confirmed visits.
|
||||
- User can click on a visit circle to rename it and select a place for it.
|
||||
- User can click on a visit card in the drawer panel to move to it on the map.
|
||||
- User can select click on the "Select area" button in the top right corner of the map to select an area on the map. Once area is selected, visits for all times in that area will be shown on the map, regardless of whether they are in the selected time range or not.
|
||||
- User can now select two or more visits in the visits drawer and merge them into a single visit. This operation is not reversible.
|
||||
- User can now select two or more visits in the visits drawer and confirm or decline them at once. This operation is not reversible.
|
||||
- Status field to the User model. Inactive users are now being restricted from accessing some of the functionality, which is mostly about writing data to the database. Reading is remaining unrestricted.
|
||||
- After user is created, a sample import is being created for them to demonstrate how to use the app.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
- Links to Points, Visits & Places, Imports and Exports were moved under "My data" section in the navbar.
|
||||
- Restrict access to Sidekiq in non self-hosted mode.
|
||||
- Restrict access to background jobs in non self-hosted mode.
|
||||
- Restrict access to users management in non self-hosted mode.
|
||||
- Restrict access to API for inactive users.
|
||||
- All users in self-hosted mode are active by default.
|
||||
- Points are now using `lonlat` column for storing longitude and latitude.
|
||||
- Semantic history points are now being imported much faster.
|
||||
- GPX files are now being imported much faster.
|
||||
- Trips, places and points are now using PostGIS' database attributes for storing longitude and latitude.
|
||||
- Distance calculation are now using Postgis functions and expected to be more accurate.
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -16,16 +68,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Fixed a bug where upon point deletion it was not being removed from the map, while it was actually deleted from the database. #883
|
||||
- Fixed a bug where upon import deletion stats were not being recalculated. #824
|
||||
|
||||
### Changed
|
||||
|
||||
- Restrict access to Sidekiq in non self-hosted mode.
|
||||
- Restrict access to background jobs in non self-hosted mode.
|
||||
- Restrict access to users management in non self-hosted mode.
|
||||
- Points are now using `lonlat` column for storing longitude and latitude.
|
||||
- Semantic history points are now being imported much faster.
|
||||
- GPX files are now being imported much faster.
|
||||
- Distance calculation are now using Postgis functions and expected to be more accurate.
|
||||
|
||||
# 0.24.1 - 2025-02-13
|
||||
|
||||
## Custom map tiles
|
||||
|
||||
25
Gemfile.lock
25
Gemfile.lock
@@ -164,7 +164,7 @@ GEM
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
json (2.9.1)
|
||||
json (2.10.1)
|
||||
json-schema (5.0.1)
|
||||
addressable (~> 2.8)
|
||||
jwt (2.10.1)
|
||||
@@ -182,6 +182,7 @@ GEM
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
language_server-protocol (3.17.0.4)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.6.6)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
@@ -234,7 +235,7 @@ GEM
|
||||
orm_adapter (0.5.0)
|
||||
ostruct (0.6.1)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.7.0)
|
||||
parser (3.3.7.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
patience_diff (1.2.0)
|
||||
@@ -263,7 +264,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.9)
|
||||
rack (3.1.10)
|
||||
rack-session (2.1.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -349,23 +350,25 @@ GEM
|
||||
rswag-ui (2.16.0)
|
||||
actionpack (>= 5.2, < 8.1)
|
||||
railties (>= 5.2, < 8.1)
|
||||
rubocop (1.71.0)
|
||||
rubocop (1.72.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.36.2, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.37.0)
|
||||
rubocop-ast (1.38.0)
|
||||
parser (>= 3.3.1.0)
|
||||
rubocop-rails (2.29.1)
|
||||
rubocop-rails (2.30.1)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.52.0, < 2.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
securerandom (0.4.1)
|
||||
shoulda-matchers (6.4.0)
|
||||
@@ -373,7 +376,7 @@ GEM
|
||||
shrine (3.6.0)
|
||||
content_disposition (~> 1.0)
|
||||
down (~> 5.1)
|
||||
sidekiq (7.3.8)
|
||||
sidekiq (7.3.9)
|
||||
base64
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@
|
||||
*= require_self
|
||||
*/
|
||||
|
||||
.emoji-icon {
|
||||
.emoji-icon {
|
||||
font-size: 36px; /* Adjust size as needed */
|
||||
text-align: center;
|
||||
line-height: 36px; /* Same as font-size for perfect centering */
|
||||
@@ -101,9 +101,3 @@
|
||||
content: '✅';
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,3 +20,86 @@
|
||||
transition: opacity 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet Panel Styles */
|
||||
.leaflet-right-panel {
|
||||
margin-top: 80px; /* Give space for controls above */
|
||||
margin-right: 10px;
|
||||
transform: none;
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 400;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaflet-right-panel.controls-shifted {
|
||||
right: 310px;
|
||||
}
|
||||
|
||||
.leaflet-control-button {
|
||||
background-color: white !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-button:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
/* Drawer Panel Styles */
|
||||
.leaflet-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 338px;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
z-index: 450;
|
||||
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.leaflet-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Controls transition */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-button,
|
||||
.toggle-panel-button {
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.controls-shifted {
|
||||
right: 338px !important;
|
||||
}
|
||||
|
||||
/* Selection Tool Styles */
|
||||
.leaflet-control-custom {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaflet-control-custom:hover {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
#selection-tool-button.active {
|
||||
background-color: #60a5fa;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Cancel Selection Button */
|
||||
#cancel-selection-button {
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
14
app/controllers/api/v1/visits/possible_places_controller.rb
Normal file
14
app/controllers/api/v1/visits/possible_places_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Visits::PossiblePlacesController < ApiController
|
||||
def index
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
possible_places = visit.suggested_places.map do |place|
|
||||
Api::PlaceSerializer.new(place).call
|
||||
end
|
||||
|
||||
render json: possible_places
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Visit not found' }, status: :not_found
|
||||
end
|
||||
end
|
||||
@@ -1,17 +1,79 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::VisitsController < ApiController
|
||||
def index
|
||||
visits = Visits::Finder.new(current_api_user, params).call
|
||||
serialized_visits = visits.map do |visit|
|
||||
Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
||||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def update
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
visit = update_visit(visit)
|
||||
|
||||
render json: visit
|
||||
render json: Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
||||
def merge
|
||||
# Validate that we have at least 2 visit IDs
|
||||
visit_ids = params[:visit_ids]
|
||||
if visit_ids.blank? || visit_ids.length < 2
|
||||
return render json: { error: 'At least 2 visits must be selected for merging' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# Find all visits that belong to the current user
|
||||
visits = current_api_user.visits.where(id: visit_ids).order(started_at: :asc)
|
||||
|
||||
# Ensure we found all the visits
|
||||
if visits.length != visit_ids.length
|
||||
return render json: { error: 'One or more visits not found' }, status: :not_found
|
||||
end
|
||||
|
||||
# Use the service to merge the visits
|
||||
service = Visits::MergeService.new(visits)
|
||||
merged_visit = service.call
|
||||
|
||||
if merged_visit&.persisted?
|
||||
render json: Api::VisitSerializer.new(merged_visit).call, status: :ok
|
||||
else
|
||||
render json: { error: service.errors.join(', ') }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
service = Visits::BulkUpdate.new(
|
||||
current_api_user,
|
||||
params[:visit_ids],
|
||||
params[:status]
|
||||
)
|
||||
|
||||
result = service.call
|
||||
|
||||
if result
|
||||
render json: {
|
||||
message: "#{result[:count]} visits updated successfully",
|
||||
updated_count: result[:count]
|
||||
}, status: :ok
|
||||
else
|
||||
render json: { error: service.errors.join(', ') }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def visit_params
|
||||
params.require(:visit).permit(:name, :place_id)
|
||||
params.require(:visit).permit(:name, :place_id, :status)
|
||||
end
|
||||
|
||||
def merge_params
|
||||
params.permit(visit_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.permit(:status, visit_ids: [])
|
||||
end
|
||||
|
||||
def update_visit(visit)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Settings::UsersController < ApplicationController
|
||||
before_action :authenticate_admin!
|
||||
before_action :authenticate_self_hosted!
|
||||
before_action :authenticate_admin!
|
||||
|
||||
def index
|
||||
@users = User.order(created_at: :desc)
|
||||
|
||||
@@ -11,11 +11,10 @@ class VisitsController < ApplicationController
|
||||
visits = current_user
|
||||
.visits
|
||||
.where(status:)
|
||||
.includes(%i[suggested_places area])
|
||||
.includes(%i[suggested_places area points])
|
||||
.order(started_at: order_by)
|
||||
|
||||
@suggested_visits_count = current_user.visits.suggested.count
|
||||
|
||||
@visits = visits.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { fetchAndDrawAreas, handleAreaCreated } from "../maps/areas";
|
||||
|
||||
import { showFlashMessage, fetchAndDisplayPhotos } from "../maps/helpers";
|
||||
import { countryCodesMap } from "../maps/country_codes";
|
||||
import { VisitsManager } from "../maps/visits";
|
||||
|
||||
import "leaflet-draw";
|
||||
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
|
||||
@@ -29,6 +30,7 @@ export default class extends BaseController {
|
||||
layerControl = null;
|
||||
visitedCitiesCache = new Map();
|
||||
trackedMonthsCache = null;
|
||||
currentPopup = null;
|
||||
|
||||
connect() {
|
||||
super.connect();
|
||||
@@ -101,6 +103,21 @@ export default class extends BaseController {
|
||||
this.map.getPane('areasPane').style.zIndex = 650;
|
||||
this.map.getPane('areasPane').style.pointerEvents = 'all';
|
||||
|
||||
// Create custom panes for visits
|
||||
// Note: We'll still create visitsPane for backward compatibility
|
||||
this.map.createPane('visitsPane');
|
||||
this.map.getPane('visitsPane').style.zIndex = 600;
|
||||
this.map.getPane('visitsPane').style.pointerEvents = 'all';
|
||||
|
||||
// Create separate panes for confirmed and suggested visits
|
||||
this.map.createPane('confirmedVisitsPane');
|
||||
this.map.getPane('confirmedVisitsPane').style.zIndex = 450;
|
||||
this.map.getPane('confirmedVisitsPane').style.pointerEvents = 'all';
|
||||
|
||||
this.map.createPane('suggestedVisitsPane');
|
||||
this.map.getPane('suggestedVisitsPane').style.zIndex = 460;
|
||||
this.map.getPane('suggestedVisitsPane').style.pointerEvents = 'all';
|
||||
|
||||
// Initialize areasLayer as a feature group and add it to the map immediately
|
||||
this.areasLayer = new L.FeatureGroup();
|
||||
this.photoMarkers = L.layerGroup();
|
||||
@@ -111,6 +128,9 @@ export default class extends BaseController {
|
||||
this.addSettingsButton();
|
||||
}
|
||||
|
||||
// Initialize the visits manager
|
||||
this.visitsManager = new VisitsManager(this.map, this.apiKey);
|
||||
|
||||
// Initialize layers for the layer control
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
@@ -119,7 +139,9 @@ export default class extends BaseController {
|
||||
"Fog of War": new this.fogOverlay(),
|
||||
"Scratch map": this.scratchLayer,
|
||||
Areas: this.areasLayer,
|
||||
Photos: this.photoMarkers
|
||||
Photos: this.photoMarkers,
|
||||
"Suggested Visits": this.visitsManager.getVisitCirclesLayer(),
|
||||
"Confirmed Visits": this.visitsManager.getConfirmedVisitCirclesLayer()
|
||||
};
|
||||
|
||||
// Initialize layer control first
|
||||
@@ -248,6 +270,12 @@ export default class extends BaseController {
|
||||
|
||||
// Start monitoring
|
||||
this.tileMonitor.startMonitoring();
|
||||
|
||||
// Add the drawer button for visits
|
||||
this.visitsManager.addDrawerButton();
|
||||
|
||||
// Fetch and display visits when map loads
|
||||
this.visitsManager.fetchAndDisplayVisits();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
@@ -959,12 +987,17 @@ export default class extends BaseController {
|
||||
const button = L.DomUtil.create('button', 'toggle-panel-button');
|
||||
button.innerHTML = '📅';
|
||||
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.backgroundColor = 'white';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '48px';
|
||||
button.style.fontSize = '18px';
|
||||
button.style.textAlign = 'center';
|
||||
|
||||
// Disable map interactions when clicking the button
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
@@ -1318,15 +1351,4 @@ export default class extends BaseController {
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const days = Math.floor(seconds / (24 * 60 * 60));
|
||||
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
return `${hours}h`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,9 @@ export default class extends BaseController {
|
||||
element.textContent = newName;
|
||||
});
|
||||
}
|
||||
|
||||
updateAll(event) {
|
||||
const newName = event.detail.name;
|
||||
this.updateVisitNameOnPage(newName);
|
||||
}
|
||||
}
|
||||
|
||||
110
app/javascript/controllers/visits_map_controller.js
Normal file
110
app/javascript/controllers/visits_map_controller.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import BaseController from "./base_controller"
|
||||
import L from "leaflet"
|
||||
import { osmMapLayer } from "../maps/layers"
|
||||
|
||||
export default class extends BaseController {
|
||||
static targets = ["container"]
|
||||
|
||||
connect() {
|
||||
this.initializeMap();
|
||||
this.visits = new Map();
|
||||
this.highlightedVisit = null;
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
// Initialize the map with a default center (will be updated when visits are added)
|
||||
this.map = L.map(this.containerTarget).setView([0, 0], 2);
|
||||
osmMapLayer(this.map, "OpenStreetMap");
|
||||
|
||||
// Add all visits to the map
|
||||
const visitElements = document.querySelectorAll('[data-visit-id]');
|
||||
if (visitElements.length > 0) {
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
visitElements.forEach(element => {
|
||||
const visitId = element.dataset.visitId;
|
||||
const lat = parseFloat(element.dataset.centerLat);
|
||||
const lon = parseFloat(element.dataset.centerLon);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: this.getVisitColor(element),
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(this.map);
|
||||
|
||||
// Store the marker reference
|
||||
this.visits.set(visitId, {
|
||||
marker,
|
||||
element
|
||||
});
|
||||
|
||||
bounds.extend([lat, lon]);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit the map to show all visits
|
||||
if (!bounds.isEmpty()) {
|
||||
this.map.fitBounds(bounds, {
|
||||
padding: [50, 50]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVisitColor(element) {
|
||||
// Check if the visit has a status badge
|
||||
const badge = element.querySelector('.badge');
|
||||
if (badge) {
|
||||
if (badge.classList.contains('badge-success')) {
|
||||
return '#2ecc71'; // Green for confirmed
|
||||
} else if (badge.classList.contains('badge-warning')) {
|
||||
return '#f1c40f'; // Yellow for suggested
|
||||
}
|
||||
}
|
||||
return '#e74c3c'; // Red for declined or unknown
|
||||
}
|
||||
|
||||
highlightVisit(event) {
|
||||
const visitId = event.currentTarget.dataset.visitId;
|
||||
const visit = this.visits.get(visitId);
|
||||
|
||||
if (visit) {
|
||||
// Reset previous highlight if any
|
||||
if (this.highlightedVisit) {
|
||||
this.highlightedVisit.marker.setStyle({
|
||||
radius: 8,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight the current visit
|
||||
visit.marker.setStyle({
|
||||
radius: 12,
|
||||
fillOpacity: 1
|
||||
});
|
||||
visit.marker.bringToFront();
|
||||
|
||||
// Center the map on the visit
|
||||
this.map.panTo(visit.marker.getLatLng());
|
||||
|
||||
this.highlightedVisit = visit;
|
||||
}
|
||||
}
|
||||
|
||||
unhighlightVisit(event) {
|
||||
const visitId = event.currentTarget.dataset.visitId;
|
||||
const visit = this.visits.get(visitId);
|
||||
|
||||
if (visit && this.highlightedVisit === visit) {
|
||||
visit.marker.setStyle({
|
||||
radius: 8,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
this.highlightedVisit = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,14 +92,14 @@ export function showFlashMessage(type, message) {
|
||||
if (!flashContainer) {
|
||||
flashContainer = document.createElement('div');
|
||||
flashContainer.id = 'flash-messages';
|
||||
flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-40';
|
||||
flashContainer.className = 'fixed top-5 right-5 flex flex-col-reverse gap-2 z-50';
|
||||
document.body.appendChild(flashContainer);
|
||||
}
|
||||
|
||||
// Create the flash message div
|
||||
const flashDiv = document.createElement('div');
|
||||
flashDiv.setAttribute('data-controller', 'removals');
|
||||
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-40`;
|
||||
flashDiv.className = `flex items-center justify-between ${classesForFlash(type)} py-3 px-5 rounded-lg z-50`;
|
||||
|
||||
// Create the message div
|
||||
const messageDiv = document.createElement('div');
|
||||
|
||||
@@ -3,46 +3,6 @@ import { formatDistance } from "../maps/helpers";
|
||||
import { minutesToDaysHoursMinutes } from "../maps/helpers";
|
||||
import { haversineDistance } from "../maps/helpers";
|
||||
|
||||
function pointToLineDistance(point, lineStart, lineEnd) {
|
||||
const x = point.lat;
|
||||
const y = point.lng;
|
||||
const x1 = lineStart.lat;
|
||||
const y1 = lineStart.lng;
|
||||
const x2 = lineEnd.lat;
|
||||
const y2 = lineEnd.lng;
|
||||
|
||||
const A = x - x1;
|
||||
const B = y - y1;
|
||||
const C = x2 - x1;
|
||||
const D = y2 - y1;
|
||||
|
||||
const dot = A * C + B * D;
|
||||
const lenSq = C * C + D * D;
|
||||
let param = -1;
|
||||
|
||||
if (lenSq !== 0) {
|
||||
param = dot / lenSq;
|
||||
}
|
||||
|
||||
let xx, yy;
|
||||
|
||||
if (param < 0) {
|
||||
xx = x1;
|
||||
yy = y1;
|
||||
} else if (param > 1) {
|
||||
xx = x2;
|
||||
yy = y2;
|
||||
} else {
|
||||
xx = x1 + param * C;
|
||||
yy = y1 + param * D;
|
||||
}
|
||||
|
||||
const dx = x - xx;
|
||||
const dy = y - yy;
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
export function calculateSpeed(point1, point2) {
|
||||
if (!point1 || !point2 || !point1[4] || !point2[4]) {
|
||||
console.warn('Invalid points for speed calculation:', { point1, point2 });
|
||||
|
||||
1497
app/javascript/maps/visits.js
Normal file
1497
app/javascript/maps/visits.js
Normal file
File diff suppressed because it is too large
Load Diff
17
app/javascript/styles/visits.css
Normal file
17
app/javascript/styles/visits.css
Normal file
@@ -0,0 +1,17 @@
|
||||
.visit-checkbox-container {
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.visit-item {
|
||||
position: relative;
|
||||
}
|
||||
.visit-item:hover .visit-checkbox-container {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.leaflet-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.merge-visits-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
35
app/jobs/bulk_visits_suggesting_job.rb
Normal file
35
app/jobs/bulk_visits_suggesting_job.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# This job is being run on daily basis at 00:05 to suggest visits for all users
|
||||
# with the default timespan of 1 day.
|
||||
class BulkVisitsSuggestingJob < ApplicationJob
|
||||
queue_as :visit_suggesting
|
||||
sidekiq_options retry: false
|
||||
|
||||
# Passing timespan of more than 3 years somehow results in duplicated Places
|
||||
def perform(start_at: 1.day.ago.beginning_of_day, end_at: 1.day.ago.end_of_day, user_ids: [])
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
users = user_ids.any? ? User.active.where(id: user_ids) : User.active
|
||||
start_at = start_at.to_datetime
|
||||
end_at = end_at.to_datetime
|
||||
|
||||
time_chunks = Visits::TimeChunks.new(start_at:, end_at:).call
|
||||
|
||||
users.active.find_each do |user|
|
||||
next if user.tracked_points.empty?
|
||||
|
||||
schedule_chunked_jobs(user, time_chunks)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schedule_chunked_jobs(user, time_chunks)
|
||||
time_chunks.each do |time_chunk|
|
||||
VisitSuggestingJob.perform_later(
|
||||
user_id: user.id, start_at: time_chunk.first, end_at: time_chunk.last
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
29
app/jobs/data_migrations/migrate_places_lonlat_job.rb
Normal file
29
app/jobs/data_migrations/migrate_places_lonlat_job.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class DataMigrations::MigratePlacesLonlatJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(user_id)
|
||||
user = User.find(user_id)
|
||||
|
||||
# Find all places with nil lonlat
|
||||
places_to_update = user.places.where(lonlat: nil)
|
||||
|
||||
# For each place, set the lonlat value based on longitude and latitude
|
||||
places_to_update.find_each do |place|
|
||||
next if place.longitude.nil? || place.latitude.nil?
|
||||
|
||||
# Set the lonlat to a PostGIS point with the proper SRID
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
place.update_column(:lonlat, "SRID=4326;POINT(#{place.longitude} #{place.latitude})")
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
# Double check if there are any remaining places without lonlat
|
||||
remaining = user.places.where(lonlat: nil)
|
||||
return unless remaining.exists?
|
||||
|
||||
# Log an error for these places
|
||||
Rails.logger.error("Places with ID #{remaining.pluck(:id).join(', ')} for user #{user.id} could not be updated with lonlat values")
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Overland::BatchCreatingJob < ApplicationJob
|
||||
include PointValidation
|
||||
|
||||
queue_as :default
|
||||
|
||||
def perform(params, user_id)
|
||||
@@ -12,15 +14,4 @@ class Overland::BatchCreatingJob < ApplicationJob
|
||||
Point.create!(location.merge(user_id:))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def point_exists?(params, user_id)
|
||||
Point.exists?(
|
||||
latitude: params[:latitude],
|
||||
longitude: params[:longitude],
|
||||
timestamp: params[:timestamp],
|
||||
user_id:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Owntracks::PointCreatingJob < ApplicationJob
|
||||
include PointValidation
|
||||
|
||||
queue_as :default
|
||||
|
||||
def perform(point_params, user_id)
|
||||
@@ -10,13 +12,4 @@ class Owntracks::PointCreatingJob < ApplicationJob
|
||||
|
||||
Point.create!(parsed_params.merge(user_id:))
|
||||
end
|
||||
|
||||
def point_exists?(params, user_id)
|
||||
Point.exists?(
|
||||
latitude: params[:latitude],
|
||||
longitude: params[:longitude],
|
||||
timestamp: params[:timestamp],
|
||||
user_id:
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,14 +4,25 @@ class VisitSuggestingJob < ApplicationJob
|
||||
queue_as :visit_suggesting
|
||||
sidekiq_options retry: false
|
||||
|
||||
def perform(user_ids: [], start_at: 1.day.ago, end_at: Time.current)
|
||||
users = user_ids.any? ? User.where(id: user_ids) : User.all
|
||||
# Passing timespan of more than 3 years somehow results in duplicated Places
|
||||
def perform(user_id:, start_at:, end_at:)
|
||||
user = User.find(user_id)
|
||||
|
||||
users.find_each do |user|
|
||||
next unless user.active?
|
||||
next if user.tracked_points.empty?
|
||||
start_time = parse_date(start_at)
|
||||
end_time = parse_date(end_at)
|
||||
|
||||
Visits::Suggest.new(user, start_at:, end_at:).call
|
||||
# Create one-day chunks
|
||||
current_time = start_time
|
||||
while current_time < end_time
|
||||
chunk_end = [current_time + 1.day, end_time].min
|
||||
Visits::Suggest.new(user, start_at: current_time, end_at: chunk_end).call
|
||||
current_time += 1.day
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_date(date)
|
||||
date.is_a?(String) ? Time.zone.parse(date) : date.to_datetime
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,5 +8,8 @@ class Area < ApplicationRecord
|
||||
|
||||
validates :name, :latitude, :longitude, :radius, presence: true
|
||||
|
||||
alias_attribute :lon, :longitude
|
||||
alias_attribute :lat, :latitude
|
||||
|
||||
def center = [latitude.to_f, longitude.to_f]
|
||||
end
|
||||
|
||||
@@ -12,6 +12,9 @@ module Nearable
|
||||
}.freeze
|
||||
|
||||
class_methods do
|
||||
# It accepts an array of coordinates [latitude, longitude]
|
||||
# and an optional radius and distance unit
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def near(*args)
|
||||
latitude, longitude, radius, unit = extract_coordinates_and_options(*args)
|
||||
|
||||
22
app/models/concerns/point_validation.rb
Normal file
22
app/models/concerns/point_validation.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PointValidation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Check if a point with the same coordinates, timestamp, and user_id already exists
|
||||
def point_exists?(params, user_id)
|
||||
# Ensure the coordinates are valid
|
||||
longitude = params[:longitude].to_f
|
||||
latitude = params[:latitude].to_f
|
||||
|
||||
# Check if longitude and latitude are valid values
|
||||
return false if longitude.zero? && latitude.zero?
|
||||
return false if longitude.abs > 180 || latitude.abs > 90
|
||||
|
||||
# Use where with parameter binding and then exists?
|
||||
Point.where(
|
||||
'ST_SetSRID(ST_MakePoint(?, ?), 4326) = lonlat AND timestamp = ? AND user_id = ?',
|
||||
longitude, latitude, params[:timestamp].to_i, user_id
|
||||
).exists?
|
||||
end
|
||||
end
|
||||
@@ -1,17 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Place < ApplicationRecord
|
||||
DEFAULT_NAME = 'Suggested place'
|
||||
reverse_geocoded_by :latitude, :longitude
|
||||
include Nearable
|
||||
include Distanceable
|
||||
|
||||
validates :name, :longitude, :latitude, presence: true
|
||||
DEFAULT_NAME = 'Suggested place'
|
||||
|
||||
validates :name, :lonlat, presence: true
|
||||
|
||||
has_many :visits, dependent: :destroy
|
||||
has_many :place_visits, dependent: :destroy
|
||||
has_many :suggested_visits, through: :place_visits, source: :visit
|
||||
has_many :suggested_visits, -> { distinct }, through: :place_visits, source: :visit
|
||||
|
||||
enum :source, { manual: 0, photon: 1 }
|
||||
|
||||
def lon
|
||||
lonlat.x
|
||||
end
|
||||
|
||||
def lat
|
||||
lonlat.y
|
||||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
return unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
@@ -21,4 +31,20 @@ class Place < ApplicationRecord
|
||||
def reverse_geocoded?
|
||||
geodata.present?
|
||||
end
|
||||
|
||||
def osm_id
|
||||
geodata['properties']['osm_id']
|
||||
end
|
||||
|
||||
def osm_key
|
||||
geodata['properties']['osm_key']
|
||||
end
|
||||
|
||||
def osm_value
|
||||
geodata['properties']['osm_value']
|
||||
end
|
||||
|
||||
def osm_type
|
||||
geodata['properties']['osm_type']
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,7 @@ class Point < ApplicationRecord
|
||||
end
|
||||
|
||||
def recorded_at
|
||||
Time.zone.at(timestamp)
|
||||
@recorded_at ||= Time.zone.at(timestamp)
|
||||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
|
||||
@@ -16,6 +16,7 @@ class User < ApplicationRecord
|
||||
has_many :trips, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
after_create :import_sample_points
|
||||
after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? }
|
||||
before_save :sanitize_input
|
||||
|
||||
@@ -128,4 +129,22 @@ class User < ApplicationRecord
|
||||
settings['photoprism_url']&.gsub!(%r{/+\z}, '')
|
||||
settings.try(:[], 'maps')&.try(:[], 'url')&.strip!
|
||||
end
|
||||
|
||||
def import_sample_points
|
||||
return unless Rails.env.development? ||
|
||||
Rails.env.production? ||
|
||||
(Rails.env.test? && ENV['IMPORT_SAMPLE_POINTS'])
|
||||
|
||||
raw_data = Hash.from_xml(
|
||||
File.read(Rails.root.join('lib/assets/sample_points.gpx'))
|
||||
)
|
||||
|
||||
import = imports.create(
|
||||
name: 'DELETE_ME_this_is_a_demo_import_DELETE_ME',
|
||||
source: 'gpx',
|
||||
raw_data:
|
||||
)
|
||||
|
||||
ImportJob.perform_later(id, import.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,7 +36,23 @@ class Visit < ApplicationRecord
|
||||
end
|
||||
|
||||
def center
|
||||
area.present? ? area.to_coordinates : place.to_coordinates
|
||||
if area.present?
|
||||
[area.lat, area.lon]
|
||||
elsif place.present?
|
||||
[place.lat, place.lon]
|
||||
else
|
||||
center_from_points
|
||||
end
|
||||
end
|
||||
|
||||
def center_from_points
|
||||
return [0, 0] if points.empty?
|
||||
|
||||
lat_sum = points.sum(&:lat)
|
||||
lon_sum = points.sum(&:lon)
|
||||
count = points.size.to_f
|
||||
|
||||
[lat_sum / count, lon_sum / count]
|
||||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
|
||||
25
app/serializers/api/place_serializer.rb
Normal file
25
app/serializers/api/place_serializer.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::PlaceSerializer
|
||||
def initialize(place)
|
||||
@place = place
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
longitude: place.lon,
|
||||
latitude: place.lat,
|
||||
city: place.city,
|
||||
country: place.country,
|
||||
source: place.source,
|
||||
geodata: place.geodata,
|
||||
reverse_geocoded_at: place.reverse_geocoded_at
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :place
|
||||
end
|
||||
29
app/serializers/api/visit_serializer.rb
Normal file
29
app/serializers/api/visit_serializer.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::VisitSerializer
|
||||
def initialize(visit)
|
||||
@visit = visit
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
id: visit.id,
|
||||
area_id: visit.area_id,
|
||||
user_id: visit.user_id,
|
||||
started_at: visit.started_at,
|
||||
ended_at: visit.ended_at,
|
||||
duration: visit.duration,
|
||||
name: visit.name,
|
||||
status: visit.status,
|
||||
place: {
|
||||
latitude: visit.place&.lat || visit.area&.latitude,
|
||||
longitude: visit.place&.lon || visit.area&.longitude,
|
||||
id: visit.place&.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :visit
|
||||
end
|
||||
@@ -14,8 +14,7 @@ class Points::Params
|
||||
next unless params_valid?(point)
|
||||
|
||||
{
|
||||
latitude: point[:geometry][:coordinates][1],
|
||||
longitude: point[:geometry][:coordinates][0],
|
||||
lonlat: lonlat(point),
|
||||
battery_status: point[:properties][:battery_state],
|
||||
battery: battery_level(point[:properties][:battery_level]),
|
||||
timestamp: DateTime.parse(point[:properties][:timestamp]),
|
||||
@@ -46,4 +45,8 @@ class Points::Params
|
||||
point[:geometry][:coordinates].present? &&
|
||||
point.dig(:properties, :timestamp).present?
|
||||
end
|
||||
|
||||
def lonlat(point)
|
||||
"POINT(#{point[:geometry][:coordinates][0]} #{point[:geometry][:coordinates][1]})"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,6 @@ class ReverseGeocoding::Places::FetchData
|
||||
|
||||
first_place = reverse_geocoded_places.shift
|
||||
update_place(first_place)
|
||||
add_suggested_place_to_a_visit
|
||||
reverse_geocoded_places.each { |reverse_geocoded_place| fetch_and_create_place(reverse_geocoded_place) }
|
||||
end
|
||||
|
||||
@@ -32,8 +31,7 @@ class ReverseGeocoding::Places::FetchData
|
||||
|
||||
place.update!(
|
||||
name: place_name(data),
|
||||
latitude: data['geometry']['coordinates'][1],
|
||||
longitude: data['geometry']['coordinates'][0],
|
||||
lonlat: "POINT(#{data['geometry']['coordinates'][0]} #{data['geometry']['coordinates'][1]})",
|
||||
city: data['properties']['city'],
|
||||
country: data['properties']['country'],
|
||||
geodata: data,
|
||||
@@ -53,24 +51,12 @@ class ReverseGeocoding::Places::FetchData
|
||||
new_place.source = :photon
|
||||
|
||||
new_place.save!
|
||||
|
||||
add_suggested_place_to_a_visit(suggested_place: new_place)
|
||||
end
|
||||
|
||||
def reverse_geocoded?
|
||||
place.geodata.present?
|
||||
end
|
||||
|
||||
def add_suggested_place_to_a_visit(suggested_place: place)
|
||||
visits = Place.near([suggested_place.latitude, suggested_place.longitude], 0.1).flat_map(&:visits)
|
||||
|
||||
visits.each do |visit|
|
||||
next if visit.suggested_places.include?(suggested_place)
|
||||
|
||||
visit.suggested_places << suggested_place
|
||||
end
|
||||
end
|
||||
|
||||
def find_place(place_data)
|
||||
found_place = Place.where(
|
||||
"geodata->'properties'->>'osm_id' = ?", place_data['properties']['osm_id'].to_s
|
||||
@@ -79,6 +65,7 @@ class ReverseGeocoding::Places::FetchData
|
||||
return found_place if found_place.present?
|
||||
|
||||
Place.find_or_initialize_by(
|
||||
lonlat: "POINT(#{place_data['geometry']['coordinates'][0].to_f.round(5)} #{place_data['geometry']['coordinates'][1].to_f.round(5)})",
|
||||
latitude: place_data['geometry']['coordinates'][1].to_f.round(5),
|
||||
longitude: place_data['geometry']['coordinates'][0].to_f.round(5)
|
||||
)
|
||||
@@ -97,11 +84,11 @@ class ReverseGeocoding::Places::FetchData
|
||||
|
||||
def reverse_geocoded_places
|
||||
data = Geocoder.search(
|
||||
[place.latitude, place.longitude],
|
||||
[place.lat, place.lon],
|
||||
limit: 10,
|
||||
distance_sort: true,
|
||||
radius: 1,
|
||||
units: DISTANCE_UNITS
|
||||
units: ::DISTANCE_UNIT,
|
||||
)
|
||||
|
||||
data.reject do |place|
|
||||
|
||||
49
app/services/visits/bulk_update.rb
Normal file
49
app/services/visits/bulk_update.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class BulkUpdate
|
||||
attr_reader :user, :visit_ids, :status, :errors
|
||||
|
||||
def initialize(user, visit_ids, status)
|
||||
@user = user
|
||||
@visit_ids = visit_ids
|
||||
@status = status
|
||||
@errors = []
|
||||
end
|
||||
|
||||
def call
|
||||
validate
|
||||
return false if errors.any?
|
||||
|
||||
update_visits
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate
|
||||
if visit_ids.blank?
|
||||
errors << 'No visits selected'
|
||||
return
|
||||
end
|
||||
|
||||
return if Visit.statuses.keys.include?(status)
|
||||
|
||||
errors << 'Invalid status'
|
||||
end
|
||||
|
||||
def update_visits
|
||||
visits = user.visits.where(id: visit_ids)
|
||||
|
||||
if visits.empty?
|
||||
errors << 'No matching visits found'
|
||||
return false
|
||||
end
|
||||
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
updated_count = visits.update_all(status: status)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
|
||||
{ count: updated_count, visits: visits }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,92 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Visits::Calculate
|
||||
def initialize(points)
|
||||
@points = points
|
||||
end
|
||||
|
||||
def call
|
||||
# Only one visit per city per day
|
||||
normalized_visits.flat_map do |country|
|
||||
{
|
||||
country: country[:country],
|
||||
cities: country[:cities].uniq { [_1[:city], Time.zone.at(_1[:timestamp]).to_date] }
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def normalized_visits
|
||||
normalize_result(city_visits)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :points
|
||||
|
||||
def group_points
|
||||
points.sort_by(&:timestamp).reject { _1.city.nil? }.group_by(&:country)
|
||||
end
|
||||
|
||||
def city_visits
|
||||
group_points.transform_values do |grouped_points|
|
||||
grouped_points
|
||||
.group_by(&:city)
|
||||
.transform_values { |city_points| identify_consecutive_visits(city_points) }
|
||||
end
|
||||
end
|
||||
|
||||
def identify_consecutive_visits(city_points)
|
||||
visits = []
|
||||
current_visit = []
|
||||
|
||||
city_points.each_cons(2) do |point1, point2|
|
||||
time_diff = (point2.timestamp - point1.timestamp) / 60
|
||||
|
||||
if time_diff <= MIN_MINUTES_SPENT_IN_CITY
|
||||
current_visit << point1 unless current_visit.include?(point1)
|
||||
current_visit << point2
|
||||
else
|
||||
visits << create_visit(current_visit) if current_visit.size > 1
|
||||
current_visit = []
|
||||
end
|
||||
end
|
||||
|
||||
visits << create_visit(current_visit) if current_visit.size > 1
|
||||
visits
|
||||
end
|
||||
|
||||
def create_visit(points)
|
||||
{
|
||||
city: points.first.city,
|
||||
points:,
|
||||
stayed_for: calculate_stayed_time(points),
|
||||
last_timestamp: points.last.timestamp
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_stayed_time(points)
|
||||
return 0 if points.empty?
|
||||
|
||||
min_time = points.first.timestamp
|
||||
max_time = points.last.timestamp
|
||||
((max_time - min_time) / 60).round
|
||||
end
|
||||
|
||||
def normalize_result(hash)
|
||||
hash.map do |country, cities|
|
||||
{
|
||||
country:,
|
||||
cities: cities.values.flatten
|
||||
.select { |visit| visit[:stayed_for] >= MIN_MINUTES_SPENT_IN_CITY }
|
||||
.map do |visit|
|
||||
{
|
||||
city: visit[:city],
|
||||
points: visit[:points].count,
|
||||
timestamp: visit[:last_timestamp],
|
||||
stayed_for: visit[:stayed_for]
|
||||
}
|
||||
end
|
||||
}
|
||||
end.reject { |entry| entry[:cities].empty? }
|
||||
end
|
||||
end
|
||||
96
app/services/visits/creator.rb
Normal file
96
app/services/visits/creator.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Creates visit records from detected visit data
|
||||
class Creator
|
||||
attr_reader :user
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def create_visits(visits)
|
||||
visits.map do |visit_data|
|
||||
# Variables to store data outside the transaction
|
||||
visit_instance = nil
|
||||
place_data = nil
|
||||
|
||||
# First transaction to create the visit
|
||||
ActiveRecord::Base.transaction do
|
||||
# Try to find matching area or place
|
||||
area = find_matching_area(visit_data)
|
||||
|
||||
# Only find/create place if no area was found
|
||||
place_data = PlaceFinder.new(user).find_or_create_place(visit_data) unless area
|
||||
|
||||
main_place = place_data&.dig(:main_place)
|
||||
|
||||
visit_instance = Visit.create!(
|
||||
user: user,
|
||||
area: area,
|
||||
place: main_place,
|
||||
started_at: Time.zone.at(visit_data[:start_time]),
|
||||
ended_at: Time.zone.at(visit_data[:end_time]),
|
||||
duration: visit_data[:duration] / 60, # Convert to minutes
|
||||
name: generate_visit_name(area, main_place, visit_data[:suggested_name]),
|
||||
status: :suggested
|
||||
)
|
||||
|
||||
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit_instance.id)
|
||||
end
|
||||
|
||||
# Associate suggested places outside the main transaction
|
||||
# to avoid deadlocks when multiple processes run simultaneously
|
||||
if place_data&.dig(:suggested_places).present?
|
||||
associate_suggested_places(visit_instance, place_data[:suggested_places])
|
||||
end
|
||||
|
||||
visit_instance
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Create place_visits records directly to avoid deadlocks
|
||||
def associate_suggested_places(visit, suggested_places)
|
||||
existing_place_ids = visit.place_visits.pluck(:place_id)
|
||||
|
||||
# Only create associations that don't already exist
|
||||
place_ids_to_add = suggested_places.map(&:id) - existing_place_ids
|
||||
|
||||
# Skip if there's nothing to add
|
||||
return if place_ids_to_add.empty?
|
||||
|
||||
# Batch create place_visit records
|
||||
place_visits_attrs = place_ids_to_add.map do |place_id|
|
||||
{ visit_id: visit.id, place_id: place_id, created_at: Time.current, updated_at: Time.current }
|
||||
end
|
||||
|
||||
# Use insert_all for efficient bulk insertion without callbacks
|
||||
PlaceVisit.insert_all(place_visits_attrs) if place_visits_attrs.any?
|
||||
end
|
||||
|
||||
def find_matching_area(visit_data)
|
||||
user.areas.find do |area|
|
||||
near_area?([visit_data[:center_lat], visit_data[:center_lon]], area)
|
||||
end
|
||||
end
|
||||
|
||||
def near_area?(center, area)
|
||||
distance = Geocoder::Calculations.distance_between(
|
||||
center,
|
||||
[area.latitude, area.longitude],
|
||||
units: :km
|
||||
)
|
||||
distance * 1000 <= area.radius # Convert to meters
|
||||
end
|
||||
|
||||
def generate_visit_name(area, place, suggested_name)
|
||||
return area.name if area
|
||||
return place.name if place
|
||||
return suggested_name if suggested_name.present?
|
||||
|
||||
'Unknown Location'
|
||||
end
|
||||
end
|
||||
end
|
||||
158
app/services/visits/detector.rb
Normal file
158
app/services/visits/detector.rb
Normal file
@@ -0,0 +1,158 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Detects potential visits from a collection of tracked points
|
||||
class Detector
|
||||
MINIMUM_VISIT_DURATION = 3.minutes
|
||||
MAXIMUM_VISIT_GAP = 30.minutes
|
||||
MINIMUM_POINTS_FOR_VISIT = 2
|
||||
|
||||
attr_reader :points
|
||||
|
||||
def initialize(points)
|
||||
@points = points
|
||||
end
|
||||
|
||||
def detect_potential_visits
|
||||
visits = []
|
||||
current_visit = nil
|
||||
|
||||
points.each do |point|
|
||||
if current_visit.nil?
|
||||
current_visit = initialize_visit(point)
|
||||
next
|
||||
end
|
||||
|
||||
if belongs_to_current_visit?(point, current_visit)
|
||||
current_visit[:points] << point
|
||||
current_visit[:end_time] = point.timestamp
|
||||
else
|
||||
visits << finalize_visit(current_visit) if valid_visit?(current_visit)
|
||||
current_visit = initialize_visit(point)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle the last visit
|
||||
visits << finalize_visit(current_visit) if current_visit && valid_visit?(current_visit)
|
||||
|
||||
visits
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def initialize_visit(point)
|
||||
{
|
||||
start_time: point.timestamp,
|
||||
end_time: point.timestamp,
|
||||
center_lat: point.lat,
|
||||
center_lon: point.lon,
|
||||
points: [point]
|
||||
}
|
||||
end
|
||||
|
||||
def belongs_to_current_visit?(point, visit)
|
||||
time_gap = point.timestamp - visit[:end_time]
|
||||
return false if time_gap > MAXIMUM_VISIT_GAP
|
||||
|
||||
# Calculate distance from visit center
|
||||
distance = Geocoder::Calculations.distance_between(
|
||||
[visit[:center_lat], visit[:center_lon]],
|
||||
[point.lat, point.lon],
|
||||
units: :km
|
||||
)
|
||||
|
||||
# Dynamically adjust radius based on visit duration
|
||||
max_radius = calculate_max_radius(visit[:end_time] - visit[:start_time])
|
||||
|
||||
distance <= max_radius
|
||||
end
|
||||
|
||||
def calculate_max_radius(duration_seconds)
|
||||
# Start with a small radius for short visits, increase for longer stays
|
||||
# but cap it at a reasonable maximum
|
||||
base_radius = 0.05 # 50 meters
|
||||
duration_hours = duration_seconds / 3600.0
|
||||
[base_radius * (1 + Math.log(1 + duration_hours)), 0.5].min # Cap at 500 meters
|
||||
end
|
||||
|
||||
def valid_visit?(visit)
|
||||
duration = visit[:end_time] - visit[:start_time]
|
||||
visit[:points].size >= MINIMUM_POINTS_FOR_VISIT && duration >= MINIMUM_VISIT_DURATION
|
||||
end
|
||||
|
||||
def finalize_visit(visit)
|
||||
points = visit[:points]
|
||||
center = calculate_center(points)
|
||||
|
||||
visit.merge(
|
||||
duration: visit[:end_time] - visit[:start_time],
|
||||
center_lat: center[0],
|
||||
center_lon: center[1],
|
||||
radius: calculate_visit_radius(points, center),
|
||||
suggested_name: suggest_place_name(points)
|
||||
)
|
||||
end
|
||||
|
||||
def calculate_center(points)
|
||||
lat_sum = points.sum(&:lat)
|
||||
lon_sum = points.sum(&:lon)
|
||||
count = points.size.to_f
|
||||
|
||||
[lat_sum / count, lon_sum / count]
|
||||
end
|
||||
|
||||
def calculate_visit_radius(points, center)
|
||||
max_distance = points.map do |point|
|
||||
Geocoder::Calculations.distance_between(center, [point.lat, point.lon], units: :km)
|
||||
end.max
|
||||
|
||||
# Convert to meters and ensure minimum radius
|
||||
[(max_distance * 1000), 15].max
|
||||
end
|
||||
|
||||
def suggest_place_name(points)
|
||||
# Get points with geodata
|
||||
geocoded_points = points.select { |p| p.geodata.present? && !p.geodata.empty? }
|
||||
return nil if geocoded_points.empty?
|
||||
|
||||
# Extract all features from points' geodata
|
||||
features = geocoded_points.flat_map do |point|
|
||||
next [] unless point.geodata['features'].is_a?(Array)
|
||||
|
||||
point.geodata['features']
|
||||
end.compact
|
||||
|
||||
return nil if features.empty?
|
||||
|
||||
# Group features by type and count occurrences
|
||||
feature_counts = features.group_by { |f| f.dig('properties', 'type') }
|
||||
.transform_values(&:size)
|
||||
|
||||
# Find the most common feature type
|
||||
most_common_type = feature_counts.max_by { |_, count| count }&.first
|
||||
return nil unless most_common_type
|
||||
|
||||
# Get all features of the most common type
|
||||
common_features = features.select { |f| f.dig('properties', 'type') == most_common_type }
|
||||
|
||||
# Group these features by name and get the most common one
|
||||
name_counts = common_features.group_by { |f| f.dig('properties', 'name') }
|
||||
.transform_values(&:size)
|
||||
most_common_name = name_counts.max_by { |_, count| count }&.first
|
||||
|
||||
return if most_common_name.blank?
|
||||
|
||||
# If we have a name, try to get additional context
|
||||
feature = common_features.find { |f| f.dig('properties', 'name') == most_common_name }
|
||||
properties = feature['properties']
|
||||
|
||||
# Build a more descriptive name if possible
|
||||
[
|
||||
most_common_name,
|
||||
properties['street'],
|
||||
properties['city'],
|
||||
properties['state']
|
||||
].compact.uniq.join(', ')
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/services/visits/find_in_time.rb
Normal file
31
app/services/visits/find_in_time.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class FindInTime
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@start_at = parse_time(params[:start_at])
|
||||
@end_at = parse_time(params[:end_at])
|
||||
end
|
||||
|
||||
def call
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user:)
|
||||
.where('started_at >= ? AND ended_at <= ?', start_at, end_at)
|
||||
.order(started_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :start_at, :end_at
|
||||
|
||||
def parse_time(time_string)
|
||||
parsed_time = Time.zone.parse(time_string)
|
||||
|
||||
raise ArgumentError, "Invalid time format: #{time_string}" if parsed_time.nil?
|
||||
|
||||
parsed_time
|
||||
end
|
||||
end
|
||||
end
|
||||
29
app/services/visits/find_within_bounding_box.rb
Normal file
29
app/services/visits/find_within_bounding_box.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Finds visits in a selected area on the map
|
||||
class FindWithinBoundingBox
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@sw_lat = params[:sw_lat].to_f
|
||||
@sw_lng = params[:sw_lng].to_f
|
||||
@ne_lat = params[:ne_lat].to_f
|
||||
@ne_lng = params[:ne_lng].to_f
|
||||
end
|
||||
|
||||
def call
|
||||
bounding_box = "ST_MakeEnvelope(#{sw_lng}, #{sw_lat}, #{ne_lng}, #{ne_lat}, 4326)"
|
||||
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user:)
|
||||
.joins(:place)
|
||||
.where("ST_Contains(#{bounding_box}, ST_SetSRID(places.lonlat::geometry, 4326))")
|
||||
.order(started_at: :desc)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :sw_lat, :sw_lng, :ne_lat, :ne_lng
|
||||
end
|
||||
end
|
||||
31
app/services/visits/finder.rb
Normal file
31
app/services/visits/finder.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Finds visits in a selected area on the map
|
||||
class Finder
|
||||
def initialize(user, params)
|
||||
@user = user
|
||||
@params = params
|
||||
end
|
||||
|
||||
def call
|
||||
if area_selected?
|
||||
Visits::FindWithinBoundingBox.new(user, params).call
|
||||
else
|
||||
Visits::FindInTime.new(user, params).call
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :user, :params
|
||||
|
||||
def area_selected?
|
||||
params[:selection] == 'true' &&
|
||||
params[:sw_lat].present? &&
|
||||
params[:sw_lng].present? &&
|
||||
params[:ne_lat].present? &&
|
||||
params[:ne_lng].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,58 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Visits::GroupPoints
|
||||
INITIAL_RADIUS = 30 # meters
|
||||
MAX_RADIUS = 100 # meters
|
||||
RADIUS_STEP = 10 # meters
|
||||
MIN_VISIT_DURATION = 3 * 60 # 3 minutes in seconds
|
||||
|
||||
attr_reader :day_points, :initial_radius, :max_radius, :step
|
||||
|
||||
def initialize(day_points, initial_radius = INITIAL_RADIUS, max_radius = MAX_RADIUS, step = RADIUS_STEP)
|
||||
@day_points = day_points
|
||||
@initial_radius = initial_radius
|
||||
@max_radius = max_radius
|
||||
@step = step
|
||||
end
|
||||
|
||||
def group_points_by_radius
|
||||
grouped = []
|
||||
remaining_points = day_points.dup
|
||||
|
||||
while remaining_points.any?
|
||||
point = remaining_points.shift
|
||||
radius = initial_radius
|
||||
|
||||
while radius <= max_radius
|
||||
new_group = [point]
|
||||
|
||||
remaining_points.each do |next_point|
|
||||
break unless within_radius?(new_group.first, next_point, radius)
|
||||
|
||||
new_group << next_point
|
||||
end
|
||||
|
||||
if new_group.size > 1
|
||||
group_duration = new_group.last.timestamp - new_group.first.timestamp
|
||||
|
||||
if group_duration >= MIN_VISIT_DURATION
|
||||
remaining_points -= new_group
|
||||
grouped << new_group
|
||||
end
|
||||
|
||||
break
|
||||
else
|
||||
radius += step
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
grouped
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def within_radius?(point1, point2, radius)
|
||||
point1.distance_to(point2) * 1000 <= radius
|
||||
end
|
||||
end
|
||||
76
app/services/visits/merge_service.rb
Normal file
76
app/services/visits/merge_service.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Service to handle merging multiple visits into one from the visits drawer
|
||||
class MergeService
|
||||
attr_reader :visits, :errors, :base_visit
|
||||
|
||||
def initialize(visits)
|
||||
@visits = visits
|
||||
@base_visit = visits.first
|
||||
@errors = []
|
||||
end
|
||||
|
||||
# Merges multiple visits into one
|
||||
# @return [Visit, nil] The merged visit or nil if merge failed
|
||||
def call
|
||||
return add_error('At least 2 visits must be selected for merging') if visits.length < 2
|
||||
|
||||
merge_visits
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def add_error(message)
|
||||
@errors << message
|
||||
nil
|
||||
end
|
||||
|
||||
def merge_visits
|
||||
Visit.transaction do
|
||||
update_base_visit(base_visit)
|
||||
reassign_points(base_visit, visits)
|
||||
|
||||
visits.drop(1).each(&:destroy!)
|
||||
|
||||
base_visit
|
||||
end
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error("Failed to merge visits: #{e.message}")
|
||||
add_error(e.record.errors.full_messages.join(', '))
|
||||
nil
|
||||
end
|
||||
|
||||
def prepare_base_visit
|
||||
earliest_start = visits.min_by(&:started_at).started_at
|
||||
latest_end = visits.max_by(&:ended_at).ended_at
|
||||
total_duration = ((latest_end - earliest_start) / 60).round
|
||||
combined_name = "Combined Visit (#{visits.map(&:name).join(', ')})"
|
||||
|
||||
{
|
||||
earliest_start:,
|
||||
latest_end:,
|
||||
total_duration:,
|
||||
combined_name:
|
||||
}
|
||||
end
|
||||
|
||||
def update_base_visit(base_visit)
|
||||
base_visit_data = prepare_base_visit
|
||||
|
||||
base_visit.update!(
|
||||
started_at: base_visit_data[:earliest_start],
|
||||
ended_at: base_visit_data[:latest_end],
|
||||
duration: base_visit_data[:total_duration],
|
||||
name: base_visit_data[:combined_name],
|
||||
status: 'confirmed'
|
||||
)
|
||||
end
|
||||
|
||||
def reassign_points(base_visit, visits)
|
||||
visits[1..].each do |visit|
|
||||
visit.points.update_all(visit_id: base_visit.id) # rubocop:disable Rails/SkipsModelValidations
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
83
app/services/visits/merger.rb
Normal file
83
app/services/visits/merger.rb
Normal file
@@ -0,0 +1,83 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Merges consecutive visits that are likely part of the same stay
|
||||
class Merger
|
||||
MAXIMUM_VISIT_GAP = 30.minutes
|
||||
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
|
||||
|
||||
attr_reader :points
|
||||
|
||||
def initialize(points)
|
||||
@points = points
|
||||
end
|
||||
|
||||
def merge_visits(visits)
|
||||
return visits if visits.empty?
|
||||
|
||||
merged = []
|
||||
current_merged = visits.first
|
||||
|
||||
visits[1..].each do |visit|
|
||||
if can_merge_visits?(current_merged, visit)
|
||||
# Merge the visits
|
||||
current_merged[:end_time] = visit[:end_time]
|
||||
current_merged[:points].concat(visit[:points])
|
||||
else
|
||||
merged << current_merged
|
||||
current_merged = visit
|
||||
end
|
||||
end
|
||||
|
||||
merged << current_merged
|
||||
merged
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_merge_visits?(first_visit, second_visit)
|
||||
return false unless same_location?(first_visit, second_visit)
|
||||
return false if gap_too_large?(first_visit, second_visit)
|
||||
return false if significant_movement_between?(first_visit, second_visit)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def same_location?(first_visit, second_visit)
|
||||
distance = Geocoder::Calculations.distance_between(
|
||||
[first_visit[:center_lat], first_visit[:center_lon]],
|
||||
[second_visit[:center_lat], second_visit[:center_lon]],
|
||||
units: :km
|
||||
)
|
||||
|
||||
# Convert to meters and check if within threshold
|
||||
(distance * 1000) <= SIGNIFICANT_MOVEMENT_THRESHOLD
|
||||
end
|
||||
|
||||
def gap_too_large?(first_visit, second_visit)
|
||||
gap = second_visit[:start_time] - first_visit[:end_time]
|
||||
gap > MAXIMUM_VISIT_GAP
|
||||
end
|
||||
|
||||
def significant_movement_between?(first_visit, second_visit)
|
||||
# Get points between the two visits
|
||||
between_points = points.where(
|
||||
timestamp: (first_visit[:end_time] + 1)..(second_visit[:start_time] - 1)
|
||||
)
|
||||
|
||||
return false if between_points.empty?
|
||||
|
||||
visit_center = [first_visit[:center_lat], first_visit[:center_lon]]
|
||||
max_distance = between_points.map do |point|
|
||||
Geocoder::Calculations.distance_between(
|
||||
visit_center,
|
||||
[point.lat, point.lon],
|
||||
units: :km
|
||||
)
|
||||
end.max
|
||||
|
||||
# Convert to meters and check if exceeds threshold
|
||||
(max_distance * 1000) > SIGNIFICANT_MOVEMENT_THRESHOLD
|
||||
end
|
||||
end
|
||||
end
|
||||
246
app/services/visits/place_finder.rb
Normal file
246
app/services/visits/place_finder.rb
Normal file
@@ -0,0 +1,246 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Finds or creates places for visits
|
||||
class PlaceFinder
|
||||
attr_reader :user
|
||||
|
||||
SEARCH_RADIUS = 100 # meters
|
||||
SIMILARITY_RADIUS = 50 # meters
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def find_or_create_place(visit_data)
|
||||
lat = visit_data[:center_lat]
|
||||
lon = visit_data[:center_lon]
|
||||
|
||||
# First check if there's an existing place
|
||||
existing_place = find_existing_place(lat, lon, visit_data[:suggested_name])
|
||||
|
||||
# If we found an exact match, return it
|
||||
if existing_place
|
||||
return {
|
||||
main_place: existing_place,
|
||||
suggested_places: find_suggested_places(lat, lon)
|
||||
}
|
||||
end
|
||||
|
||||
# Get potential places from all sources
|
||||
potential_places = collect_potential_places(visit_data)
|
||||
|
||||
# Find or create the main place
|
||||
main_place = select_or_create_main_place(potential_places, lat, lon, visit_data[:suggested_name])
|
||||
|
||||
# Get suggested places including our main place
|
||||
all_suggested_places = potential_places.presence || [main_place]
|
||||
|
||||
{
|
||||
main_place: main_place,
|
||||
suggested_places: all_suggested_places.uniq(&:name)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Step 1: Find existing place
|
||||
def find_existing_place(lat, lon, name)
|
||||
# Try to find existing place by location first
|
||||
existing_by_location = Place.near([lat, lon], SIMILARITY_RADIUS, :m).first
|
||||
return existing_by_location if existing_by_location
|
||||
|
||||
# Then try by name if available
|
||||
return nil unless name.present?
|
||||
|
||||
Place.where(name: name)
|
||||
.near([lat, lon], SEARCH_RADIUS, :m)
|
||||
.first
|
||||
end
|
||||
|
||||
# Step 2: Collect potential places from all sources
|
||||
def collect_potential_places(visit_data)
|
||||
lat = visit_data[:center_lat]
|
||||
lon = visit_data[:center_lon]
|
||||
|
||||
# Get places from points' geodata
|
||||
places_from_points = extract_places_from_points(visit_data[:points], lat, lon)
|
||||
|
||||
# Get places from external API
|
||||
places_from_api = fetch_places_from_api(lat, lon)
|
||||
|
||||
# Combine and deduplicate by name
|
||||
combined_places = []
|
||||
|
||||
# Add API places first (usually better quality)
|
||||
places_from_api.each do |api_place|
|
||||
combined_places << api_place unless place_name_exists?(combined_places, api_place.name)
|
||||
end
|
||||
|
||||
# Add places from points if name doesn't already exist
|
||||
places_from_points.each do |point_place|
|
||||
combined_places << point_place unless place_name_exists?(combined_places, point_place.name)
|
||||
end
|
||||
|
||||
combined_places
|
||||
end
|
||||
|
||||
# Step 3: Extract places from points
|
||||
def extract_places_from_points(points, center_lat, center_lon)
|
||||
return [] if points.blank?
|
||||
|
||||
# Filter points with geodata
|
||||
points_with_geodata = points.select { |point| point.geodata.present? }
|
||||
return [] if points_with_geodata.empty?
|
||||
|
||||
# Process each point to create or find places
|
||||
places = []
|
||||
|
||||
points_with_geodata.each do |point|
|
||||
place = create_place_from_point(point)
|
||||
places << place if place
|
||||
end
|
||||
|
||||
places.uniq { |place| place.name }
|
||||
end
|
||||
|
||||
# Step 4: Create place from point
|
||||
def create_place_from_point(point)
|
||||
return nil unless point.geodata.is_a?(Hash)
|
||||
|
||||
properties = point.geodata['properties'] || {}
|
||||
return nil if properties.blank?
|
||||
|
||||
# Get or build a name
|
||||
name = build_place_name(properties)
|
||||
return nil if name == Place::DEFAULT_NAME
|
||||
|
||||
# Look for existing place with this name
|
||||
existing = Place.where(name: name)
|
||||
.near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m)
|
||||
.first
|
||||
|
||||
return existing if existing
|
||||
|
||||
# Create new place
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{point.longitude} #{point.latitude})",
|
||||
latitude: point.latitude,
|
||||
longitude: point.longitude,
|
||||
city: properties['city'],
|
||||
country: properties['country'],
|
||||
geodata: point.geodata,
|
||||
source: :photon
|
||||
)
|
||||
|
||||
place.save!
|
||||
place
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
|
||||
# Step 5: Fetch places from API
|
||||
def fetch_places_from_api(lat, lon)
|
||||
# Get broader search results from Geocoder
|
||||
geocoder_results = Geocoder.search([lat, lon], units: :km, limit: 20, distance_sort: true)
|
||||
return [] if geocoder_results.blank?
|
||||
|
||||
places = []
|
||||
|
||||
geocoder_results.each do |result|
|
||||
place = create_place_from_api_result(result)
|
||||
places << place if place
|
||||
end
|
||||
|
||||
places
|
||||
end
|
||||
|
||||
# Step 6: Create place from API result
|
||||
def create_place_from_api_result(result)
|
||||
return nil unless result && result.data.is_a?(Hash)
|
||||
|
||||
properties = result.data['properties'] || {}
|
||||
return nil if properties.blank?
|
||||
|
||||
# Get or build a name
|
||||
name = build_place_name(properties)
|
||||
return nil if name == Place::DEFAULT_NAME
|
||||
|
||||
# Look for existing place with this name
|
||||
existing = Place.where(name: name)
|
||||
.near([result.latitude, result.longitude], SIMILARITY_RADIUS, :m)
|
||||
.first
|
||||
|
||||
return existing if existing
|
||||
|
||||
# Create new place
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{result.longitude} #{result.latitude})",
|
||||
latitude: result.latitude,
|
||||
longitude: result.longitude,
|
||||
city: properties['city'],
|
||||
country: properties['country'],
|
||||
geodata: result.data,
|
||||
source: :photon
|
||||
)
|
||||
|
||||
place.save!
|
||||
place
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
nil
|
||||
end
|
||||
|
||||
# Step 7: Select or create main place
|
||||
def select_or_create_main_place(potential_places, lat, lon, suggested_name)
|
||||
return create_default_place(lat, lon, suggested_name) if potential_places.blank?
|
||||
|
||||
# Choose the closest place as the main one
|
||||
sorted_places = potential_places.sort_by do |place|
|
||||
place.distance_to([lat, lon], :m)
|
||||
end
|
||||
|
||||
sorted_places.first
|
||||
end
|
||||
|
||||
# Step 8: Create default place when no other options
|
||||
def create_default_place(lat, lon, suggested_name)
|
||||
name = suggested_name.presence || Place::DEFAULT_NAME
|
||||
|
||||
place = Place.new(
|
||||
name: name,
|
||||
lonlat: "POINT(#{lon} #{lat})",
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
source: :manual
|
||||
)
|
||||
|
||||
place.save!
|
||||
place
|
||||
end
|
||||
|
||||
# Step 9: Find suggested places
|
||||
def find_suggested_places(lat, lon)
|
||||
Place.near([lat, lon], SEARCH_RADIUS, :m).with_distance([lat, lon], :m)
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
|
||||
def build_place_name(properties)
|
||||
name_components = [
|
||||
properties['name'],
|
||||
properties['street'],
|
||||
properties['housenumber'],
|
||||
properties['postcode'],
|
||||
properties['city']
|
||||
].compact.reject(&:empty?).uniq
|
||||
|
||||
name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
|
||||
end
|
||||
|
||||
def place_name_exists?(places, name)
|
||||
places.any? { |place| place.name == name }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,80 +0,0 @@
|
||||
# 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)
|
||||
|
||||
# Iterate through the day_result, check if there are any points outside
|
||||
# of visits that are between two consecutive visits. If there are none,
|
||||
# merge the visits.
|
||||
|
||||
day_result.each_cons(2) do |visit1, visit2|
|
||||
next if visit1[:points].last == visit2[:points].first
|
||||
|
||||
points_between_visits = day_points.select do |point|
|
||||
point.timestamp > visit1[:points].last.timestamp &&
|
||||
point.timestamp < visit2[:points].first.timestamp
|
||||
end
|
||||
|
||||
if points_between_visits.any?
|
||||
# If there are points between the visits, we need to check if they are close enough to the visits to be considered part of them.
|
||||
|
||||
points_between_visits.each do |point|
|
||||
next unless visit1[:points].last.distance_to(point) < visit1[:radius] ||
|
||||
visit2[:points].first.distance_to(point) < visit2[:radius] ||
|
||||
(point.timestamp - visit1[:points].last.timestamp).to_i < 600
|
||||
|
||||
visit1[:points] << point
|
||||
end
|
||||
end
|
||||
|
||||
visit1[:points] += visit2[:points]
|
||||
visit1[:duration] = (visit1[:points].last.timestamp - visit1[:points].first.timestamp).to_i / 60
|
||||
visit1[:ended_at] = Time.zone.at(visit1[:points].last.timestamp)
|
||||
day_result.delete(visit2)
|
||||
end
|
||||
|
||||
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.lat,
|
||||
longitude: center_point.lon,
|
||||
radius: calculate_radius(center_point, group),
|
||||
points: group,
|
||||
duration: (group.last.timestamp - group.first.timestamp).to_i / 60,
|
||||
started_at: Time.zone.at(group.first.timestamp).to_s,
|
||||
ended_at: Time.zone.at(group.last.timestamp).to_s
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
42
app/services/visits/smart_detect.rb
Normal file
42
app/services/visits/smart_detect.rb
Normal file
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Coordinates the process of detecting and creating visits from tracked points
|
||||
class SmartDetect
|
||||
MINIMUM_VISIT_DURATION = 3.minutes
|
||||
MAXIMUM_VISIT_GAP = 30.minutes
|
||||
MINIMUM_POINTS_FOR_VISIT = 3
|
||||
|
||||
attr_reader :user, :start_at, :end_at, :points
|
||||
|
||||
def initialize(user, start_at:, end_at:)
|
||||
@user = user
|
||||
@start_at = start_at.to_i
|
||||
@end_at = end_at.to_i
|
||||
@points = user.tracked_points.not_visited
|
||||
.order(timestamp: :asc)
|
||||
.where(timestamp: start_at..end_at)
|
||||
end
|
||||
|
||||
def call
|
||||
return [] if points.empty?
|
||||
|
||||
potential_visits = Visits::Detector.new(points).detect_potential_visits
|
||||
merged_visits = Visits::Merger.new(points).merge_visits(potential_visits)
|
||||
grouped_visits = group_nearby_visits(merged_visits).flatten
|
||||
|
||||
Visits::Creator.new(user).create_visits(grouped_visits)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def group_nearby_visits(visits)
|
||||
visits.group_by do |visit|
|
||||
[
|
||||
(visit[:center_lat] * 1000).round / 1000.0,
|
||||
(visit[:center_lon] * 1000).round / 1000.0
|
||||
]
|
||||
end.values
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,61 +13,24 @@ class Visits::Suggest
|
||||
end
|
||||
|
||||
def call
|
||||
prepared_visits = Visits::Prepare.new(points).call
|
||||
|
||||
visited_places = create_places(prepared_visits)
|
||||
visits = create_visits(visited_places)
|
||||
|
||||
create_visits_notification(user)
|
||||
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
|
||||
create_visits_notification(user) if visits.any?
|
||||
|
||||
return nil unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
reverse_geocode(visits)
|
||||
visits.each(&:async_reverse_geocode)
|
||||
visits
|
||||
rescue StandardError => e
|
||||
# create a notification with stacktrace and what arguments were used
|
||||
user.notifications.create!(
|
||||
kind: :error,
|
||||
title: 'Error suggesting visits',
|
||||
content: "Error suggesting visits: #{e.message}\n#{e.backtrace.join("\n")}"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_places(prepared_visits)
|
||||
prepared_visits.flat_map do |date|
|
||||
date[:visits] = handle_visits(date[:visits])
|
||||
|
||||
date
|
||||
end
|
||||
end
|
||||
|
||||
def create_visits(visited_places)
|
||||
visited_places.flat_map do |date|
|
||||
date[:visits].map do |visit_data|
|
||||
ActiveRecord::Base.transaction do
|
||||
search_params = {
|
||||
user_id: user.id,
|
||||
duration: visit_data[:duration],
|
||||
started_at: Time.zone.at(visit_data[:points].first.timestamp)
|
||||
}
|
||||
|
||||
if visit_data[:area].present?
|
||||
search_params[:area_id] = visit_data[:area].id
|
||||
elsif visit_data[:place].present?
|
||||
search_params[:place_id] = visit_data[:place].id
|
||||
end
|
||||
|
||||
visit = Visit.find_or_initialize_by(search_params)
|
||||
visit.name = visit_data[:place]&.name || visit_data[:area]&.name if visit.name.blank?
|
||||
visit.ended_at = Time.zone.at(visit_data[:points].last.timestamp)
|
||||
visit.save!
|
||||
|
||||
visit_data[:points].each { |point| point.update!(visit_id: visit.id) }
|
||||
|
||||
visit
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def reverse_geocode(visits)
|
||||
visits.each(&:async_reverse_geocode)
|
||||
end
|
||||
|
||||
def 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 on the <a href="#{visits_path}" class="link">Visits</a> page.
|
||||
@@ -79,32 +42,4 @@ class Visits::Suggest
|
||||
content:
|
||||
)
|
||||
end
|
||||
|
||||
def create_place(visit)
|
||||
place = Place.find_or_initialize_by(
|
||||
latitude: visit[:latitude].to_f.round(5),
|
||||
longitude: visit[:longitude].to_f.round(5)
|
||||
)
|
||||
|
||||
place.name = Place::DEFAULT_NAME
|
||||
place.source = Place.sources[:manual]
|
||||
|
||||
place.save!
|
||||
|
||||
place
|
||||
end
|
||||
|
||||
def handle_visits(visits)
|
||||
visits.map do |visit|
|
||||
area = Area.near([visit[:latitude], visit[:longitude]], 0.100).first
|
||||
|
||||
if area.present?
|
||||
visit.merge(area:)
|
||||
else
|
||||
place = create_place(visit)
|
||||
|
||||
visit.merge(place:)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
47
app/services/visits/time_chunks.rb
Normal file
47
app/services/visits/time_chunks.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
class TimeChunks
|
||||
def initialize(start_at:, end_at:)
|
||||
@start_at = start_at
|
||||
@end_at = end_at
|
||||
@time_chunks = []
|
||||
end
|
||||
|
||||
def call
|
||||
# If the start date is in the future or equal to the end date,
|
||||
# handle as a special case extending to the end of the start's year
|
||||
# or if the start and end are in the same year, return the year chunk
|
||||
return [start_at..start_at.end_of_year] if start_in_future? || same_year?
|
||||
|
||||
# First chunk: from start_at to end of that year
|
||||
first_end = start_at.end_of_year
|
||||
time_chunks << (start_at...first_end)
|
||||
|
||||
# Full-year chunks
|
||||
current = first_end.beginning_of_year + 1.year # Start from the next full year
|
||||
while current.year < end_at.year
|
||||
year_end = current.end_of_year
|
||||
time_chunks << (current...year_end)
|
||||
current += 1.year
|
||||
end
|
||||
|
||||
# Last chunk: from start of the last year to end_at
|
||||
time_chunks << (current...end_at) if current.year == end_at.year
|
||||
|
||||
time_chunks
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :start_at, :end_at, :time_chunks
|
||||
|
||||
def start_in_future?
|
||||
start_at >= end_at
|
||||
end
|
||||
|
||||
def same_year?
|
||||
start_at.year == end_at.year
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -54,7 +54,7 @@
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>">
|
||||
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen">
|
||||
<div data-maps-target="container" class="h-[25rem] rounded-lg w-full min-h-screen z-0">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
<tr>
|
||||
<td><%= place.name %></td>
|
||||
<td><%= place.created_at.strftime('%Y-%m-%d %H:%M:%S') %></td>
|
||||
<td><%= place.to_coordinates.map(&:to_f) %></td>
|
||||
<td><%= "#{place.lat}, #{place.lon}" %></td>
|
||||
<td>
|
||||
<%= link_to 'Delete', place, data: { confirm: "Are you sure?", turbo_confirm: "Are you sure?", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
<%= link_to 'Delete', place, data: { confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_confirm: "Are you sure? Deleting a place will result in deleting all visits for this place.", turbo_method: :delete }, method: :delete, class: "px-4 py-2 bg-red-500 text-white rounded-md" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="fixed top-5 right-5 flex flex-col gap-2" id="flash-messages">
|
||||
<div class="fixed top-5 right-5 flex flex-col gap-2 z-50" id="flash-messages">
|
||||
<% flash.each do |key, value| %>
|
||||
<div data-controller="removals"
|
||||
data-removals-timeout-value="5000"
|
||||
|
||||
@@ -6,15 +6,22 @@
|
||||
</label>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<li>
|
||||
<details>
|
||||
<summary>My data</summary>
|
||||
<ul class="p-2 bg-base-100">
|
||||
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
<%= link_to 'Dawarich', root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
<div class="badge mx-4 <%= 'badge-outline' if new_version_available? %>">
|
||||
<a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
|
||||
<% if new_version_available? %>
|
||||
@@ -42,12 +49,19 @@
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
||||
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "mx-1 #{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "mx-1 #{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "mx-1 #{active_class?(exports_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
||||
<li>
|
||||
<details>
|
||||
<summary>My data</summary>
|
||||
<ul class="p-2 bg-base-100 rounded-box shadow-md z-10">
|
||||
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
|
||||
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "mx-1 #{active_class?(visits_url)}" %></li>
|
||||
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
|
||||
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="alert my-5">
|
||||
<div role="alert" class="alert mt-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
8VIWnwlEhKkoPvsVuDII3z+D/YN83w/iLZd69QIm4Z0FMrSt6LPidLsgnnW81Rx44Z+0al/MWDUNGpYKa4dCSy01g+Pjdez4BrNLR4qGlRXruAZkapI78/J9r1ynyGf9GRW7c+kimRngPTg/enInUlo8wGrW/P2KhKPqn1tcUzKl4pyy2eD+BELblrwG2k96FxA7NmR6NDvB1K9OlLpAHiA0AVxuSKlXweX/Q5lCZsAeWFN1tlieGJABeadG/AnpWT53vigyvdYyqGactxhh6kkFU+baNj0ELwrAqD3bjTD/haqgiH2ZqjlqjNxLVdJdcHUGqs6jS9MziwRouRo8AbYRZz++BH0ZHslkhdSWm68DH7xpLGL5MXTqBF6uHv8edcHleZM9ThfKsO68M7GADzHvsIBJYZEbeDPh--ggwrEpNtVdYbWPMO--NI2ABLK+rU+9YBFeVjWbEQ==
|
||||
@@ -81,8 +81,14 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[index create update destroy]
|
||||
resources :visits, only: %i[update]
|
||||
resources :stats, only: :index
|
||||
resources :visits, only: %i[index update] do
|
||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||
collection do
|
||||
post 'merge', to: 'visits#merge'
|
||||
post 'bulk_update', to: 'visits#bulk_update'
|
||||
end
|
||||
end
|
||||
resources :stats, only: :index
|
||||
|
||||
namespace :overland do
|
||||
resources :batches, only: :create
|
||||
|
||||
@@ -10,11 +10,10 @@ area_visits_calculation_scheduling_job:
|
||||
class: "AreaVisitsCalculationSchedulingJob"
|
||||
queue: visit_suggesting
|
||||
|
||||
# Disabled until fixed
|
||||
# visit_suggesting_job:
|
||||
# cron: "0 1 * * *" # every day at 1:00
|
||||
# class: "VisitSuggestingJob"
|
||||
# queue: visit_suggesting
|
||||
visit_suggesting_job:
|
||||
cron: "5 0 * * *" # every day at 00:05
|
||||
class: "BulkVisitsSuggestingJob"
|
||||
queue: visit_suggesting
|
||||
|
||||
watcher_job:
|
||||
cron: "0 */1 * * *" # every 1 hour
|
||||
|
||||
13
db/data/20250303194123_migrate_places_lonlat.rb
Normal file
13
db/data/20250303194123_migrate_places_lonlat.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MigratePlacesLonlat < ActiveRecord::Migration[8.0]
|
||||
def up
|
||||
User.find_each do |user|
|
||||
DataMigrations::MigratePlacesLonlatJob.perform_later(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
||||
7
db/migrate/20250303194009_add_lonlat_to_places.rb
Normal file
7
db/migrate/20250303194009_add_lonlat_to_places.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddLonlatToPlaces < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :places, :lonlat, :st_point, geographic: true
|
||||
end
|
||||
end
|
||||
9
db/migrate/20250303194043_add_lonlat_index_to_places.rb
Normal file
9
db/migrate/20250303194043_add_lonlat_index_to_places.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddLonlatIndexToPlaces < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :places, :lonlat, using: :gist, algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
5
db/schema.rb
generated
5
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_02_21_194509) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_03_03_194043) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
@@ -125,6 +125,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_21_194509) do
|
||||
t.datetime "reverse_geocoded_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.geography "lonlat", limit: {srid: 4326, type: "st_point", geographic: true}
|
||||
t.index "name, st_astext(lonlat)", name: "index_places_on_name_and_lonlat", unique: true
|
||||
t.index ["lonlat"], name: "index_places_on_lonlat", using: :gist
|
||||
end
|
||||
|
||||
create_table "points", force: :cascade do |t|
|
||||
|
||||
2589
lib/assets/sample_points.gpx
Normal file
2589
lib/assets/sample_points.gpx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,50 @@
|
||||
FactoryBot.define do
|
||||
factory :place do
|
||||
name { 'MyString' }
|
||||
latitude { 1.5 }
|
||||
longitude { 1.5 }
|
||||
latitude { 54.2905245 }
|
||||
longitude { 13.0948638 }
|
||||
lonlat { "SRID=4326;POINT(#{longitude} #{latitude})" }
|
||||
|
||||
trait :with_geodata do
|
||||
geodata do
|
||||
{
|
||||
"geometry": {
|
||||
"coordinates": [
|
||||
13.0948638,
|
||||
54.2905245
|
||||
],
|
||||
"type": 'Point'
|
||||
},
|
||||
"type": 'Feature',
|
||||
"properties": {
|
||||
"osm_id": 5_762_449_774,
|
||||
"country": 'Germany',
|
||||
"city": 'Stralsund',
|
||||
"countrycode": 'DE',
|
||||
"postcode": '18439',
|
||||
"locality": 'Frankensiedlung',
|
||||
"county": 'Vorpommern-Rügen',
|
||||
"type": 'house',
|
||||
"osm_type": 'N',
|
||||
"osm_key": 'amenity',
|
||||
"housenumber": '84-85',
|
||||
"street": 'Greifswalder Chaussee',
|
||||
"district": 'Franken',
|
||||
"osm_value": 'restaurant',
|
||||
"name": 'Braugasthaus Zum Alten Fritz',
|
||||
"state": 'Mecklenburg-Vorpommern'
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Special trait for testing with nil lonlat
|
||||
trait :without_lonlat do
|
||||
# Skip validation to create an invalid record for testing
|
||||
to_create { |instance| instance.save(validate: false) }
|
||||
after(:build) do |place|
|
||||
place.lonlat = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
106
spec/jobs/bulk_visits_suggesting_job_spec.rb
Normal file
106
spec/jobs/bulk_visits_suggesting_job_spec.rb
Normal file
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe BulkVisitsSuggestingJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:start_at) { 1.day.ago.beginning_of_day }
|
||||
let(:end_at) { 1.day.ago.end_of_day }
|
||||
let(:user) { create(:user) }
|
||||
let(:inactive_user) { create(:user, status: :inactive) }
|
||||
let(:user_with_points) { create(:user) }
|
||||
let(:time_chunks) { [[start_at, end_at]] }
|
||||
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true)
|
||||
allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(time_chunks)
|
||||
create(:point, user: user_with_points)
|
||||
end
|
||||
|
||||
it 'does nothing if reverse geocoding is disabled' do
|
||||
allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(false)
|
||||
|
||||
expect(VisitSuggestingJob).not_to receive(:perform_later)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'schedules jobs only for active users with tracked points' do
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
start_at: time_chunks.first.first,
|
||||
end_at: time_chunks.first.last
|
||||
)
|
||||
|
||||
expect(VisitSuggestingJob).not_to receive(:perform_later).with(
|
||||
user_id: user.id,
|
||||
start_at: anything,
|
||||
end_at: anything
|
||||
)
|
||||
|
||||
expect(VisitSuggestingJob).not_to receive(:perform_later).with(
|
||||
user_id: inactive_user.id,
|
||||
start_at: anything,
|
||||
end_at: anything
|
||||
)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'handles multiple time chunks' do
|
||||
chunks = [
|
||||
[start_at, start_at + 12.hours],
|
||||
[start_at + 12.hours, end_at]
|
||||
]
|
||||
allow_any_instance_of(Visits::TimeChunks).to receive(:call).and_return(chunks)
|
||||
|
||||
chunks.each do |chunk|
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
start_at: chunk.first,
|
||||
end_at: chunk.last
|
||||
)
|
||||
end
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
|
||||
it 'only processes specified users when user_ids is provided' do
|
||||
create(:point, user: user)
|
||||
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user.id,
|
||||
start_at: time_chunks.first.first,
|
||||
end_at: time_chunks.first.last
|
||||
)
|
||||
|
||||
expect(VisitSuggestingJob).not_to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
start_at: anything,
|
||||
end_at: anything
|
||||
)
|
||||
|
||||
described_class.perform_now(user_ids: [user.id])
|
||||
end
|
||||
|
||||
it 'uses custom time range when provided' do
|
||||
custom_start = 2.days.ago.beginning_of_day
|
||||
custom_end = 2.days.ago.end_of_day
|
||||
custom_chunks = [[custom_start, custom_end]]
|
||||
|
||||
time_chunks_instance = instance_double(Visits::TimeChunks)
|
||||
allow(Visits::TimeChunks).to receive(:new)
|
||||
.with(start_at: custom_start, end_at: custom_end)
|
||||
.and_return(time_chunks_instance)
|
||||
allow(time_chunks_instance).to receive(:call).and_return(custom_chunks)
|
||||
|
||||
expect(VisitSuggestingJob).to receive(:perform_later).with(
|
||||
user_id: user_with_points.id,
|
||||
start_at: custom_chunks.first.first,
|
||||
end_at: custom_chunks.first.last
|
||||
)
|
||||
|
||||
described_class.perform_now(start_at: custom_start, end_at: custom_end)
|
||||
end
|
||||
end
|
||||
end
|
||||
82
spec/jobs/data_migrations/migrate_places_lonlat_job_spec.rb
Normal file
82
spec/jobs/data_migrations/migrate_places_lonlat_job_spec.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe DataMigrations::MigratePlacesLonlatJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
context 'when places exist for the user' do
|
||||
let!(:place1) { create(:place, :without_lonlat, longitude: 10.0, latitude: 20.0) }
|
||||
let!(:place2) { create(:place, :without_lonlat, longitude: -73.935242, latitude: 40.730610) }
|
||||
let!(:other_place) { create(:place, :without_lonlat, longitude: 15.0, latitude: 25.0) }
|
||||
|
||||
# Create visits to associate places with users
|
||||
let!(:visit1) { create(:visit, user: user, place: place1) }
|
||||
let!(:visit2) { create(:visit, user: user, place: place2) }
|
||||
let!(:other_visit) { create(:visit, place: other_place) } # associated with a different user
|
||||
|
||||
it 'updates lonlat field for all places belonging to the user' do
|
||||
# Force a reload to ensure we have the initial state
|
||||
place1.reload
|
||||
place2.reload
|
||||
|
||||
# Both places should have nil lonlat initially
|
||||
expect(place1.lonlat).to be_nil
|
||||
expect(place2.lonlat).to be_nil
|
||||
|
||||
# Run the job
|
||||
described_class.perform_now(user.id)
|
||||
|
||||
# Reload to get updated state
|
||||
place1.reload
|
||||
place2.reload
|
||||
other_place.reload
|
||||
|
||||
# Check that lonlat is now set correctly
|
||||
expect(place1.lonlat).not_to be_nil
|
||||
expect(place2.lonlat).not_to be_nil
|
||||
|
||||
# The other user's place should still have nil lonlat
|
||||
expect(other_place.lonlat).to be_nil
|
||||
|
||||
# Verify the coordinates
|
||||
expect(place1.lonlat.x).to eq(10.0) # longitude
|
||||
expect(place1.lonlat.y).to eq(20.0) # latitude
|
||||
|
||||
expect(place2.lonlat.x).to eq(-73.935242) # longitude
|
||||
expect(place2.lonlat.y).to eq(40.730610) # latitude
|
||||
end
|
||||
|
||||
it 'sets the correct SRID (4326) on the geometry' do
|
||||
described_class.perform_now(user.id)
|
||||
place1.reload
|
||||
|
||||
# SRID should be 4326 (WGS84)
|
||||
expect(place1.lonlat.srid).to eq(4326)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no places exist for the user' do
|
||||
it 'completes successfully without errors' do
|
||||
expect do
|
||||
described_class.perform_now(user.id)
|
||||
end.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not exist' do
|
||||
it 'raises ActiveRecord::RecordNotFound' do
|
||||
expect do
|
||||
described_class.perform_now(-1)
|
||||
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'uses the default queue' do
|
||||
expect(described_class.queue_name).to eq('default')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,44 +3,125 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe VisitSuggestingJob, type: :job do
|
||||
let!(:users) { [create(:user)] }
|
||||
let(:user) { create(:user) }
|
||||
let(:start_at) { DateTime.now.beginning_of_day - 1.day }
|
||||
let(:end_at) { DateTime.now.end_of_day }
|
||||
|
||||
describe '#perform' do
|
||||
subject { described_class.perform_now }
|
||||
subject { described_class.perform_now(user_id: user.id, start_at: start_at, end_at: end_at) }
|
||||
|
||||
before do
|
||||
allow(Visits::Suggest).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Visits::Suggest).to receive(:call)
|
||||
end
|
||||
context 'when time range is valid' do
|
||||
before do
|
||||
allow(Visits::Suggest).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Visits::Suggest).to receive(:call)
|
||||
end
|
||||
|
||||
context 'when user has no tracked points' do
|
||||
it 'does not suggest visits' do
|
||||
it 'processes each day in the time range' do
|
||||
# With a 2-day range, we should call Suggest twice (once per day)
|
||||
expect(Visits::Suggest).to receive(:new).twice.and_call_original
|
||||
subject
|
||||
end
|
||||
|
||||
expect(Visits::Suggest).not_to have_received(:new)
|
||||
it 'passes the correct parameters to the Suggest service' do
|
||||
# First day
|
||||
first_day_start = start_at.to_datetime
|
||||
first_day_end = (first_day_start + 1.day)
|
||||
|
||||
expect(Visits::Suggest).to receive(:new)
|
||||
.with(user,
|
||||
start_at: first_day_start,
|
||||
end_at: first_day_end)
|
||||
.and_call_original
|
||||
|
||||
# Second day
|
||||
second_day_start = first_day_end
|
||||
second_day_end = end_at.to_datetime
|
||||
|
||||
expect(Visits::Suggest).to receive(:new)
|
||||
.with(user,
|
||||
start_at: second_day_start,
|
||||
end_at: second_day_end)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user has tracked points' do
|
||||
let!(:tracked_point) { create(:point, user: users.first) }
|
||||
context 'when time range spans multiple days' do
|
||||
let(:start_at) { DateTime.now.beginning_of_day - 3.days }
|
||||
let(:end_at) { DateTime.now.end_of_day }
|
||||
|
||||
it 'suggests visits' do
|
||||
before do
|
||||
allow(Visits::Suggest).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Visits::Suggest).to receive(:call)
|
||||
end
|
||||
|
||||
it 'processes each day in the range' do
|
||||
# With a 4-day range, we should call Suggest 4 times
|
||||
expect(Visits::Suggest).to receive(:new).exactly(4).times.and_call_original
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
expect(Visits::Suggest).to have_received(:new)
|
||||
context 'when user not found' do
|
||||
it 'raises an error' do
|
||||
expect do
|
||||
described_class.perform_now(user_id: -1, start_at: start_at, end_at: end_at)
|
||||
end.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with string dates' do
|
||||
let(:string_start) { start_at.to_s }
|
||||
let(:string_end) { end_at.to_s }
|
||||
let(:parsed_start) { start_at.to_datetime }
|
||||
let(:parsed_end) { end_at.to_datetime }
|
||||
|
||||
before do
|
||||
allow(Visits::Suggest).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Visits::Suggest).to receive(:call)
|
||||
allow(Time.zone).to receive(:parse).with(string_start).and_return(parsed_start)
|
||||
allow(Time.zone).to receive(:parse).with(string_end).and_return(parsed_end)
|
||||
end
|
||||
|
||||
it 'handles string date parameters correctly' do
|
||||
# At minimum we expect one call to Suggest
|
||||
expect(Visits::Suggest).to receive(:new).at_least(:once).and_call_original
|
||||
|
||||
described_class.perform_now(
|
||||
user_id: user.id,
|
||||
start_at: string_start,
|
||||
end_at: string_end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is inactive' do
|
||||
before do
|
||||
users.first.update(status: :inactive)
|
||||
user.update(status: :inactive)
|
||||
|
||||
allow(Visits::Suggest).to receive(:new).and_call_original
|
||||
allow_any_instance_of(Visits::Suggest).to receive(:call)
|
||||
end
|
||||
|
||||
it 'does not suggest visits' do
|
||||
subject
|
||||
it 'still processes the job for the specified user' do
|
||||
# The job doesn't check for user active status, it just processes whatever user is passed
|
||||
expect(Visits::Suggest).to receive(:new).at_least(:once).and_call_original
|
||||
|
||||
expect(Visits::Suggest).not_to have_received(:new)
|
||||
subject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'queue name' do
|
||||
it 'uses the visit_suggesting queue' do
|
||||
expect(described_class.queue_name).to eq('visit_suggesting')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sidekiq options' do
|
||||
it 'has retry disabled' do
|
||||
expect(described_class.sidekiq_options_hash['retry']).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
162
spec/models/concerns/point_validation_spec.rb
Normal file
162
spec/models/concerns/point_validation_spec.rb
Normal file
@@ -0,0 +1,162 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PointValidation do
|
||||
# Create a test class that includes the concern
|
||||
let(:test_class) do
|
||||
Class.new do
|
||||
include PointValidation
|
||||
end
|
||||
end
|
||||
|
||||
let(:validator) { test_class.new }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#point_exists?' do
|
||||
context 'with invalid coordinates' do
|
||||
it 'returns false for zero coordinates' do
|
||||
params = { longitude: '0', latitude: '0', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false for longitude outside valid range' do
|
||||
params = { longitude: '181', latitude: '45', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
|
||||
params = { longitude: '-181', latitude: '45', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false for latitude outside valid range' do
|
||||
params = { longitude: '45', latitude: '91', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
|
||||
params = { longitude: '45', latitude: '-91', timestamp: Time.now.to_i }
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid coordinates' do
|
||||
let(:longitude) { 10.0 }
|
||||
let(:latitude) { 50.0 }
|
||||
let(:timestamp) { Time.now.to_i }
|
||||
let(:params) { { longitude: longitude.to_s, latitude: latitude.to_s, timestamp: timestamp } }
|
||||
|
||||
context 'when point does not exist' do
|
||||
before do
|
||||
allow(Point).to receive(:where).and_return(double(exists?: false))
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'queries the database with correct parameters' do
|
||||
expect(Point).to receive(:where).with(
|
||||
'ST_SetSRID(ST_MakePoint(?, ?), 4326) = lonlat AND timestamp = ? AND user_id = ?',
|
||||
longitude, latitude, timestamp, user.id
|
||||
).and_return(double(exists?: false))
|
||||
|
||||
validator.point_exists?(params, user.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when point exists' do
|
||||
before do
|
||||
allow(Point).to receive(:where).and_return(double(exists?: true))
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(validator.point_exists?(params, user.id)).to be true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with string parameters' do
|
||||
it 'converts string coordinates to float values' do
|
||||
params = { longitude: '10.5', latitude: '50.5', timestamp: '1650000000' }
|
||||
|
||||
expect(Point).to receive(:where).with(
|
||||
'ST_SetSRID(ST_MakePoint(?, ?), 4326) = lonlat AND timestamp = ? AND user_id = ?',
|
||||
10.5, 50.5, 1_650_000_000, user.id
|
||||
).and_return(double(exists?: false))
|
||||
|
||||
validator.point_exists?(params, user.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different boundary values' do
|
||||
it 'accepts maximum valid coordinate values' do
|
||||
params = { longitude: '180', latitude: '90', timestamp: Time.now.to_i }
|
||||
|
||||
expect(Point).to receive(:where).and_return(double(exists?: false))
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'accepts minimum valid coordinate values' do
|
||||
params = { longitude: '-180', latitude: '-90', timestamp: Time.now.to_i }
|
||||
|
||||
expect(Point).to receive(:where).and_return(double(exists?: false))
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with integration tests', :db do
|
||||
# These tests require a database with PostGIS support
|
||||
# Only run them if using real database integration
|
||||
|
||||
let(:existing_timestamp) { 1_650_000_000 }
|
||||
let(:existing_point_params) do
|
||||
{
|
||||
longitude: 10.5,
|
||||
latitude: 50.5,
|
||||
timestamp: existing_timestamp,
|
||||
user_id: user.id
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
# Skip this context if not in integration mode
|
||||
skip 'Skipping integration tests' unless ENV['RUN_INTEGRATION_TESTS']
|
||||
|
||||
# Create a point in the database
|
||||
existing_point = Point.create!(
|
||||
lonlat: "POINT(#{existing_point_params[:longitude]} #{existing_point_params[:latitude]})",
|
||||
timestamp: existing_timestamp,
|
||||
user_id: user.id
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns true when a point with same coordinates and timestamp exists' do
|
||||
params = {
|
||||
longitude: existing_point_params[:longitude].to_s,
|
||||
latitude: existing_point_params[:latitude].to_s,
|
||||
timestamp: existing_timestamp
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be true
|
||||
end
|
||||
|
||||
it 'returns false when a point with different coordinates exists' do
|
||||
params = {
|
||||
longitude: (existing_point_params[:longitude] + 0.1).to_s,
|
||||
latitude: existing_point_params[:latitude].to_s,
|
||||
timestamp: existing_timestamp
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
|
||||
it 'returns false when a point with different timestamp exists' do
|
||||
params = {
|
||||
longitude: existing_point_params[:longitude].to_s,
|
||||
latitude: existing_point_params[:latitude].to_s,
|
||||
timestamp: existing_timestamp + 1
|
||||
}
|
||||
|
||||
expect(validator.point_exists?(params, user.id)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,7 @@ RSpec.describe Place, type: :model do
|
||||
|
||||
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) }
|
||||
it { is_expected.to validate_presence_of(:lonlat) }
|
||||
end
|
||||
|
||||
describe 'enums' do
|
||||
@@ -20,14 +19,51 @@ RSpec.describe Place, type: :model do
|
||||
end
|
||||
|
||||
describe 'methods' do
|
||||
describe '#async_reverse_geocode' do
|
||||
let(:place) { create(:place) }
|
||||
let(:place) { create(:place, :with_geodata) }
|
||||
|
||||
describe '#async_reverse_geocode' do
|
||||
before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
|
||||
before { allow(DawarichSettings).to receive(:reverse_geocoding_enabled?).and_return(true) }
|
||||
|
||||
it 'updates address' do
|
||||
expect { place.async_reverse_geocode }.to have_enqueued_job(ReverseGeocodingJob).with('Place', place.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_id' do
|
||||
it 'returns the osm_id' do
|
||||
expect(place.osm_id).to eq(5_762_449_774)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_key' do
|
||||
it 'returns the osm_key' do
|
||||
expect(place.osm_key).to eq('amenity')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_value' do
|
||||
it 'returns the osm_value' do
|
||||
expect(place.osm_value).to eq('restaurant')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#osm_type' do
|
||||
it 'returns the osm_type' do
|
||||
expect(place.osm_type).to eq('N')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#lon' do
|
||||
it 'returns the longitude' do
|
||||
expect(place.lon).to eq(13.0948638)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#lat' do
|
||||
it 'returns the latitude' do
|
||||
expect(place.lat).to eq(54.2905245)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,6 +55,26 @@ RSpec.describe User, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#import_sample_points' do
|
||||
before do
|
||||
ENV['IMPORT_SAMPLE_POINTS'] = 'true'
|
||||
end
|
||||
|
||||
after do
|
||||
ENV['IMPORT_SAMPLE_POINTS'] = nil
|
||||
end
|
||||
|
||||
it 'creates a sample import and enqueues an import job' do
|
||||
user = create(:user)
|
||||
|
||||
expect(user.imports.count).to eq(1)
|
||||
expect(user.imports.first.name).to eq('DELETE_ME_this_is_a_demo_import_DELETE_ME')
|
||||
expect(user.imports.first.source).to eq('gpx')
|
||||
|
||||
expect(ImportJob).to have_been_enqueued.with(user.id, user.imports.first.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'methods' do
|
||||
@@ -67,7 +87,8 @@ RSpec.describe User, type: :model do
|
||||
let!(:stat2) { create(:stat, user:, toponyms: [{ 'country' => 'France' }]) }
|
||||
|
||||
it 'returns array of countries' do
|
||||
expect(subject).to eq(%w[Germany France])
|
||||
expect(subject).to include('Germany', 'France')
|
||||
expect(subject.count).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
55
spec/requests/api/v1/visits/possible_places_spec.rb
Normal file
55
spec/requests/api/v1/visits/possible_places_spec.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Visits::PossiblePlaces', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
let(:api_key) { user.api_key }
|
||||
let(:visit) { create(:visit, user:) }
|
||||
let(:place) { create(:place) }
|
||||
let!(:place_visit) { create(:place_visit, visit:, place:) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:other_visit) { create(:visit, user: other_user) }
|
||||
|
||||
describe 'GET /api/v1/visits/:id/possible_places' do
|
||||
context 'when visit belongs to the user' do
|
||||
it 'returns a list of suggested places for the visit' do
|
||||
get "/api/v1/visits/#{visit.id}/possible_places", params: { api_key: }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response).to be_an(Array)
|
||||
expect(json_response.size).to eq(1)
|
||||
expect(json_response.first['id']).to eq(place.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit does not exist' do
|
||||
it 'returns a not found error' do
|
||||
get '/api/v1/visits/999999/possible_places', headers: { 'Authorization' => "Bearer #{api_key}" }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Visit not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit does not belong to the user' do
|
||||
it 'returns a not found error' do
|
||||
get "/api/v1/visits/#{other_visit.id}/possible_places", params: { api_key: }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to eq('Visit not found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no api key is provided' do
|
||||
it 'returns unauthorized error' do
|
||||
get "/api/v1/visits/#{visit.id}/possible_places"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,66 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Visits', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
let(:api_key) { user.api_key }
|
||||
let(:place) { create(:place) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:auth_headers) { { 'Authorization' => "Bearer #{api_key}" } }
|
||||
|
||||
describe 'GET /api/v1/visits' do
|
||||
let!(:visit1) { create(:visit, user: user, place: place, started_at: 2.days.ago, ended_at: 1.day.ago) }
|
||||
let!(:visit2) { create(:visit, user: user, place: place, started_at: 4.days.ago, ended_at: 3.days.ago) }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, place: place) }
|
||||
|
||||
context 'when requesting time-based visits' do
|
||||
let(:params) do
|
||||
{
|
||||
start_at: 5.days.ago.iso8601,
|
||||
end_at: Time.zone.now.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns visits within the specified time range' do
|
||||
get '/api/v1/visits', params: params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response.size).to eq(2)
|
||||
expect(json_response.pluck('id')).to include(visit1.id, visit2.id)
|
||||
end
|
||||
|
||||
it 'does not return visits from other users' do
|
||||
get '/api/v1/visits', params: params, headers: auth_headers
|
||||
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response.pluck('id')).not_to include(other_user_visit.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requesting area-based visits' do
|
||||
let(:place_inside) { create(:place, latitude: 50.0, longitude: 14.0) }
|
||||
let!(:visit_inside) { create(:visit, user: user, place: place_inside) }
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '49.0',
|
||||
sw_lng: '13.0',
|
||||
ne_lat: '51.0',
|
||||
ne_lng: '15.0'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns visits within the specified area' do
|
||||
get '/api/v1/visits', params: params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response.pluck('id')).to include(visit_inside.id)
|
||||
expect(json_response.pluck('id')).not_to include(visit1.id, visit2.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/visits/:id' do
|
||||
let(:visit) { create(:visit, user:) }
|
||||
|
||||
@@ -27,13 +85,13 @@ RSpec.describe 'Api::V1::Visits', type: :request do
|
||||
|
||||
context 'with valid parameters' do
|
||||
it 'updates the requested visit' do
|
||||
put api_v1_visit_url(visit, api_key:), params: valid_attributes
|
||||
put "/api/v1/visits/#{visit.id}", params: valid_attributes, headers: auth_headers
|
||||
|
||||
expect(visit.reload.name).to eq('New name')
|
||||
end
|
||||
|
||||
it 'renders a JSON response with the visit' do
|
||||
put api_v1_visit_url(visit, api_key:), params: valid_attributes
|
||||
put "/api/v1/visits/#{visit.id}", params: valid_attributes, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
@@ -41,10 +99,129 @@ RSpec.describe 'Api::V1::Visits', type: :request do
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'renders a JSON response with errors for the visit' do
|
||||
put api_v1_visit_url(visit, api_key:), params: invalid_attributes
|
||||
put "/api/v1/visits/#{visit.id}", params: invalid_attributes, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/visits/merge' do
|
||||
let!(:visit1) { create(:visit, user: user, started_at: 2.days.ago, ended_at: 1.day.ago) }
|
||||
let!(:visit2) { create(:visit, user: user, started_at: 4.days.ago, ended_at: 3.days.ago) }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user) }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:valid_merge_params) do
|
||||
{
|
||||
visit_ids: [visit1.id, visit2.id]
|
||||
}
|
||||
end
|
||||
|
||||
it 'merges the specified visits' do
|
||||
# Mock the service to avoid dealing with complex merging logic in the test
|
||||
merge_service = instance_double(Visits::MergeService)
|
||||
merged_visit = create(:visit, user: user)
|
||||
|
||||
expect(Visits::MergeService).to receive(:new).with(kind_of(ActiveRecord::Relation)).and_return(merge_service)
|
||||
expect(merge_service).to receive(:call).and_return(merged_visit)
|
||||
|
||||
post '/api/v1/visits/merge', params: valid_merge_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
it 'returns an error when fewer than 2 visits are specified' do
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('At least 2 visits must be selected')
|
||||
end
|
||||
|
||||
it 'returns an error when not all visits are found' do
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, 999_999] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('not found')
|
||||
end
|
||||
|
||||
it 'returns an error when trying to merge other user visits' do
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, other_user_visit.id] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('not found')
|
||||
end
|
||||
|
||||
it 'returns an error when the merge fails' do
|
||||
merge_service = instance_double(Visits::MergeService)
|
||||
|
||||
expect(Visits::MergeService).to receive(:new).with(kind_of(ActiveRecord::Relation)).and_return(merge_service)
|
||||
expect(merge_service).to receive(:call).and_return(nil)
|
||||
expect(merge_service).to receive(:errors).and_return(['Failed to merge visits'])
|
||||
|
||||
post '/api/v1/visits/merge', params: { visit_ids: [visit1.id, visit2.id] }, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Failed to merge visits')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/visits/bulk_update' do
|
||||
let!(:visit1) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:visit2) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, status: 'suggested') }
|
||||
let(:bulk_update_service) { instance_double(Visits::BulkUpdate) }
|
||||
|
||||
context 'with valid parameters' do
|
||||
let(:valid_update_params) do
|
||||
{
|
||||
visit_ids: [visit1.id, visit2.id],
|
||||
status: 'confirmed'
|
||||
}
|
||||
end
|
||||
|
||||
it 'updates the status of specified visits' do
|
||||
expect(Visits::BulkUpdate).to receive(:new)
|
||||
.with(user, kind_of(Array), 'confirmed')
|
||||
.and_return(bulk_update_service)
|
||||
expect(bulk_update_service).to receive(:call).and_return({ count: 2 })
|
||||
|
||||
post '/api/v1/visits/bulk_update', params: valid_update_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['updated_count']).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_update_params) do
|
||||
{
|
||||
visit_ids: [visit1.id, visit2.id],
|
||||
status: 'invalid_status'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns an error when the update fails' do
|
||||
expect(Visits::BulkUpdate).to receive(:new)
|
||||
.with(user, kind_of(Array), 'invalid_status')
|
||||
.and_return(bulk_update_service)
|
||||
expect(bulk_update_service).to receive(:call).and_return(nil)
|
||||
expect(bulk_update_service).to receive(:errors).and_return(['Invalid status'])
|
||||
|
||||
post '/api/v1/visits/bulk_update', params: invalid_update_params, headers: auth_headers
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
json_response = JSON.parse(response.body)
|
||||
expect(json_response['error']).to include('Invalid status')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
67
spec/serializers/api/place_serializer_spec.rb
Normal file
67
spec/serializers/api/place_serializer_spec.rb
Normal file
@@ -0,0 +1,67 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::PlaceSerializer do
|
||||
describe '#call' do
|
||||
let(:place) do
|
||||
create(
|
||||
:place,
|
||||
:with_geodata,
|
||||
name: 'Central Park',
|
||||
longitude: -73.9665,
|
||||
latitude: 40.7812,
|
||||
lonlat: 'SRID=4326;POINT(-73.9665 40.7812)',
|
||||
city: 'New York',
|
||||
country: 'United States',
|
||||
source: 'photon',
|
||||
geodata: { 'amenity' => 'park', 'leisure' => 'park' }, reverse_geocoded_at: Time.zone.parse('2023-01-15T12:00:00Z')
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer) { described_class.new(place) }
|
||||
|
||||
it 'serializes a place into a hash with all attributes' do
|
||||
result = serializer.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:id]).to eq(place.id)
|
||||
expect(result[:name]).to eq('Central Park')
|
||||
expect(result[:longitude]).to eq(-73.9665)
|
||||
expect(result[:latitude]).to eq(40.7812)
|
||||
expect(result[:city]).to eq('New York')
|
||||
expect(result[:country]).to eq('United States')
|
||||
expect(result[:source]).to eq('photon')
|
||||
expect(result[:geodata]).to eq({ 'amenity' => 'park', 'leisure' => 'park' })
|
||||
expect(result[:reverse_geocoded_at]).to eq(Time.zone.parse('2023-01-15T12:00:00Z'))
|
||||
end
|
||||
|
||||
context 'with nil values' do
|
||||
let(:place_with_nils) do
|
||||
create(
|
||||
:place,
|
||||
name: 'Unknown Place',
|
||||
city: nil,
|
||||
country: nil,
|
||||
source: nil,
|
||||
geodata: {},
|
||||
reverse_geocoded_at: nil
|
||||
)
|
||||
end
|
||||
|
||||
subject(:serializer_with_nils) { described_class.new(place_with_nils) }
|
||||
|
||||
it 'handles nil values correctly' do
|
||||
result = serializer_with_nils.call
|
||||
|
||||
expect(result[:id]).to eq(place_with_nils.id)
|
||||
expect(result[:name]).to eq('Unknown Place')
|
||||
expect(result[:city]).to be_nil
|
||||
expect(result[:country]).to be_nil
|
||||
expect(result[:source]).to be_nil
|
||||
expect(result[:geodata]).to eq({})
|
||||
expect(result[:reverse_geocoded_at]).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
30
spec/serializers/api/visit_serializer_spec.rb
Normal file
30
spec/serializers/api/visit_serializer_spec.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Api::VisitSerializer do
|
||||
describe '#call' do
|
||||
let(:place) { create(:place) }
|
||||
let(:area) { create(:area) }
|
||||
let(:visit) { create(:visit, place: place, area: area) }
|
||||
|
||||
subject(:serializer) { described_class.new(visit) }
|
||||
|
||||
it 'serializes a real visit model correctly' do
|
||||
result = serializer.call
|
||||
|
||||
expect(result[:id]).to eq(visit.id)
|
||||
expect(result[:area_id]).to eq(visit.area_id)
|
||||
expect(result[:user_id]).to eq(visit.user_id)
|
||||
expect(result[:started_at]).to eq(visit.started_at)
|
||||
expect(result[:ended_at]).to eq(visit.ended_at)
|
||||
expect(result[:duration]).to eq(visit.duration)
|
||||
expect(result[:name]).to eq(visit.name)
|
||||
expect(result[:status]).to eq(visit.status)
|
||||
|
||||
expect(result[:place][:id]).to eq(place.id)
|
||||
expect(result[:place][:latitude]).to eq(place.lat)
|
||||
expect(result[:place][:longitude]).to eq(place.lon)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -10,8 +10,7 @@ RSpec.describe Points::Params do
|
||||
let(:json) { JSON.parse(file.read) }
|
||||
let(:expected_json) do
|
||||
{
|
||||
latitude: 37.74430413,
|
||||
longitude: -122.40530871,
|
||||
lonlat: 'POINT(-122.40530871 37.74430413)',
|
||||
battery_status: nil,
|
||||
battery: nil,
|
||||
timestamp: DateTime.parse('2025-01-17T21:03:01Z'),
|
||||
|
||||
154
spec/services/visits/bulk_update_spec.rb
Normal file
154
spec/services/visits/bulk_update_spec.rb
Normal file
@@ -0,0 +1,154 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::BulkUpdate do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
|
||||
let!(:visit1) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:visit2) { create(:visit, user: user, status: 'suggested') }
|
||||
let!(:visit3) { create(:visit, user: user, status: 'confirmed') }
|
||||
let!(:other_user_visit) { create(:visit, user: other_user, status: 'suggested') }
|
||||
|
||||
describe '#call' do
|
||||
context 'when all parameters are valid' do
|
||||
let(:visit_ids) { [visit1.id, visit2.id] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'updates the status of all specified visits' do
|
||||
result = service.call
|
||||
|
||||
expect(result[:count]).to eq(2)
|
||||
expect(visit1.reload.status).to eq('confirmed')
|
||||
expect(visit2.reload.status).to eq('confirmed')
|
||||
expect(visit3.reload.status).to eq('confirmed') # This one wasn't changed
|
||||
end
|
||||
|
||||
it 'returns a hash with count and visits' do
|
||||
result = service.call
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:count]).to eq(2)
|
||||
expect(result[:visits]).to include(visit1, visit2)
|
||||
expect(result[:visits]).not_to include(visit3, other_user_visit)
|
||||
end
|
||||
|
||||
it 'does not update visits that belong to other users' do
|
||||
service.call
|
||||
|
||||
expect(other_user_visit.reload.status).to eq('suggested')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when changing to declined status' do
|
||||
let(:visit_ids) { [visit1.id, visit2.id, visit3.id] }
|
||||
let(:status) { 'declined' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'updates the status to declined' do
|
||||
result = service.call
|
||||
|
||||
expect(result[:count]).to eq(3)
|
||||
expect(visit1.reload.status).to eq('declined')
|
||||
expect(visit2.reload.status).to eq('declined')
|
||||
expect(visit3.reload.status).to eq('declined')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit_ids is empty' do
|
||||
let(:visit_ids) { [] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('No visits selected')
|
||||
end
|
||||
|
||||
it 'does not update any visits' do
|
||||
service.call
|
||||
expect(visit1.reload.status).to eq('suggested')
|
||||
expect(visit2.reload.status).to eq('suggested')
|
||||
expect(visit3.reload.status).to eq('confirmed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visit_ids is nil' do
|
||||
let(:visit_ids) { nil }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('No visits selected')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status is invalid' do
|
||||
let(:visit_ids) { [visit1.id, visit2.id] }
|
||||
let(:status) { 'invalid_status' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('Invalid status')
|
||||
end
|
||||
|
||||
it 'does not update any visits' do
|
||||
service.call
|
||||
expect(visit1.reload.status).to eq('suggested')
|
||||
expect(visit2.reload.status).to eq('suggested')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no matching visits are found' do
|
||||
let(:visit_ids) { [999_999, 888_888] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.call).to be(false)
|
||||
end
|
||||
|
||||
it 'adds an error' do
|
||||
service.call
|
||||
expect(service.errors).to include('No matching visits found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when some visit IDs do not belong to the user' do
|
||||
let(:visit_ids) { [visit1.id, other_user_visit.id] }
|
||||
let(:status) { 'confirmed' }
|
||||
|
||||
subject(:service) { described_class.new(user, visit_ids, status) }
|
||||
|
||||
it 'only updates visits that belong to the user' do
|
||||
result = service.call
|
||||
|
||||
expect(result[:count]).to eq(1)
|
||||
expect(visit1.reload.status).to eq('confirmed')
|
||||
expect(other_user_visit.reload.status).to eq('suggested')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,8 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Calculate do
|
||||
describe '#call' do
|
||||
end
|
||||
end
|
||||
258
spec/services/visits/creator_spec.rb
Normal file
258
spec/services/visits/creator_spec.rb
Normal file
@@ -0,0 +1,258 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Creator do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject { described_class.new(user) }
|
||||
|
||||
describe '#create_visits' do
|
||||
let(:point1) { create(:point, user: user) }
|
||||
let(:point2) { create(:point, user: user) }
|
||||
|
||||
let(:visit_data) do
|
||||
{
|
||||
start_time: 1.hour.ago.to_i,
|
||||
end_time: 30.minutes.ago.to_i,
|
||||
duration: 30.minutes.to_i,
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
radius: 50,
|
||||
suggested_name: 'Test Place',
|
||||
points: [point1, point2]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when matching an area' do
|
||||
let!(:area) { create(:area, user: user, latitude: 40.7128, longitude: -74.0060, radius: 100) }
|
||||
|
||||
it 'creates a visit associated with the area' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.size).to eq(1)
|
||||
visit = visits.first
|
||||
|
||||
expect(visit.area).to eq(area)
|
||||
expect(visit.place).to be_nil
|
||||
expect(visit.started_at).to be_within(1.second).of(Time.zone.at(visit_data[:start_time]))
|
||||
expect(visit.ended_at).to be_within(1.second).of(Time.zone.at(visit_data[:end_time]))
|
||||
expect(visit.duration).to eq(30)
|
||||
expect(visit.name).to eq(area.name)
|
||||
expect(visit.status).to eq('suggested')
|
||||
|
||||
expect(point1.reload.visit_id).to eq(visit.id)
|
||||
expect(point2.reload.visit_id).to eq(visit.id)
|
||||
end
|
||||
|
||||
it 'uses area name for visit name' do
|
||||
area.update(name: 'Custom Area Name')
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.first.name).to eq('Custom Area Name')
|
||||
end
|
||||
|
||||
it 'does not find areas too far from the visit center' do
|
||||
far_area = create(:area, user: user, latitude: 41.8781, longitude: -87.6298, radius: 100) # Chicago
|
||||
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.first.area).to eq(area) # Should match the closer area
|
||||
expect(visits.first.area).not_to eq(far_area)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when matching a place' do
|
||||
let(:place) { create(:place, name: 'Test Place') }
|
||||
let(:suggested_place1) { create(:place, name: 'Suggested Place 1') }
|
||||
let(:suggested_place2) { create(:place, name: 'Suggested Place 2') }
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
let(:place_data) do
|
||||
{
|
||||
main_place: place,
|
||||
suggested_places: [suggested_place1, suggested_place2]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(place_data)
|
||||
end
|
||||
|
||||
it 'creates a visit associated with the place' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.size).to eq(1)
|
||||
visit = visits.first
|
||||
|
||||
expect(visit.area).to be_nil
|
||||
expect(visit.place).to eq(place)
|
||||
expect(visit.name).to eq(place.name)
|
||||
end
|
||||
|
||||
it 'associates suggested places with the visit' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
visit = visits.first
|
||||
|
||||
# Check for place_visits associations
|
||||
expect(visit.place_visits.count).to eq(2)
|
||||
expect(visit.place_visits.pluck(:place_id)).to contain_exactly(
|
||||
suggested_place1.id,
|
||||
suggested_place2.id
|
||||
)
|
||||
expect(visit.suggested_places).to contain_exactly(suggested_place1, suggested_place2)
|
||||
end
|
||||
|
||||
it 'does not create duplicate place_visit associations' do
|
||||
# Create an existing association
|
||||
visit = create(:visit, user: user, place: place)
|
||||
create(:place_visit, visit: visit, place: suggested_place1)
|
||||
|
||||
allow(Visit).to receive(:create!).and_return(visit)
|
||||
|
||||
# Only one new association should be created
|
||||
expect do
|
||||
subject.create_visits([visit_data])
|
||||
end.to change(PlaceVisit, :count).by(1)
|
||||
expect(visit.place_visits.pluck(:place_id)).to contain_exactly(
|
||||
suggested_place1.id,
|
||||
suggested_place2.id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no area or place is found' do
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(nil)
|
||||
end
|
||||
|
||||
it 'uses suggested name from visit data' do
|
||||
visits = subject.create_visits([visit_data])
|
||||
|
||||
expect(visits.first.area).to be_nil
|
||||
expect(visits.first.place).to be_nil
|
||||
expect(visits.first.name).to eq('Test Place')
|
||||
end
|
||||
|
||||
it 'uses "Unknown Location" when no name is available' do
|
||||
visit_data_without_name = visit_data.dup
|
||||
visit_data_without_name[:suggested_name] = nil
|
||||
|
||||
visits = subject.create_visits([visit_data_without_name])
|
||||
|
||||
expect(visits.first.name).to eq('Unknown Location')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when processing multiple visits' do
|
||||
let(:place1) { create(:place, name: 'Place 1') }
|
||||
let(:place2) { create(:place, name: 'Place 2') }
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
|
||||
let(:visit_data2) do
|
||||
{
|
||||
start_time: 3.hours.ago.to_i,
|
||||
end_time: 2.hours.ago.to_i,
|
||||
duration: 60.minutes.to_i,
|
||||
center_lat: 41.8781,
|
||||
center_lon: -87.6298,
|
||||
radius: 50,
|
||||
suggested_name: 'Chicago Visit',
|
||||
points: [create(:point, user: user), create(:point, user: user)]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place)
|
||||
.with(visit_data).and_return({ main_place: place1, suggested_places: [] })
|
||||
allow(place_finder).to receive(:find_or_create_place)
|
||||
.with(visit_data2).and_return({ main_place: place2, suggested_places: [] })
|
||||
end
|
||||
|
||||
it 'creates multiple visits' do
|
||||
visits = subject.create_visits([visit_data, visit_data2])
|
||||
|
||||
expect(visits.size).to eq(2)
|
||||
expect(visits[0].place).to eq(place1)
|
||||
expect(visits[0].name).to eq('Place 1')
|
||||
expect(visits[1].place).to eq(place2)
|
||||
expect(visits[1].name).to eq('Place 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when transaction fails' do
|
||||
let(:place_finder) { instance_double(Visits::PlaceFinder) }
|
||||
|
||||
before do
|
||||
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
|
||||
allow(place_finder).to receive(:find_or_create_place).and_return(nil)
|
||||
allow(Visit).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
|
||||
it 'does not update points if visit creation fails' do
|
||||
expect do
|
||||
subject.create_visits([visit_data])
|
||||
end.to raise_error(ActiveRecord::RecordInvalid)
|
||||
|
||||
# Points should not be associated with any visit
|
||||
expect(point1.reload.visit_id).to be_nil
|
||||
expect(point2.reload.visit_id).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#find_matching_area' do
|
||||
let(:visit_data) do
|
||||
{
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
radius: 50
|
||||
}
|
||||
end
|
||||
|
||||
it 'finds areas within radius' do
|
||||
area_within = create(:area, user: user, latitude: 40.7129, longitude: -74.0061, radius: 100)
|
||||
area_outside = create(:area, user: user, latitude: 40.7500, longitude: -74.0500, radius: 100)
|
||||
|
||||
result = subject.send(:find_matching_area, visit_data)
|
||||
expect(result).to eq(area_within)
|
||||
end
|
||||
|
||||
it 'returns nil when no areas match' do
|
||||
create(:area, user: user, latitude: 42.0, longitude: -72.0, radius: 100)
|
||||
|
||||
result = subject.send(:find_matching_area, visit_data)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'only considers user areas' do
|
||||
area_other_user = create(:area, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
area_user = create(:area, user: user, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
|
||||
result = subject.send(:find_matching_area, visit_data)
|
||||
expect(result).to eq(area_user)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#near_area?' do
|
||||
it 'returns true when point is within area radius' do
|
||||
area = create(:area, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
center = [40.7129, -74.0061] # Very close to area center
|
||||
|
||||
result = subject.send(:near_area?, center, area)
|
||||
expect(result).to be true
|
||||
end
|
||||
|
||||
it 'returns false when point is outside area radius' do
|
||||
area = create(:area, latitude: 40.7128, longitude: -74.0060, radius: 100)
|
||||
center = [40.7500, -74.0500] # Further away
|
||||
|
||||
result = subject.send(:near_area?, center, area)
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
324
spec/services/visits/detector_spec.rb
Normal file
324
spec/services/visits/detector_spec.rb
Normal file
@@ -0,0 +1,324 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Detector do
|
||||
# Constants from the class to make tests more maintainable
|
||||
let(:minimum_visit_duration) { described_class::MINIMUM_VISIT_DURATION }
|
||||
let(:maximum_visit_gap) { described_class::MAXIMUM_VISIT_GAP }
|
||||
let(:minimum_points_for_visit) { described_class::MINIMUM_POINTS_FOR_VISIT }
|
||||
|
||||
# Base time for tests
|
||||
let(:base_time) { Time.zone.now }
|
||||
|
||||
# Create points for a typical visit scenario
|
||||
let(:points) do
|
||||
[
|
||||
# First visit - multiple points close together
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: (base_time - 50.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)', timestamp: (base_time - 40.minutes).to_i),
|
||||
|
||||
# Gap in time (> MAXIMUM_VISIT_GAP)
|
||||
|
||||
# Second visit - different location
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: (base_time - 10.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: (base_time - 5.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(points) }
|
||||
|
||||
describe '#detect_potential_visits' do
|
||||
context 'with valid visit data' do
|
||||
before do
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
end
|
||||
|
||||
it 'identifies separate visits based on time gaps and location changes' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(2)
|
||||
expect(visits.first[:points].size).to eq(3)
|
||||
expect(visits.last[:points].size).to eq(2)
|
||||
end
|
||||
|
||||
it 'calculates correct visit properties' do
|
||||
visits = subject.detect_potential_visits
|
||||
first_visit = visits.first
|
||||
|
||||
# The center should be the average of the first 3 points
|
||||
expected_lat = (40.7128 + 40.7129 + 40.7130) / 3
|
||||
expected_lon = (-74.0060 + -74.0061 + -74.0062) / 3
|
||||
|
||||
expect(first_visit[:start_time]).to eq((base_time - 1.hour).to_i)
|
||||
expect(first_visit[:end_time]).to eq((base_time - 40.minutes).to_i)
|
||||
expect(first_visit[:duration]).to eq(20.minutes.to_i)
|
||||
expect(first_visit[:center_lat]).to be_within(0.0001).of(expected_lat)
|
||||
expect(first_visit[:center_lon]).to be_within(0.0001).of(expected_lon)
|
||||
expect(first_visit[:radius]).to be > 0
|
||||
expect(first_visit[:suggested_name]).to eq('Test Place')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with visits that are too short in duration' do
|
||||
let(:short_duration_points) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)',
|
||||
timestamp: (base_time - 1.hour + 2.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(short_duration_points) }
|
||||
|
||||
it 'filters out visits that are too short' do
|
||||
visits = subject.detect_potential_visits
|
||||
expect(visits).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with insufficient points for a visit' do
|
||||
let(:single_point) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(single_point) }
|
||||
|
||||
it 'does not create a visit with just one point' do
|
||||
visits = subject.detect_potential_visits
|
||||
expect(visits).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points that create multiple valid visits' do
|
||||
let(:multi_visit_points) do
|
||||
[
|
||||
# First visit
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 3.hours).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: (base_time - 2.5.hours).to_i),
|
||||
|
||||
# Second visit (different location, after a gap)
|
||||
build_stubbed(:point, lonlat: 'POINT(-73.9800 40.7600)', timestamp: (base_time - 1.5.hours).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-73.9801 40.7601)', timestamp: (base_time - 1.hour).to_i),
|
||||
|
||||
# Third visit (another location, after another gap)
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: (base_time - 30.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: (base_time - 20.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(multi_visit_points) }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
end
|
||||
|
||||
it 'correctly identifies all valid visits' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(3)
|
||||
expect(visits[0][:points].size).to eq(2)
|
||||
expect(visits[1][:points].size).to eq(2)
|
||||
expect(visits[2][:points].size).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with points having small time gaps but in same area' do
|
||||
let(:same_area_points) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: (base_time - 1.hour).to_i),
|
||||
# Small gap (less than MAXIMUM_VISIT_GAP)
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)',
|
||||
timestamp: (base_time - 1.hour + 25.minutes).to_i),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
||||
timestamp: (base_time - 1.hour + 40.minutes).to_i)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(same_area_points) }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
|
||||
end
|
||||
|
||||
it 'groups points into a single visit despite small gaps' do
|
||||
visits = subject.detect_potential_visits
|
||||
|
||||
expect(visits.size).to eq(1)
|
||||
expect(visits.first[:points].size).to eq(3)
|
||||
expect(visits.first[:duration]).to eq(40.minutes.to_i)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with no points' do
|
||||
subject { described_class.new([]) }
|
||||
|
||||
it 'returns an empty array' do
|
||||
visits = subject.detect_potential_visits
|
||||
expect(visits).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
describe '#belongs_to_current_visit?' do
|
||||
let(:current_visit) do
|
||||
{
|
||||
start_time: (base_time - 1.hour).to_i,
|
||||
end_time: (base_time - 50.minutes).to_i,
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
points: []
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns true for a point with small time gap and close to center' do
|
||||
point = build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
||||
timestamp: (base_time - 45.minutes).to_i)
|
||||
|
||||
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
||||
expect(result).to be true
|
||||
end
|
||||
|
||||
it 'returns false for a point with large time gap' do
|
||||
point = build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)',
|
||||
timestamp: (base_time - 10.minutes).to_i)
|
||||
|
||||
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
||||
expect(result).to be false
|
||||
end
|
||||
|
||||
it 'returns false for a point far from the center' do
|
||||
point = build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)',
|
||||
timestamp: (base_time - 49.minutes).to_i)
|
||||
|
||||
result = subject.send(:belongs_to_current_visit?, point, current_visit)
|
||||
expect(result).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_max_radius' do
|
||||
it 'returns larger radius for longer visits' do
|
||||
short_radius = subject.send(:calculate_max_radius, 5.minutes.to_i)
|
||||
long_radius = subject.send(:calculate_max_radius, 1.hour.to_i)
|
||||
|
||||
expect(long_radius).to be > short_radius
|
||||
end
|
||||
|
||||
it 'has a minimum radius even for very short visits' do
|
||||
radius = subject.send(:calculate_max_radius, 1.minute.to_i)
|
||||
expect(radius).to be > 0
|
||||
end
|
||||
|
||||
it 'caps the radius at maximum value' do
|
||||
radius = subject.send(:calculate_max_radius, 24.hours.to_i)
|
||||
expect(radius).to be <= 0.5 # Cap at 500 meters
|
||||
end
|
||||
end
|
||||
|
||||
describe '#calculate_visit_radius' do
|
||||
let(:center) { [40.7128, -74.0060] }
|
||||
let(:test_points) do
|
||||
[
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)'), # At center
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0070 40.7138)'), # ~100m away
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0080 40.7148)') # ~200m away
|
||||
]
|
||||
end
|
||||
|
||||
it 'returns the distance to the furthest point as radius' do
|
||||
radius = subject.send(:calculate_visit_radius, test_points, center)
|
||||
|
||||
# Adjust the expected value to match the actual Geocoder calculation
|
||||
# or increase the tolerance to account for the difference
|
||||
expect(radius).to be_within(100).of(275)
|
||||
end
|
||||
|
||||
it 'ensures a minimum radius even with close points' do
|
||||
close_points = [
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)'),
|
||||
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)')
|
||||
]
|
||||
|
||||
radius = subject.send(:calculate_visit_radius, close_points, center)
|
||||
expect(radius).to be >= 15 # Minimum 15 meters
|
||||
end
|
||||
end
|
||||
|
||||
describe '#suggest_place_name' do
|
||||
let(:point_with_geodata) do
|
||||
build_stubbed(:point,
|
||||
geodata: {
|
||||
'features' => [
|
||||
{
|
||||
'properties' => {
|
||||
'type' => 'restaurant',
|
||||
'name' => 'Awesome Pizza',
|
||||
'street' => 'Main St',
|
||||
'city' => 'New York',
|
||||
'state' => 'NY'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
let(:point_with_different_geodata) do
|
||||
build_stubbed(:point,
|
||||
geodata: {
|
||||
'features' => [
|
||||
{
|
||||
'properties' => {
|
||||
'type' => 'park',
|
||||
'name' => 'Central Park',
|
||||
'city' => 'New York',
|
||||
'state' => 'NY'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
let(:point_without_geodata) do
|
||||
build_stubbed(:point, geodata: nil)
|
||||
end
|
||||
|
||||
it 'extracts the most common feature name' do
|
||||
test_points = [point_with_geodata, point_with_geodata]
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to eq('Awesome Pizza, Main St, New York, NY')
|
||||
end
|
||||
|
||||
it 'returns nil for points without geodata' do
|
||||
test_points = [point_without_geodata, point_without_geodata]
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to be_nil
|
||||
end
|
||||
|
||||
it 'uses the most common feature type across multiple points' do
|
||||
restaurant_points = Array.new(3) { point_with_geodata }
|
||||
park_points = Array.new(2) { point_with_different_geodata }
|
||||
|
||||
test_points = restaurant_points + park_points
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to eq('Awesome Pizza, Main St, New York, NY')
|
||||
end
|
||||
|
||||
it 'handles empty or invalid geodata gracefully' do
|
||||
point_with_empty_features = build_stubbed(:point, geodata: { 'features' => [] })
|
||||
point_with_invalid_geodata = build_stubbed(:point, geodata: { 'invalid' => 'data' })
|
||||
|
||||
test_points = [point_with_empty_features, point_with_invalid_geodata]
|
||||
name = subject.send(:suggest_place_name, test_points)
|
||||
|
||||
expect(name).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
142
spec/services/visits/find_in_time_spec.rb
Normal file
142
spec/services/visits/find_in_time_spec.rb
Normal file
@@ -0,0 +1,142 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::FindInTime do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
let(:place) { create(:place) }
|
||||
|
||||
let(:reference_time) { Time.zone.parse('2023-01-15 12:00:00') }
|
||||
|
||||
let!(:visit1) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time,
|
||||
ended_at: reference_time + 1.hour
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit2) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time + 2.hours,
|
||||
ended_at: reference_time + 3.hours
|
||||
)
|
||||
end
|
||||
|
||||
# Visit outside range (before)
|
||||
let!(:visit_before) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time - 3.hours,
|
||||
ended_at: reference_time - 2.hours
|
||||
)
|
||||
end
|
||||
|
||||
# Visit outside range (after)
|
||||
let!(:visit_after) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time + 5.hours,
|
||||
ended_at: reference_time + 6.hours
|
||||
)
|
||||
end
|
||||
|
||||
# Visit for different user within range
|
||||
let!(:other_user_visit) do
|
||||
create(
|
||||
:visit,
|
||||
user: other_user,
|
||||
place: place,
|
||||
started_at: reference_time + 1.hour,
|
||||
ended_at: reference_time + 2.hours
|
||||
)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'when given a time range' do
|
||||
let(:params) do
|
||||
{
|
||||
start_at: reference_time.to_s,
|
||||
end_at: (reference_time + 4.hours).to_s
|
||||
}
|
||||
end
|
||||
|
||||
subject(:result) { described_class.new(user, params).call }
|
||||
|
||||
it 'returns visits within the time range' do
|
||||
expect(result).to include(visit1, visit2)
|
||||
expect(result).not_to include(visit_before, visit_after)
|
||||
end
|
||||
|
||||
it 'returns visits in descending order by started_at' do
|
||||
expect(result.to_a).to eq([visit2, visit1])
|
||||
end
|
||||
|
||||
it 'does not include visits from other users' do
|
||||
expect(result).not_to include(other_user_visit)
|
||||
end
|
||||
|
||||
it 'preloads the place association' do
|
||||
expect(result.first.association(:place)).to be_loaded
|
||||
end
|
||||
end
|
||||
|
||||
context 'with visits at the boundaries of the time range' do
|
||||
let!(:visit_at_start) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time,
|
||||
ended_at: reference_time + 30.minutes
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_at_end) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: reference_time + 3.hours + 30.minutes,
|
||||
ended_at: reference_time + 4.hours
|
||||
)
|
||||
end
|
||||
|
||||
let(:params) do
|
||||
{
|
||||
start_at: reference_time.to_s,
|
||||
end_at: (reference_time + 4.hours).to_s
|
||||
}
|
||||
end
|
||||
|
||||
subject(:result) { described_class.new(user, params).call }
|
||||
|
||||
it 'includes visits at the boundaries of the time range' do
|
||||
expect(result).to include(visit_at_start, visit_at_end)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when time parameters are invalid' do
|
||||
let(:params) do
|
||||
{
|
||||
start_at: 'invalid-date',
|
||||
end_at: (reference_time + 4.hours).to_s
|
||||
}
|
||||
end
|
||||
|
||||
it 'raises an ArgumentError' do
|
||||
expect { described_class.new(user, params).call }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
161
spec/services/visits/find_within_bounding_box_spec.rb
Normal file
161
spec/services/visits/find_within_bounding_box_spec.rb
Normal file
@@ -0,0 +1,161 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::FindWithinBoundingBox do
|
||||
let(:user) { create(:user) }
|
||||
let(:other_user) { create(:user) }
|
||||
|
||||
# Define a bounding box for testing
|
||||
# This creates a box around central Paris
|
||||
let(:sw_lat) { 48.8534 } # Southwest latitude
|
||||
let(:sw_lng) { 2.3380 } # Southwest longitude
|
||||
let(:ne_lat) { 48.8667 } # Northeast latitude
|
||||
let(:ne_lng) { 2.3580 } # Northeast longitude
|
||||
|
||||
# Create places inside the bounding box
|
||||
let!(:place_inside_1) do
|
||||
create(:place, latitude: 48.8600, longitude: 2.3500) # Inside the bounding box
|
||||
end
|
||||
|
||||
let!(:place_inside_2) do
|
||||
create(:place, latitude: 48.8580, longitude: 2.3450) # Inside the bounding box
|
||||
end
|
||||
|
||||
# Create places outside the bounding box
|
||||
let!(:place_outside_1) do
|
||||
create(:place, latitude: 48.8700, longitude: 2.3600) # North of the bounding box
|
||||
end
|
||||
|
||||
let!(:place_outside_2) do
|
||||
create(:place, latitude: 48.8500, longitude: 2.3300) # Southwest of the bounding box
|
||||
end
|
||||
|
||||
# Create visits for the test user
|
||||
let!(:visit_inside_1) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_inside_1,
|
||||
started_at: 2.hours.ago,
|
||||
ended_at: 1.hour.ago
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_inside_2) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_inside_2,
|
||||
started_at: 4.hours.ago,
|
||||
ended_at: 3.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_outside_1) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_outside_1,
|
||||
started_at: 6.hours.ago,
|
||||
ended_at: 5.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
let!(:visit_outside_2) do
|
||||
create(
|
||||
:visit,
|
||||
user: user,
|
||||
place: place_outside_2,
|
||||
started_at: 8.hours.ago,
|
||||
ended_at: 7.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
# Create a visit for another user inside the bounding box
|
||||
let!(:other_user_visit_inside) do
|
||||
create(
|
||||
:visit,
|
||||
user: other_user,
|
||||
place: place_inside_1,
|
||||
started_at: 3.hours.ago,
|
||||
ended_at: 2.hours.ago
|
||||
)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: sw_lat.to_s,
|
||||
sw_lng: sw_lng.to_s,
|
||||
ne_lat: ne_lat.to_s,
|
||||
ne_lng: ne_lng.to_s
|
||||
}
|
||||
end
|
||||
|
||||
subject(:result) { described_class.new(user, params).call }
|
||||
|
||||
it 'returns visits within the specified bounding box' do
|
||||
expect(result).to include(visit_inside_1, visit_inside_2)
|
||||
expect(result).not_to include(visit_outside_1, visit_outside_2)
|
||||
end
|
||||
|
||||
it 'returns visits in descending order by started_at' do
|
||||
expect(result.to_a).to eq([visit_inside_1, visit_inside_2])
|
||||
end
|
||||
|
||||
it 'does not include visits from other users' do
|
||||
expect(result).not_to include(other_user_visit_inside)
|
||||
end
|
||||
|
||||
it 'preloads the place association' do
|
||||
expect(result.first.association(:place)).to be_loaded
|
||||
end
|
||||
|
||||
context 'with an empty bounding box' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: '0',
|
||||
sw_lng: '0',
|
||||
ne_lat: '0',
|
||||
ne_lng: '0'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns an empty collection' do
|
||||
expect(result).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a very large bounding box' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: '-90',
|
||||
sw_lng: '-180',
|
||||
ne_lat: '90',
|
||||
ne_lng: '180'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns all visits for the user' do
|
||||
expect(result).to include(visit_inside_1, visit_inside_2, visit_outside_1, visit_outside_2)
|
||||
expect(result).not_to include(other_user_visit_inside)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with string coordinates' do
|
||||
let(:params) do
|
||||
{
|
||||
sw_lat: sw_lat.to_s,
|
||||
sw_lng: sw_lng.to_s,
|
||||
ne_lat: ne_lat.to_s,
|
||||
ne_lng: ne_lng.to_s
|
||||
}
|
||||
end
|
||||
|
||||
it 'converts strings to floats' do
|
||||
expect(result).to include(visit_inside_1, visit_inside_2)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
156
spec/services/visits/finder_spec.rb
Normal file
156
spec/services/visits/finder_spec.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Finder do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#call' do
|
||||
context 'when area selection parameters are provided' do
|
||||
let(:area_params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '48.8534',
|
||||
sw_lng: '2.3380',
|
||||
ne_lat: '48.8667',
|
||||
ne_lng: '2.3580'
|
||||
}
|
||||
end
|
||||
|
||||
it 'delegates to FindWithinBoundingBox service' do
|
||||
bounding_box_finder = instance_double(Visits::FindWithinBoundingBox)
|
||||
expect(Visits::FindWithinBoundingBox).to receive(:new)
|
||||
.with(user, area_params)
|
||||
.and_return(bounding_box_finder)
|
||||
|
||||
expect(bounding_box_finder).to receive(:call)
|
||||
|
||||
described_class.new(user, area_params).call
|
||||
end
|
||||
|
||||
it 'does not call FindInTime service' do
|
||||
expect(Visits::FindWithinBoundingBox).to receive(:new).and_call_original
|
||||
expect(Visits::FindInTime).not_to receive(:new)
|
||||
|
||||
described_class.new(user, area_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when time-based parameters are provided' do
|
||||
let(:time_params) do
|
||||
{
|
||||
start_at: Time.zone.now.beginning_of_day.iso8601,
|
||||
end_at: Time.zone.now.end_of_day.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'delegates to FindInTime service' do
|
||||
time_finder = instance_double(Visits::FindInTime)
|
||||
expect(Visits::FindInTime).to receive(:new)
|
||||
.with(user, time_params)
|
||||
.and_return(time_finder)
|
||||
|
||||
expect(time_finder).to receive(:call)
|
||||
|
||||
described_class.new(user, time_params).call
|
||||
end
|
||||
|
||||
it 'does not call FindWithinBoundingBox service' do
|
||||
expect(Visits::FindInTime).to receive(:new).and_call_original
|
||||
expect(Visits::FindWithinBoundingBox).not_to receive(:new)
|
||||
|
||||
described_class.new(user, time_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when selection is true but coordinates are missing' do
|
||||
let(:incomplete_params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '48.8534'
|
||||
# Missing other coordinates
|
||||
}
|
||||
end
|
||||
|
||||
it 'falls back to FindInTime service' do
|
||||
time_finder = instance_double(Visits::FindInTime)
|
||||
expect(Visits::FindInTime).to receive(:new)
|
||||
.with(user, incomplete_params)
|
||||
.and_return(time_finder)
|
||||
|
||||
expect(time_finder).to receive(:call)
|
||||
|
||||
described_class.new(user, incomplete_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both area and time parameters are provided' do
|
||||
let(:combined_params) do
|
||||
{
|
||||
selection: 'true',
|
||||
sw_lat: '48.8534',
|
||||
sw_lng: '2.3380',
|
||||
ne_lat: '48.8667',
|
||||
ne_lng: '2.3580',
|
||||
start_at: Time.zone.now.beginning_of_day.iso8601,
|
||||
end_at: Time.zone.now.end_of_day.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'prioritizes area search over time search' do
|
||||
bounding_box_finder = instance_double(Visits::FindWithinBoundingBox)
|
||||
expect(Visits::FindWithinBoundingBox).to receive(:new)
|
||||
.with(user, combined_params)
|
||||
.and_return(bounding_box_finder)
|
||||
|
||||
expect(bounding_box_finder).to receive(:call)
|
||||
expect(Visits::FindInTime).not_to receive(:new)
|
||||
|
||||
described_class.new(user, combined_params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'when selection is not "true"' do
|
||||
let(:params) do
|
||||
{
|
||||
selection: 'false', # explicitly not true
|
||||
sw_lat: '48.8534',
|
||||
sw_lng: '2.3380',
|
||||
ne_lat: '48.8667',
|
||||
ne_lng: '2.3580',
|
||||
start_at: Time.zone.now.beginning_of_day.iso8601,
|
||||
end_at: Time.zone.now.end_of_day.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
it 'uses FindInTime service' do
|
||||
expect(Visits::FindInTime).to receive(:new).and_call_original
|
||||
expect(Visits::FindWithinBoundingBox).not_to receive(:new)
|
||||
|
||||
described_class.new(user, params).call
|
||||
end
|
||||
end
|
||||
|
||||
context 'edge cases' do
|
||||
context 'with empty params' do
|
||||
let(:empty_params) { {} }
|
||||
|
||||
it 'uses FindInTime service' do
|
||||
# We need to handle the ArgumentError from FindInTime when params are empty
|
||||
expect(Visits::FindInTime).to receive(:new).and_raise(ArgumentError)
|
||||
expect(Visits::FindWithinBoundingBox).not_to receive(:new)
|
||||
|
||||
expect { described_class.new(user, empty_params).call }.to raise_error(ArgumentError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil params' do
|
||||
let(:nil_params) { nil }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { described_class.new(user, nil_params).call }.to raise_error(NoMethodError)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,30 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::GroupPoints do
|
||||
describe '#group_points_by_radius' do
|
||||
it 'groups points by radius' do
|
||||
day_points = [
|
||||
build(:point, lonlat: 'POINT(0 0)', timestamp: 1.day.ago),
|
||||
build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: 1.day.ago + 1.minute),
|
||||
build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: 1.day.ago + 2.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: 1.day.ago + 3.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: 1.day.ago + 4.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: 1.day.ago + 5.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: 1.day.ago + 6.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: 1.day.ago + 7.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: 1.day.ago + 8.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: 1.day.ago + 9.minutes),
|
||||
build(:point, lonlat: 'POINT(0.001 0.001)', timestamp: 1.day.ago + 10.minutes)
|
||||
]
|
||||
|
||||
grouped_points = described_class.new(day_points).group_points_by_radius
|
||||
|
||||
expect(grouped_points.size).to eq(1)
|
||||
expect(grouped_points.first.size).to eq(10)
|
||||
# The last point is too far from the first point
|
||||
expect(grouped_points.first).not_to include(day_points.last)
|
||||
end
|
||||
end
|
||||
end
|
||||
100
spec/services/visits/merge_service_spec.rb
Normal file
100
spec/services/visits/merge_service_spec.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::MergeService do
|
||||
let(:user) { create(:user) }
|
||||
let(:place) { create(:place) }
|
||||
|
||||
let(:visit1) do
|
||||
create(:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: 2.days.ago,
|
||||
ended_at: 1.day.ago,
|
||||
duration: 1440,
|
||||
name: 'Visit 1',
|
||||
status: 'suggested')
|
||||
end
|
||||
|
||||
let(:visit2) do
|
||||
create(:visit,
|
||||
user: user,
|
||||
place: place,
|
||||
started_at: 1.day.ago,
|
||||
ended_at: Time.current,
|
||||
duration: 1440,
|
||||
name: 'Visit 2',
|
||||
status: 'suggested')
|
||||
end
|
||||
|
||||
let!(:point1) { create(:point, user: user, visit: visit1) }
|
||||
let!(:point2) { create(:point, user: user, visit: visit2) }
|
||||
|
||||
describe '#call' do
|
||||
context 'with valid visits' do
|
||||
it 'merges visits successfully' do
|
||||
service = described_class.new([visit1, visit2])
|
||||
result = service.call
|
||||
|
||||
expect(result).to be_persisted
|
||||
expect(result.id).to eq(visit1.id)
|
||||
expect(result.started_at).to eq(visit1.started_at)
|
||||
expect(result.ended_at).to eq(visit2.ended_at)
|
||||
expect(result.status).to eq('confirmed')
|
||||
expect(result.points.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'deletes the second visit' do
|
||||
service = described_class.new([visit1, visit2])
|
||||
service.call
|
||||
|
||||
expect { visit2.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'creates a combined name for the merged visit' do
|
||||
visit1_name = visit1.name
|
||||
visit2_name = visit2.name
|
||||
service = described_class.new([visit1, visit2])
|
||||
result = service.call
|
||||
|
||||
expected_name = "Combined Visit (#{visit1_name}, #{visit2_name})"
|
||||
expect(result.name).to eq(expected_name)
|
||||
end
|
||||
|
||||
it 'calculates the correct duration' do
|
||||
service = described_class.new([visit1, visit2])
|
||||
result = service.call
|
||||
|
||||
# Total duration should be from earliest start to latest end
|
||||
expected_duration = ((visit2.ended_at - visit1.started_at) / 60).round
|
||||
expect(result.duration).to eq(expected_duration)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with less than 2 visits' do
|
||||
it 'returns nil and adds an error' do
|
||||
service = described_class.new([visit1])
|
||||
result = service.call
|
||||
|
||||
expect(result).to be_nil
|
||||
expect(service.errors).to include('At least 2 visits must be selected for merging')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a database error occurs' do
|
||||
before do
|
||||
allow(visit1).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(visit1))
|
||||
allow(visit1).to receive_message_chain(:errors, :full_messages, :join).and_return('Error message')
|
||||
end
|
||||
|
||||
it 'handles ActiveRecord errors' do
|
||||
service = described_class.new([visit1, visit2])
|
||||
result = service.call
|
||||
|
||||
expect(result).to be_nil
|
||||
expect(service.errors).to include('Error message')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
80
spec/services/visits/merger_spec.rb
Normal file
80
spec/services/visits/merger_spec.rb
Normal file
@@ -0,0 +1,80 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Merger do
|
||||
let(:user) { create(:user) }
|
||||
let(:points) { double('Points') }
|
||||
|
||||
subject { described_class.new(points) }
|
||||
|
||||
describe '#merge_visits' do
|
||||
let(:visit1) do
|
||||
{
|
||||
start_time: 2.hours.ago.to_i,
|
||||
end_time: 1.hour.ago.to_i,
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
points: [double('Point1'), double('Point2')]
|
||||
}
|
||||
end
|
||||
|
||||
let(:visit2) do
|
||||
{
|
||||
start_time: 50.minutes.ago.to_i,
|
||||
end_time: 40.minutes.ago.to_i,
|
||||
center_lat: 40.7129,
|
||||
center_lon: -74.0061,
|
||||
points: [double('Point3'), double('Point4')]
|
||||
}
|
||||
end
|
||||
|
||||
let(:visit3) do
|
||||
{
|
||||
start_time: 30.minutes.ago.to_i,
|
||||
end_time: 20.minutes.ago.to_i,
|
||||
center_lat: 40.7500,
|
||||
center_lon: -74.0500,
|
||||
points: [double('Point5'), double('Point6')]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when visits can be merged' do
|
||||
let(:visits) { [visit1, visit2, visit3] }
|
||||
before do
|
||||
allow(subject).to receive(:can_merge_visits?).with(visit1, visit2).and_return(true)
|
||||
allow(subject).to receive(:can_merge_visits?).with(anything, visit3).and_return(false)
|
||||
end
|
||||
|
||||
it 'merges consecutive visits that meet criteria' do
|
||||
merged = subject.merge_visits(visits)
|
||||
|
||||
expect(merged.size).to eq(2)
|
||||
expect(merged.first[:points].size).to eq(4)
|
||||
expect(merged.first[:end_time]).to eq(visit2[:end_time])
|
||||
expect(merged.last).to eq(visit3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visits cannot be merged' do
|
||||
let(:visits) { [visit1, visit2, visit3] }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:can_merge_visits?).and_return(false)
|
||||
end
|
||||
|
||||
it 'keeps visits separate' do
|
||||
merged = subject.merge_visits(visits)
|
||||
|
||||
expect(merged.size).to eq(3)
|
||||
expect(merged).to eq(visits)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty visits array' do
|
||||
it 'returns an empty array' do
|
||||
expect(subject.merge_visits([])).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
278
spec/services/visits/place_finder_spec.rb
Normal file
278
spec/services/visits/place_finder_spec.rb
Normal file
@@ -0,0 +1,278 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::PlaceFinder do
|
||||
let(:user) { create(:user) }
|
||||
let(:latitude) { 40.7128 }
|
||||
let(:longitude) { -74.0060 }
|
||||
|
||||
subject { described_class.new(user) }
|
||||
|
||||
describe '#find_or_create_place' do
|
||||
let(:visit_data) do
|
||||
{
|
||||
center_lat: latitude,
|
||||
center_lon: longitude,
|
||||
suggested_name: 'Test Place',
|
||||
points: []
|
||||
}
|
||||
end
|
||||
|
||||
context 'when an existing place is found' do
|
||||
let!(:existing_place) { create(:place, latitude: latitude, longitude: longitude) }
|
||||
|
||||
it 'returns the existing place as main_place' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result).to be_a(Hash)
|
||||
expect(result[:main_place]).to eq(existing_place)
|
||||
end
|
||||
|
||||
it 'includes suggested places in the result' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:suggested_places]).to respond_to(:each)
|
||||
expect(result[:suggested_places]).to include(existing_place)
|
||||
end
|
||||
|
||||
it 'finds an existing place by name within search radius' do
|
||||
similar_named_place = create(:place,
|
||||
name: 'Test Place',
|
||||
latitude: latitude + 0.0001,
|
||||
longitude: longitude + 0.0001)
|
||||
|
||||
allow(subject).to receive(:find_existing_place).and_return(similar_named_place)
|
||||
|
||||
modified_visit_data = visit_data.merge(
|
||||
center_lat: latitude + 0.0002,
|
||||
center_lon: longitude + 0.0002
|
||||
)
|
||||
|
||||
result = subject.find_or_create_place(modified_visit_data)
|
||||
|
||||
expect(result[:main_place]).to eq(similar_named_place)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with places from points data' do
|
||||
let(:point_with_geodata) do
|
||||
build_stubbed(:point,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
geodata: {
|
||||
'properties' => {
|
||||
'name' => 'POI from Point',
|
||||
'city' => 'New York',
|
||||
'country' => 'USA'
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
let(:visit_data_with_points) do
|
||||
visit_data.merge(points: [point_with_geodata])
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([])
|
||||
allow(subject).to receive(:fetch_places_from_api).and_return([])
|
||||
end
|
||||
|
||||
it 'extracts and creates places from point geodata' do
|
||||
allow(subject).to receive(:create_place_from_point).and_call_original
|
||||
|
||||
expect do
|
||||
result = subject.find_or_create_place(visit_data_with_points)
|
||||
expect(result[:main_place].name).to include('POI from Point')
|
||||
end.to change(Place, :count).by(1)
|
||||
|
||||
expect(subject).to have_received(:create_place_from_point)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no existing place is found' do
|
||||
let(:geocoder_result) do
|
||||
double(
|
||||
data: {
|
||||
'properties' => {
|
||||
'name' => 'Test Location',
|
||||
'street' => 'Test Street',
|
||||
'city' => 'Test City',
|
||||
'country' => 'Test Country'
|
||||
}
|
||||
},
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
)
|
||||
end
|
||||
|
||||
let(:other_geocoder_result) do
|
||||
double(
|
||||
data: {
|
||||
'properties' => {
|
||||
'name' => 'Other Location',
|
||||
'street' => 'Other Street',
|
||||
'city' => 'Test City',
|
||||
'country' => 'Test Country'
|
||||
}
|
||||
},
|
||||
latitude: latitude + 0.001,
|
||||
longitude: longitude + 0.001
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([geocoder_result, other_geocoder_result])
|
||||
end
|
||||
|
||||
it 'creates a new place with geocoded data' do
|
||||
expect do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
expect(result[:main_place].name).to include('Test Location')
|
||||
end.to change(Place, :count).by(2)
|
||||
|
||||
place = Place.find_by_name('Test Location, Test Street, Test City')
|
||||
|
||||
expect(place.city).to eq('Test City')
|
||||
expect(place.country).to eq('Test Country')
|
||||
expect(place.source).to eq('photon')
|
||||
end
|
||||
|
||||
it 'returns both main place and suggested places' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:main_place].name).to include('Test Location')
|
||||
expect(result[:suggested_places].length).to eq(2)
|
||||
|
||||
expect(result[:suggested_places].map(&:name)).to include(
|
||||
'Test Location, Test Street, Test City',
|
||||
'Other Location, Other Street, Test City'
|
||||
)
|
||||
end
|
||||
|
||||
context 'when geocoding returns no results' do
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([])
|
||||
end
|
||||
|
||||
it 'creates a place with the suggested name' do
|
||||
expect do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
expect(result[:main_place].name).to eq('Test Place')
|
||||
end.to change(Place, :count).by(1)
|
||||
|
||||
place = Place.last
|
||||
expect(place.name).to eq('Test Place')
|
||||
expect(place.source).to eq('manual')
|
||||
end
|
||||
|
||||
it 'returns the created place as both main and the only suggested place' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:main_place].name).to eq('Test Place')
|
||||
expect(result[:suggested_places]).to eq([result[:main_place]])
|
||||
end
|
||||
|
||||
it 'falls back to default name when suggested name is missing' do
|
||||
visit_data_without_name = visit_data.merge(suggested_name: nil)
|
||||
|
||||
result = subject.find_or_create_place(visit_data_without_name)
|
||||
|
||||
expect(result[:main_place].name).to eq(Place::DEFAULT_NAME)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple potential places' do
|
||||
let!(:place1) { create(:place, name: 'Place 1', latitude: latitude, longitude: longitude) }
|
||||
let!(:place2) { create(:place, name: 'Place 2', latitude: latitude + 0.0005, longitude: longitude + 0.0005) }
|
||||
let!(:place3) { create(:place, name: 'Place 3', latitude: latitude + 0.001, longitude: longitude + 0.001) }
|
||||
|
||||
it 'selects the closest place as main_place' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:main_place]).to eq(place1)
|
||||
end
|
||||
|
||||
it 'includes nearby places as suggested_places' do
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
expect(result[:suggested_places]).to include(place1, place2)
|
||||
# place3 might be outside the search radius depending on the constants defined
|
||||
end
|
||||
|
||||
it 'may include places with the same name' do
|
||||
dup_place = create(:place, name: 'Place 1', latitude: latitude + 0.0002, longitude: longitude + 0.0002)
|
||||
|
||||
allow(subject).to receive(:place_name_exists?).and_return(false)
|
||||
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
names = result[:suggested_places].map(&:name)
|
||||
expect(names.count('Place 1')).to be >= 1
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API place creation failures' do
|
||||
let(:invalid_geocoder_result) do
|
||||
double(
|
||||
data: {
|
||||
'properties' => {
|
||||
# Missing required fields
|
||||
}
|
||||
},
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return([invalid_geocoder_result])
|
||||
end
|
||||
|
||||
it 'gracefully handles errors in place creation' do
|
||||
allow(subject).to receive(:create_place_from_api_result).and_call_original
|
||||
|
||||
result = subject.find_or_create_place(visit_data)
|
||||
|
||||
# Should create the default place
|
||||
expect(result[:main_place].name).to eq('Test Place')
|
||||
expect(result[:main_place].source).to eq('manual')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'private methods' do
|
||||
context '#build_place_name' do
|
||||
it 'combines name components correctly' do
|
||||
properties = {
|
||||
'name' => 'Coffee Shop',
|
||||
'street' => 'Main St',
|
||||
'housenumber' => '123',
|
||||
'city' => 'New York'
|
||||
}
|
||||
|
||||
name = subject.send(:build_place_name, properties)
|
||||
expect(name).to eq('Coffee Shop, Main St, 123, New York')
|
||||
end
|
||||
|
||||
it 'removes duplicate components' do
|
||||
properties = {
|
||||
'name' => 'Coffee Shop',
|
||||
'street' => 'Coffee Shop', # Duplicate of name
|
||||
'city' => 'New York'
|
||||
}
|
||||
|
||||
name = subject.send(:build_place_name, properties)
|
||||
expect(name).to eq('Coffee Shop, New York')
|
||||
end
|
||||
|
||||
it 'returns default name when no components are available' do
|
||||
properties = { 'other' => 'irrelevant' }
|
||||
|
||||
name = subject.send(:build_place_name, properties)
|
||||
expect(name).to eq(Place::DEFAULT_NAME)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,49 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Prepare do
|
||||
describe '#call' do
|
||||
let(:static_time) { Time.zone.local(2021, 1, 1, 0, 0, 0) }
|
||||
let(:points) do
|
||||
[
|
||||
build(:point, lonlat: 'POINT(0 0)', timestamp: static_time),
|
||||
build(:point, lonlat: 'POINT(0.00001 0.00001)', timestamp: static_time + 5.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00002 0.00002)', timestamp: static_time + 10.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00003 0.00003)', timestamp: static_time + 15.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00004 0.00004)', timestamp: static_time + 20.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00005 0.00005)', timestamp: static_time + 25.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00006 0.00006)', timestamp: static_time + 30.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00007 0.00007)', timestamp: static_time + 35.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00008 0.00008)', timestamp: static_time + 40.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00009 0.00009)', timestamp: static_time + 45.minutes),
|
||||
build(:point, lonlat: 'POINT(0.0001 0.0001)', timestamp: static_time + 50.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 55.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 95.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 100.minutes),
|
||||
build(:point, lonlat: 'POINT(0.00011 0.00011)', timestamp: static_time + 105.minutes)
|
||||
]
|
||||
end
|
||||
|
||||
subject { described_class.new(points).call }
|
||||
|
||||
it 'returns correct visits' do
|
||||
expect(subject).to eq [
|
||||
{
|
||||
date: static_time.to_date.to_s,
|
||||
visits: [
|
||||
{
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
radius: 10,
|
||||
points:,
|
||||
duration: 105,
|
||||
started_at: static_time.to_s,
|
||||
ended_at: (static_time + 105.minutes).to_s
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
45
spec/services/visits/smart_detect_spec.rb
Normal file
45
spec/services/visits/smart_detect_spec.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::SmartDetect do
|
||||
let(:user) { create(:user) }
|
||||
let(:start_at) { 1.day.ago }
|
||||
let(:end_at) { Time.current }
|
||||
let(:points) { create_list(:point, 5, user: user, timestamp: 2.hours.ago) }
|
||||
|
||||
subject { described_class.new(user, start_at: start_at, end_at: end_at) }
|
||||
|
||||
describe '#call' do
|
||||
context 'when there are no points' do
|
||||
it 'returns an empty array' do
|
||||
expect(subject.call).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are points' do
|
||||
let(:visit_detector) { instance_double(Visits::Detector) }
|
||||
let(:visit_merger) { instance_double(Visits::Merger) }
|
||||
let(:visit_creator) { instance_double(Visits::Creator) }
|
||||
let(:potential_visits) { [{ id: 1 }] }
|
||||
let(:merged_visits) { [{ id: 2 }] }
|
||||
let(:grouped_visits) { [[{ id: 3 }]] }
|
||||
let(:created_visits) { [instance_double(Visit)] }
|
||||
|
||||
before do
|
||||
allow(user).to receive_message_chain(:tracked_points, :not_visited, :order, :where).and_return(points)
|
||||
allow(Visits::Detector).to receive(:new).with(points).and_return(visit_detector)
|
||||
allow(Visits::Merger).to receive(:new).with(points).and_return(visit_merger)
|
||||
allow(Visits::Creator).to receive(:new).with(user).and_return(visit_creator)
|
||||
allow(visit_detector).to receive(:detect_potential_visits).and_return(potential_visits)
|
||||
allow(visit_merger).to receive(:merge_visits).with(potential_visits).and_return(merged_visits)
|
||||
allow(subject).to receive(:group_nearby_visits).with(merged_visits).and_return(grouped_visits)
|
||||
allow(visit_creator).to receive(:create_visits).with([{ id: 3 }]).and_return(created_visits)
|
||||
end
|
||||
|
||||
it 'delegates to the appropriate services' do
|
||||
expect(subject.call).to eq(created_visits)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,11 +5,12 @@ require 'rails_helper'
|
||||
RSpec.describe Visits::Suggest do
|
||||
describe '#call' do
|
||||
let!(:user) { create(:user) }
|
||||
let(:start_at) { Time.new(2020, 1, 1, 0, 0, 0) }
|
||||
let(:end_at) { Time.new(2020, 1, 1, 2, 0, 0) }
|
||||
let(:start_at) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
|
||||
let(:end_at) { Time.zone.local(2020, 1, 1, 2, 0, 0) }
|
||||
|
||||
let!(:points) do
|
||||
[
|
||||
# first visit
|
||||
create(:point, :with_known_location, user:, timestamp: start_at),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 5.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 10.minutes),
|
||||
@@ -22,20 +23,73 @@ RSpec.describe Visits::Suggest do
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 45.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 50.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 55.minutes),
|
||||
# end of first visit
|
||||
# second visit
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 95.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 100.minutes),
|
||||
create(:point, :with_known_location, user:, timestamp: start_at + 105.minutes)
|
||||
# end of second visit
|
||||
]
|
||||
end
|
||||
|
||||
let(:geocoder_struct) do
|
||||
Struct.new(:data) do
|
||||
def data
|
||||
{
|
||||
"features": [
|
||||
{
|
||||
"geometry": {
|
||||
"coordinates": [
|
||||
37.6175406,
|
||||
55.7559395
|
||||
],
|
||||
"type": 'Point'
|
||||
},
|
||||
"type": 'Feature',
|
||||
"properties": {
|
||||
"osm_id": 681_354_082,
|
||||
"extent": [
|
||||
37.6175406,
|
||||
55.7559395,
|
||||
37.6177036,
|
||||
55.755847
|
||||
],
|
||||
"country": 'Russia',
|
||||
"city": 'Moscow',
|
||||
"countrycode": 'RU',
|
||||
"postcode": '103265',
|
||||
"type": 'street',
|
||||
"osm_type": 'W',
|
||||
"osm_key": 'highway',
|
||||
"district": 'Tverskoy',
|
||||
"osm_value": 'pedestrian',
|
||||
"name": 'проезд Воскресенские Ворота',
|
||||
"state": 'Moscow'
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": 'FeatureCollection'
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
let(:geocoder_response) do
|
||||
[geocoder_struct.new]
|
||||
end
|
||||
|
||||
subject { described_class.new(user, start_at:, end_at:).call }
|
||||
|
||||
before do
|
||||
allow(Geocoder).to receive(:search).and_return(geocoder_response)
|
||||
end
|
||||
|
||||
it 'creates places' do
|
||||
expect { subject }.to change(Place, :count).by(1)
|
||||
end
|
||||
|
||||
it 'creates visits' do
|
||||
expect { subject }.to change(Visit, :count).by(1)
|
||||
expect { subject }.to change(Visit, :count).by(2)
|
||||
end
|
||||
|
||||
it 'creates visits notification' do
|
||||
@@ -48,9 +102,7 @@ RSpec.describe Visits::Suggest do
|
||||
end
|
||||
|
||||
it 'reverse geocodes visits' do
|
||||
expect_any_instance_of(Visit).to receive(:async_reverse_geocode).and_call_original
|
||||
|
||||
subject
|
||||
expect { subject }.to have_enqueued_job(ReverseGeocodingJob).exactly(2).times
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
161
spec/services/visits/time_chunks_spec.rb
Normal file
161
spec/services/visits/time_chunks_spec.rb
Normal file
@@ -0,0 +1,161 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::TimeChunks do
|
||||
describe '#call' do
|
||||
context 'with a multi-year span' do
|
||||
it 'splits time correctly across year boundaries' do
|
||||
# Span over multiple years
|
||||
start_at = DateTime.new(2020, 6, 15)
|
||||
end_at = DateTime.new(2023, 3, 10)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
# Should have 4 chunks:
|
||||
# 1. 2020-06-15 to 2021-01-01
|
||||
# 2. 2021-01-01 to 2022-01-01
|
||||
# 3. 2022-01-01 to 2023-01-01
|
||||
# 4. 2023-01-01 to 2023-03-10
|
||||
expect(chunks.size).to eq(4)
|
||||
|
||||
# First chunk: partial year (Jun 15 - Jan 1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_day)
|
||||
|
||||
# Second chunk: full year 2021
|
||||
expect(chunks[1].begin).to eq(DateTime.new(2021, 1, 1).beginning_of_year)
|
||||
expect(chunks[1].end).to eq(DateTime.new(2021, 12, 31).end_of_year)
|
||||
|
||||
# Third chunk: full year 2022
|
||||
expect(chunks[2].begin).to eq(DateTime.new(2022, 1, 1).beginning_of_year)
|
||||
expect(chunks[2].end).to eq(DateTime.new(2022, 12, 31).end_of_year)
|
||||
|
||||
# Fourth chunk: partial year (Jan 1 - Mar 10, 2023)
|
||||
expect(chunks[3].begin).to eq(DateTime.new(2023, 1, 1).beginning_of_year)
|
||||
expect(chunks[3].end).to eq(end_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a span within a single year' do
|
||||
it 'creates a single chunk ending at year end' do
|
||||
start_at = DateTime.new(2020, 3, 15)
|
||||
end_at = DateTime.new(2020, 10, 20)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
# The implementation appears to extend to the end of the year
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with spans exactly on year boundaries' do
|
||||
it 'creates one chunk per year ending at next year start' do
|
||||
start_at = DateTime.new(2020, 1, 1)
|
||||
end_at = DateTime.new(2022, 12, 31).end_of_day
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(3)
|
||||
|
||||
# Three full years, each ending at the start of the next year
|
||||
expect(chunks[0].begin).to eq(DateTime.new(2020, 1, 1).beginning_of_year)
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_year)
|
||||
|
||||
expect(chunks[1].begin).to eq(DateTime.new(2021, 1, 1).beginning_of_year)
|
||||
expect(chunks[1].end).to eq(DateTime.new(2021, 12, 31).end_of_year)
|
||||
|
||||
expect(chunks[2].begin).to eq(DateTime.new(2022, 1, 1).beginning_of_year)
|
||||
expect(chunks[2].end).to eq(DateTime.new(2022, 12, 31).end_of_year)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with start and end dates in the same day' do
|
||||
it 'returns a single chunk ending at the end of the year' do
|
||||
date = DateTime.new(2020, 5, 15)
|
||||
start_at = date.beginning_of_day
|
||||
end_at = date.end_of_day
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
# Implementation extends to end of year
|
||||
expect(chunks[0].end).to eq(DateTime.new(2020, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a full single year' do
|
||||
it 'returns a single chunk for the entire year' do
|
||||
start_at = DateTime.new(2020, 1, 1).beginning_of_day
|
||||
end_at = DateTime.new(2020, 12, 31).end_of_day
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
expect(chunks[0].end).to eq(end_at)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with dates spanning a decade' do
|
||||
it 'creates appropriate chunks for each year ending at next year start' do
|
||||
start_at = DateTime.new(2020, 1, 1)
|
||||
end_at = DateTime.new(2030, 12, 31)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
# Should have 11 chunks (2020 through 2030)
|
||||
expect(chunks.size).to eq(11)
|
||||
|
||||
# Check first and last chunks
|
||||
expect(chunks.first.begin).to eq(start_at)
|
||||
expect(chunks.last.end).to eq(end_at)
|
||||
|
||||
# Check that each chunk starts on Jan 1 and ends on next Jan 1 (except last)
|
||||
(1...chunks.size - 1).each do |i|
|
||||
year = 2020 + i
|
||||
expect(chunks[i].begin).to eq(DateTime.new(year, 1, 1).beginning_of_year)
|
||||
expect(chunks[i].end).to eq(DateTime.new(year, 12, 31).end_of_year)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with start date after end date' do
|
||||
it 'still creates a chunk for start date year' do
|
||||
start_at = DateTime.new(2023, 1, 1)
|
||||
end_at = DateTime.new(2020, 1, 1)
|
||||
|
||||
service = described_class.new(start_at: start_at, end_at: end_at)
|
||||
chunks = service.call
|
||||
|
||||
# The implementation creates one chunk for the start date year
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(start_at)
|
||||
expect(chunks[0].end).to eq(DateTime.new(2023, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when start date equals end date' do
|
||||
it 'returns a single chunk extending to year end' do
|
||||
date = DateTime.new(2022, 6, 15, 12, 30)
|
||||
|
||||
service = described_class.new(start_at: date, end_at: date)
|
||||
chunks = service.call
|
||||
|
||||
expect(chunks.size).to eq(1)
|
||||
expect(chunks[0].begin).to eq(date)
|
||||
# Implementation extends to end of year
|
||||
expect(chunks[0].end).to eq(DateTime.new(2022, 12, 31).end_of_day)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user