Merge remote-tracking branch 'origin' into feature/subscription

This commit is contained in:
Eugene Burmakin
2025-03-18 16:50:26 +01:00
88 changed files with 8511 additions and 601 deletions

View File

@@ -1 +1 @@
0.24.2
0.25.0

View File

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

View File

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

View File

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

View File

@@ -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%;
}

View 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

View File

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

View File

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

View File

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

View File

@@ -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`;
}
}

View File

@@ -46,4 +46,9 @@ export default class extends BaseController {
element.textContent = newName;
});
}
updateAll(event) {
const newName = event.detail.name;
this.updateVisitNameOnPage(newName);
}
}

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View 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

View 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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View 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

View 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

View 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

View File

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

View 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

View 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

View 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

View File

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

View 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

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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&nbsp;&amp;&nbsp;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&nbsp;&amp;&nbsp;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">

View File

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

View File

@@ -1 +0,0 @@
8VIWnwlEhKkoPvsVuDII3z+D/YN83w/iLZd69QIm4Z0FMrSt6LPidLsgnnW81Rx44Z+0al/MWDUNGpYKa4dCSy01g+Pjdez4BrNLR4qGlRXruAZkapI78/J9r1ynyGf9GRW7c+kimRngPTg/enInUlo8wGrW/P2KhKPqn1tcUzKl4pyy2eD+BELblrwG2k96FxA7NmR6NDvB1K9OlLpAHiA0AVxuSKlXweX/Q5lCZsAeWFN1tlieGJABeadG/AnpWT53vigyvdYyqGactxhh6kkFU+baNj0ELwrAqD3bjTD/haqgiH2ZqjlqjNxLVdJdcHUGqs6jS9MziwRouRo8AbYRZz++BH0ZHslkhdSWm68DH7xpLGL5MXTqBF6uHv8edcHleZM9ThfKsO68M7GADzHvsIBJYZEbeDPh--ggwrEpNtVdYbWPMO--NI2ABLK+rU+9YBFeVjWbEQ==

View File

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

View File

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

View 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

View 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

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

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

File diff suppressed because it is too large Load Diff

View File

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

View 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

View 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

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View 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

View 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

View File

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

View 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

View File

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

View 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

View 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

View 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

View 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

View 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

View File

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

View 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

View 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

View 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

View File

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

View 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

View File

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

View 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