Implement visits merging

This commit is contained in:
Eugene Burmakin 2025-03-05 20:04:26 +01:00
parent c00bd2e387
commit 6b356d24b1
24 changed files with 1420 additions and 868 deletions

View file

@ -6,14 +6,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
# 0.24.2 - 2025-02-24
TODO:
- [ ] Allow user to see all suggested visits in specific area on the map regardless of time period. (clustering?) #921
- [ ] Allow user to merge visits #909
- [ ] Accepted Visits are not remembered #848
- [ ] Flash z-index is under visits drawer
- [ ] There are still duplicates in the visits places?
## Added
- 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.

File diff suppressed because one or more lines are too long

View file

@ -51,7 +51,7 @@
position: absolute;
top: 0;
right: 0;
width: 320px;
width: 338px;
height: 100%;
background: rgba(255, 255, 255, 0.5);
transform: translateX(100%);
@ -73,5 +73,5 @@
}
.controls-shifted {
right: 320px !important;
right: 338px !important;
}

View file

@ -34,12 +34,65 @@ class Api::V1::VisitsController < ApiController
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::BulkUpdateService.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, :status)
end
def merge_params
params.permit(visit_ids: [])
end
def bulk_update_params
params.permit(:status, visit_ids: [])
end
def update_visit(visit)
visit_params.each do |key, value|
visit[key] = value

View file

@ -129,7 +129,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
@ -1340,4 +1342,3 @@ export default class extends BaseController {
container.innerHTML = html;
}
}

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

@ -9,6 +9,7 @@ export class VisitsManager {
this.map = map;
this.apiKey = apiKey;
this.visitCircles = L.layerGroup();
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
this.currentPopup = null;
this.drawerOpen = false;
}
@ -68,8 +69,8 @@ export class VisitsManager {
*/
toggleDrawer() {
this.drawerOpen = !this.drawerOpen;
let drawer = document.getElementById('visits-drawer');
let drawer = document.querySelector('.leaflet-drawer');
if (!drawer) {
drawer = this.createDrawer();
}
@ -89,6 +90,15 @@ export class VisitsManager {
// Update the drawer content if it's being opened
if (this.drawerOpen) {
this.fetchAndDisplayVisits();
// Show the suggested visits layer when drawer is open
if (!this.map.hasLayer(this.visitCircles)) {
this.map.addLayer(this.visitCircles);
}
} else {
// Hide the suggested visits layer when drawer is closed
if (this.map.hasLayer(this.visitCircles)) {
this.map.removeLayer(this.visitCircles);
}
}
}
@ -99,7 +109,7 @@ export class VisitsManager {
createDrawer() {
const drawer = document.createElement('div');
drawer.id = 'visits-drawer';
drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-50 overflow-y-auto leaflet-drawer';
drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer';
// Add styles to make the drawer scrollable
drawer.style.overflowY = 'auto';
@ -151,6 +161,17 @@ export class VisitsManager {
const visits = await response.json();
this.displayVisits(visits);
// Ensure the suggested visits layer visibility matches the drawer state
if (this.drawerOpen) {
if (!this.map.hasLayer(this.visitCircles)) {
this.map.addLayer(this.visitCircles);
}
} else {
if (this.map.hasLayer(this.visitCircles)) {
this.map.removeLayer(this.visitCircles);
}
}
} catch (error) {
console.error('Error fetching visits:', error);
const container = document.getElementById('visits-list');
@ -175,13 +196,16 @@ export class VisitsManager {
// Clear existing visit circles
this.visitCircles.clearLayers();
this.confirmedVisitCircles.clearLayers();
// Draw circles for all visits
visits
.filter(visit => visit.status !== 'declined')
.forEach(visit => {
if (visit.place?.latitude && visit.place?.longitude) {
const isConfirmed = visit.status === 'confirmed';
const isSuggested = visit.status === 'suggested';
const circle = L.circle([visit.place.latitude, visit.place.longitude], {
color: isSuggested ? '#FFA500' : '#4A90E2', // Border color
fillColor: isSuggested ? '#FFD700' : '#4A90E2', // Fill color
@ -194,8 +218,12 @@ export class VisitsManager {
dashArray: isSuggested ? '4' : null // Dotted border for suggested
});
// Add the circle to the map
this.visitCircles.addLayer(circle);
// Add the circle to the appropriate layer
if (isConfirmed) {
this.confirmedVisitCircles.addLayer(circle);
} else {
this.visitCircles.addLayer(circle);
}
// Attach click event to the circle
circle.on('click', () => this.fetchPossiblePlaces(visit));
@ -233,15 +261,18 @@ export class VisitsManager {
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
return `
<div class="w-full p-3 rounded-lg hover:bg-base-300 transition-colors visit-item ${bgClass}"
<div class="w-full p-3 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
style="${visitStyle}"
data-lat="${visit.place?.latitude || ''}"
data-lng="${visit.place?.longitude || ''}"
data-id="${visit.id}">
<div class="font-semibold overflow-hidden text-ellipsis whitespace-nowrap" title="${visit.name}">${this.truncateText(visit.name, 30)}</div>
<div class="absolute top-2 left-2 opacity-0 transition-opacity duration-200 visit-checkbox-container">
<input type="checkbox" class="checkbox checkbox-sm visit-checkbox" data-id="${visit.id}">
</div>
<div class="font-semibold overflow-hidden text-ellipsis whitespace-nowrap pl-6" title="${visit.name}">${this.truncateText(visit.name, 30)}</div>
<div class="text-sm text-gray-600">
${timeDisplay.trim()}
<span class="text-gray-500">(${durationText})</span>
<div class="text-gray-500">(${durationText})</div>
</div>
${visit.place?.city ? `<div class="text-sm">${visit.place.city}, ${visit.place.country}</div>` : ''}
${visit.status !== 'confirmed' ? `
@ -265,6 +296,273 @@ export class VisitsManager {
// Add click handlers to visit items and buttons
this.addVisitItemEventListeners(container);
// Add merge functionality
this.setupMergeFunctionality(container);
// Ensure all checkboxes are hidden by default
container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => {
checkboxContainer.style.opacity = '0';
checkboxContainer.style.pointerEvents = 'none';
});
}
/**
* Sets up the merge functionality for visits
* @param {HTMLElement} container - The container with visit items
*/
setupMergeFunctionality(container) {
const visitItems = container.querySelectorAll('.visit-item');
// Add hover event to show checkboxes
visitItems.forEach(item => {
// Show checkbox on hover only if no checkboxes are currently checked
item.addEventListener('mouseenter', () => {
const allChecked = container.querySelectorAll('.visit-checkbox:checked');
if (allChecked.length === 0) {
const checkbox = item.querySelector('.visit-checkbox-container');
if (checkbox) {
checkbox.style.opacity = '1';
checkbox.style.pointerEvents = 'auto';
}
}
});
// Hide checkbox on mouse leave if not checked and if no other checkboxes are checked
item.addEventListener('mouseleave', () => {
const allChecked = container.querySelectorAll('.visit-checkbox:checked');
if (allChecked.length === 0) {
const checkbox = item.querySelector('.visit-checkbox-container');
const checkboxInput = item.querySelector('.visit-checkbox');
if (checkbox && checkboxInput && !checkboxInput.checked) {
checkbox.style.opacity = '0';
checkbox.style.pointerEvents = 'none';
}
}
});
});
// Add change event to checkboxes
const checkboxes = container.querySelectorAll('.visit-checkbox');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.updateMergeUI(container);
});
});
}
/**
* Updates the merge UI based on selected checkboxes
* @param {HTMLElement} container - The container with visit items
*/
updateMergeUI(container) {
// Remove any existing action buttons
const existingActionButtons = container.querySelector('.visit-bulk-actions');
if (existingActionButtons) {
existingActionButtons.remove();
}
// Get all checked checkboxes
const checkedBoxes = container.querySelectorAll('.visit-checkbox:checked');
// Hide all checkboxes first
container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => {
checkboxContainer.style.opacity = '0';
checkboxContainer.style.pointerEvents = 'none';
});
// If no checkboxes are checked, we're done
if (checkedBoxes.length === 0) {
return;
}
// Get all visit items and their data
const visitItems = Array.from(container.querySelectorAll('.visit-item'));
// For each checked visit, show checkboxes for adjacent visits
Array.from(checkedBoxes).forEach(checkbox => {
const visitItem = checkbox.closest('.visit-item');
const visitId = checkbox.dataset.id;
const index = visitItems.indexOf(visitItem);
// Show checkbox for the current visit
const currentCheckbox = visitItem.querySelector('.visit-checkbox-container');
if (currentCheckbox) {
currentCheckbox.style.opacity = '1';
currentCheckbox.style.pointerEvents = 'auto';
}
// Show checkboxes for visits above and below
// Above visit
if (index > 0) {
const aboveVisitItem = visitItems[index - 1];
const aboveCheckbox = aboveVisitItem.querySelector('.visit-checkbox-container');
if (aboveCheckbox) {
aboveCheckbox.style.opacity = '1';
aboveCheckbox.style.pointerEvents = 'auto';
}
}
// Below visit
if (index < visitItems.length - 1) {
const belowVisitItem = visitItems[index + 1];
const belowCheckbox = belowVisitItem.querySelector('.visit-checkbox-container');
if (belowCheckbox) {
belowCheckbox.style.opacity = '1';
belowCheckbox.style.pointerEvents = 'auto';
}
}
});
// If 2 or more checkboxes are checked, show action buttons
if (checkedBoxes.length >= 2) {
// Find the lowest checked visit item
let lowestVisitItem = null;
let lowestPosition = -1;
checkedBoxes.forEach(checkbox => {
const visitItem = checkbox.closest('.visit-item');
const position = visitItems.indexOf(visitItem);
if (lowestPosition === -1 || position > lowestPosition) {
lowestPosition = position;
lowestVisitItem = visitItem;
}
});
// Create action buttons container
if (lowestVisitItem) {
// Create a container for the action buttons to ensure proper spacing
const actionsContainer = document.createElement('div');
actionsContainer.className = 'w-full p-2 visit-bulk-actions';
// Create button grid
const buttonGrid = document.createElement('div');
buttonGrid.className = 'grid grid-cols-3 gap-2';
// Merge button
const mergeButton = document.createElement('button');
mergeButton.className = 'btn btn-xs btn-primary';
mergeButton.textContent = 'Merge';
mergeButton.addEventListener('click', () => {
this.mergeVisits(Array.from(checkedBoxes).map(cb => cb.dataset.id));
});
// Confirm button
const confirmButton = document.createElement('button');
confirmButton.className = 'btn btn-xs btn-success';
confirmButton.textContent = 'Confirm';
confirmButton.addEventListener('click', () => {
this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'confirmed');
});
// Decline button
const declineButton = document.createElement('button');
declineButton.className = 'btn btn-xs btn-error';
declineButton.textContent = 'Decline';
declineButton.addEventListener('click', () => {
this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'declined');
});
// Add buttons to grid
buttonGrid.appendChild(mergeButton);
buttonGrid.appendChild(confirmButton);
buttonGrid.appendChild(declineButton);
// Add selection count text
const selectionText = document.createElement('div');
selectionText.className = 'text-sm text-center mt-1 text-gray-500';
selectionText.textContent = `${checkedBoxes.length} visits selected`;
// Add elements to container
actionsContainer.appendChild(buttonGrid);
actionsContainer.appendChild(selectionText);
// Insert after the lowest visit item
lowestVisitItem.insertAdjacentElement('afterend', actionsContainer);
}
}
// Show all checkboxes when at least one is checked
const checkboxContainers = container.querySelectorAll('.visit-checkbox-container');
checkboxContainers.forEach(checkboxContainer => {
checkboxContainer.style.opacity = '1';
checkboxContainer.style.pointerEvents = 'auto';
});
}
/**
* Sends a request to merge the selected visits
* @param {Array} visitIds - Array of visit IDs to merge
*/
async mergeVisits(visitIds) {
if (!visitIds || visitIds.length < 2) {
showFlashMessage('error', 'At least 2 visits must be selected for merging');
return;
}
try {
const response = await fetch('/api/v1/visits/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
visit_ids: visitIds
})
});
if (!response.ok) {
throw new Error('Failed to merge visits');
}
showFlashMessage('notice', 'Visits merged successfully');
// Refresh the visits list
this.fetchAndDisplayVisits();
} catch (error) {
console.error('Error merging visits:', error);
showFlashMessage('error', 'Failed to merge visits');
}
}
/**
* Sends a request to update status for multiple visits
* @param {Array} visitIds - Array of visit IDs to update
* @param {string} status - The new status ('confirmed' or 'declined')
*/
async bulkUpdateVisitStatus(visitIds, status) {
if (!visitIds || visitIds.length === 0) {
showFlashMessage('error', 'No visits selected');
return;
}
try {
const response = await fetch('/api/v1/visits/bulk_update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
visit_ids: visitIds,
status: status
})
});
if (!response.ok) {
throw new Error(`Failed to ${status} visits`);
}
showFlashMessage('notice', `${visitIds.length} visits ${status === 'confirmed' ? 'confirmed' : 'declined'} successfully`);
// Refresh the visits list
this.fetchAndDisplayVisits();
} catch (error) {
console.error(`Error ${status}ing visits:`, error);
showFlashMessage('error', `Failed to ${status} visits`);
}
}
/**
@ -276,8 +574,12 @@ export class VisitsManager {
visitItems.forEach(item => {
// Location click handler
item.addEventListener('click', (event) => {
// Don't trigger if clicking on buttons
if (event.target.classList.contains('btn')) return;
// Don't trigger if clicking on buttons or checkboxes
if (event.target.classList.contains('btn') ||
event.target.classList.contains('checkbox') ||
event.target.closest('.visit-checkbox-container')) {
return;
}
const lat = parseFloat(item.dataset.lat);
const lng = parseFloat(item.dataset.lng);
@ -382,12 +684,12 @@ export class VisitsManager {
<form class="visit-name-form" data-visit-id="${visit.id}">
<div class="form-control">
<input type="text"
class="input input-bordered input-sm w-full"
class="input input-bordered input-sm w-full text-neutral-content"
value="${defaultName}"
placeholder="Enter visit name">
</div>
<div class="form-control mt-2">
<select class="select select-bordered select-sm w-full" name="place">
<select class="select text-neutral-content select-bordered select-sm w-full h-fit" name="place">
${possiblePlaces.map(place => `
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
${place.name}
@ -572,4 +874,12 @@ export class VisitsManager {
getVisitCirclesLayer() {
return this.visitCircles;
}
/**
* Gets the confirmed visits layer group that's always visible
* @returns {L.LayerGroup} The confirmed visits layer group
*/
getConfirmedVisitCirclesLayer() {
return this.confirmedVisitCircles;
}
}

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,47 @@
# frozen_string_literal: true
module Visits
class BulkUpdateService
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 %w[confirmed declined].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
updated_count = visits.update_all(status: status)
{ count: updated_count, visits: visits }
end
end
end

View file

@ -0,0 +1,61 @@
# 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|
ActiveRecord::Base.transaction do
# Try to find matching area or place
area = find_matching_area(visit_data)
place = area ? nil : PlaceFinder.new(user).find_or_create_place(visit_data)
visit = Visit.create!(
user: user,
area: area,
place: 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, place, visit_data[:suggested_name]),
status: :suggested
)
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit.id)
visit
end
end
end
private
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]
)
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 = 5.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
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]
)
# 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])
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 unless most_common_name.present?
# 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,69 @@
# frozen_string_literal: true
module Visits
# Service to handle merging multiple visits into one
class MergeService
attr_reader :visits, :errors
def initialize(visits)
@visits = visits
@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
# Use the first visit as the base for the merged visit
base_visit = visits.first
# Calculate the new start and end times
earliest_start = visits.min_by(&:started_at).started_at
latest_end = visits.max_by(&:ended_at).ended_at
# Calculate the total duration (sum of all visit durations)
total_duration = ((latest_end - earliest_start) / 60).round
# Create a combined name
combined_name = "Combined Visit (#{earliest_start.strftime('%b %d')} - #{latest_end.strftime('%b %d')})"
# Update the base visit with the new data
base_visit.update!(
started_at: earliest_start,
ended_at: latest_end,
duration: total_duration,
name: combined_name,
status: 'confirmed' # Set status to confirmed for the merged visit
)
# Move all points from other visits to the base visit
visits[1..].each do |visit|
# Update points to associate with the base visit
visit.points.update_all(visit_id: base_visit.id) # rubocop:disable Rails/SkipsModelValidations
# Delete the other visit
visit.destroy!
end
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
end
end

View file

@ -0,0 +1,81 @@
# 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..-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]]
)
# 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]
)
end.max
# Convert to meters and check if exceeds threshold
(max_distance * 1000) > SIGNIFICANT_MOVEMENT_THRESHOLD
end
end
end

View file

@ -0,0 +1,134 @@
# frozen_string_literal: true
module Visits
# Finds or creates places for visits
class PlaceFinder
attr_reader :user
def initialize(user)
@user = user
end
def find_or_create_place(visit_data)
lat = visit_data[:center_lat].round(5)
lon = visit_data[:center_lon].round(5)
name = visit_data[:suggested_name]
# Define the search radius in meters
search_radius = 100 # Adjust this value as needed
# First check by exact coordinates
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat).first
# If no exact match, check by name within radius
existing_place ||= Place.where(name: name)
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)', lon, lat, search_radius)
.first
return existing_place if existing_place
# Use a database transaction with a lock to prevent race conditions
Place.transaction do
# Check again within transaction to prevent race conditions
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 50)', lon, lat)
.lock(true)
.first
return existing_place if existing_place
create_new_place(lat, lon, visit_data[:suggested_name])
end
end
private
def create_new_place(lat, lon, suggested_name)
# If no existing place is found, create a new one
place = Place.new(
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon
)
# Get reverse geocoding data
geocoded_data = Geocoder.search([lat, lon])
if geocoded_data.present?
first_result = geocoded_data.first
data = first_result.data.with_indifferent_access
properties = data['properties'] || {}
# Build a descriptive name from available components
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.uniq
place.name = name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
place.city = properties['city']
place.country = properties['country']
place.geodata = data
place.source = :photon
place.save!
# Process nearby organizations outside the main transaction
process_nearby_organizations(geocoded_data.drop(1))
else
place.name = suggested_name || Place::DEFAULT_NAME
place.source = :manual
place.save!
end
place
end
def process_nearby_organizations(geocoded_data)
# Fetch nearby organizations
nearby_organizations = fetch_nearby_organizations(geocoded_data)
# Save each organization as a possible place
nearby_organizations.each do |org|
lon = org[:longitude]
lat = org[:latitude]
# Check if a similar place already exists
existing = Place.where(name: org[:name])
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat)
.first
next if existing
Place.create!(
name: org[:name],
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon,
city: org[:city],
country: org[:country],
geodata: org[:geodata],
source: :photon
)
end
end
def fetch_nearby_organizations(geocoded_data)
geocoded_data.map do |result|
data = result.data
properties = data['properties'] || {}
{
name: properties['name'] || 'Unknown Organization',
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: data
}
end
end
end
end

View file

@ -1,427 +1,44 @@
class Visits::SmartDetect
MINIMUM_VISIT_DURATION = 5.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_PLACE_VISITS = 2 # Number of visits to consider a place significant
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
# frozen_string_literal: true
attr_reader :user, :start_at, :end_at, :points
module Visits
# Coordinates the process of detecting and creating visits from tracked points
class SmartDetect
MINIMUM_VISIT_DURATION = 5.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_PLACE_VISITS = 2 # Number of visits to consider a place significant
SIGNIFICANT_MOVEMENT_THRESHOLD = 50 # meters
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
attr_reader :user, :start_at, :end_at, :points
def call
return [] if points.empty?
potential_visits = detect_potential_visits
merged_visits = merge_consecutive_visits(potential_visits)
grouped_visits = group_nearby_visits(merged_visits).flatten
create_visits(grouped_visits)
end
private
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
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
# Handle the last visit
visits << finalize_visit(current_visit) if current_visit && valid_visit?(current_visit)
def call
return [] if points.empty?
visits
end
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
def merge_consecutive_visits(visits)
return visits if visits.empty?
merged = []
current_merged = visits.first
visits[1..-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
Visits::Creator.new(user).create_visits(grouped_visits)
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]]
)
# 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]
)
end.max
# Convert to meters and check if exceeds threshold
(max_distance * 1000) > SIGNIFICANT_MOVEMENT_THRESHOLD
end
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]
)
# 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])
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 unless most_common_name.present?
# 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
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
def significant_duration?(visits)
total_duration = visits.sum { |v| v[:duration] }
total_duration >= 1.hour
end
def near_known_place?(visit)
# Check if the visit is near a known area or previously confirmed place
center = [visit[:center_lat], visit[:center_lon]]
user.areas.any? { |area| near_area?(center, area) } ||
user.places.any? { |place| near_place?(center, place) }
end
def near_area?(center, area)
distance = Geocoder::Calculations.distance_between(
center,
[area.latitude, area.longitude]
)
distance * 1000 <= area.radius # Convert to meters
end
def near_place?(center, place)
distance = Geocoder::Calculations.distance_between(
center,
[place.latitude, place.longitude]
)
distance <= 0.05 # 50 meters
end
def create_visits(visits)
visits.map do |visit_data|
ActiveRecord::Base.transaction do
# Try to find matching area or place
area = find_matching_area(visit_data)
place = area ? nil : find_or_create_place(visit_data)
visit = Visit.create!(
user: user,
area: area,
place: 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, place, visit_data[:suggested_name]),
status: :suggested # Use the new status
)
Point.where(id: visit_data[:points].map(&:id)).update_all(visit_id: visit.id)
visit
end
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
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 find_or_create_place(visit_data)
lat = visit_data[:center_lat].round(5)
lon = visit_data[:center_lon].round(5)
name = visit_data[:suggested_name]
# Define the search radius in meters
search_radius = 100 # Adjust this value as needed
# First check by exact coordinates
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat).first
# If no exact match, check by name within radius
existing_place ||= Place.where(name: name)
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?)', lon, lat, search_radius)
.first
return existing_place if existing_place
# Use a database transaction with a lock to prevent race conditions
Place.transaction do
# Check again within transaction to prevent race conditions
existing_place = Place.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 50)', lon, lat)
.lock(true)
.first
return existing_place if existing_place
# If no existing place is found, create a new one
place = Place.new(
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon
)
# Get reverse geocoding data
geocoded_data = Geocoder.search([lat, lon])
if geocoded_data.present?
first_result = geocoded_data.first
data = first_result.data.with_indifferent_access
properties = data['properties'] || {}
# Build a descriptive name from available components
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.uniq
place.name = name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
place.city = properties['city']
place.country = properties['country']
place.geodata = data
place.source = :photon
place.save!
# Process nearby organizations outside the main transaction
process_nearby_organizations(geocoded_data.drop(1))
else
place.name = visit_data[:suggested_name] || Place::DEFAULT_NAME
place.source = :manual
place.save!
end
place
end
end
# Extract nearby organizations processing to a separate method
def process_nearby_organizations(geocoded_data)
# Fetch nearby organizations
nearby_organizations = fetch_nearby_organizations(geocoded_data)
# Save each organization as a possible place
nearby_organizations.each do |org|
lon = org[:longitude]
lat = org[:latitude]
# Check if a similar place already exists
existing = Place.where(name: org[:name])
.where('ST_DWithin(lonlat, ST_SetSRID(ST_MakePoint(?, ?), 4326), 1)', lon, lat)
.first
next if existing
Place.create!(
name: org[:name],
lonlat: "POINT(#{lon} #{lat})",
latitude: lat,
longitude: lon,
city: org[:city],
country: org[:country],
geodata: org[:geodata],
source: :photon
)
end
end
def fetch_nearby_organizations(geocoded_data)
geocoded_data.map do |result|
data = result.data
properties = data['properties'] || {}
{
name: properties['name'] || 'Unknown Organization',
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: data
}
end
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

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

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

@ -78,6 +78,10 @@ Rails.application.routes.draw do
resources :points, only: %i[index create update destroy]
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

View file

@ -0,0 +1,75 @@
# 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) }
before do
allow(subject).to receive(:near_area?).and_return(true)
end
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
end
context 'when matching a place' do
let(:place) { create(:place, latitude: 40.7128, longitude: -74.0060) }
let(:place_finder) { instance_double(Visits::PlaceFinder) }
before do
allow(subject).to receive(:near_area?).and_return(false)
allow(Visits::PlaceFinder).to receive(:new).with(user).and_return(place_finder)
allow(place_finder).to receive(:find_or_create_place).and_return(place)
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
end
end
end

View file

@ -0,0 +1,75 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::Detector do
let(:user) { create(:user) }
# The issue is likely with how we're creating the test points
# Let's make them more realistic with proper spacing in time and location
let(:points) do
[
# First visit - 3 points close together
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i),
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: 50.minutes.ago.to_i),
build_stubbed(:point, lonlat: 'POINT(-74.0062 40.7130)', timestamp: 40.minutes.ago.to_i),
# Gap in time (> MAXIMUM_VISIT_GAP)
# Second visit - different location
build_stubbed(:point, lonlat: 'POINT(-74.0500 40.7500)', timestamp: 10.minutes.ago.to_i),
build_stubbed(:point, lonlat: 'POINT(-74.0501 40.7501)', timestamp: 5.minutes.ago.to_i)
]
end
subject { described_class.new(points) }
describe '#detect_potential_visits' do
before do
allow(subject).to receive(:valid_visit?).and_return(true)
allow(subject).to receive(:suggest_place_name).and_return('Test Place')
end
it 'identifies potential visits from points' 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 visit properties correctly' 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[:duration]).to be_within(60).of(20.minutes.to_i)
expect(first_visit[:center_lat]).to be_within(0.001).of(expected_lat)
expect(first_visit[:center_lon]).to be_within(0.001).of(expected_lon)
expect(first_visit[:radius]).to be > 0
expect(first_visit[:suggested_name]).to eq('Test Place')
end
context 'with insufficient points for a visit' do
let(:points) do
[
build_stubbed(:point, lonlat: 'POINT(-74.0060 40.7128)', timestamp: 1.hour.ago.to_i),
# Large time gap
build_stubbed(:point, lonlat: 'POINT(-74.0061 40.7129)', timestamp: 10.minutes.ago.to_i)
]
end
before do
allow(subject).to receive(:valid_visit?).and_call_original
end
it 'does not create a visit' do
visits = subject.detect_potential_visits
expect(visits).to be_empty
end
end
end
end

View file

@ -0,0 +1,98 @@
# 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
service = described_class.new([visit1, visit2])
result = service.call
expected_name = "Combined Visit (#{visit1.started_at.strftime('%b %d')} - #{visit2.ended_at.strftime('%b %d')})"
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,81 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Visits::PlaceFinder do
let(:user) { create(:user) }
subject { described_class.new(user) }
describe '#find_or_create_place' do
let(:visit_data) do
{
center_lat: 40.7128,
center_lon: -74.0060,
suggested_name: 'Test Place'
}
end
context 'when an existing place is found' do
let!(:existing_place) { create(:place, latitude: 40.7128, longitude: -74.0060) }
it 'returns the existing place' do
place = subject.find_or_create_place(visit_data)
expect(place).to eq(existing_place)
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: 40.7128,
longitude: -74.0060
)
end
before do
allow(Geocoder).to receive(:search).and_return([geocoder_result])
allow(subject).to receive(:process_nearby_organizations)
end
it 'creates a new place with geocoded data' do
expect do
subject.find_or_create_place(visit_data)
end.to change(Place, :count).by(1)
place = Place.last
expect(place.name).to include('Test Location')
expect(place.city).to eq('Test City')
expect(place.country).to eq('Test Country')
expect(place.source).to eq('photon')
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
subject.find_or_create_place(visit_data)
end.to change(Place, :count).by(1)
place = Place.last
expect(place.name).to eq('Test Place')
expect(place.source).to eq('manual')
end
end
end
end
end

View file

@ -4,437 +4,41 @@ require 'rails_helper'
RSpec.describe Visits::SmartDetect do
let(:user) { create(:user) }
let(:start_at) { DateTime.new(2025, 3, 1, 12, 0, 0) }
let(:end_at) { DateTime.new(2025, 3, 1, 13, 0, 0) }
let(:start_at) { 1.day.ago }
let(:end_at) { Time.current }
let(:points) { create_list(:point, 5, user: user, timestamp: 2.hours.ago) }
let(:geocoder_struct) do
Struct.new(:lon, :lat, :data) do
def latitude
lat
end
def longitude
lon
end
def data # rubocop:disable Metrics/MethodLength
{
"geometry": {
"coordinates": [
lon,
lat
],
"type": 'Point'
},
"type": 'Feature',
"properties": {
"osm_id": 681_354_082,
"extent": [
lon,
lat,
lon + 0.0001,
lat + 0.0001
],
"country": 'Russia',
"city": 'Moscow',
"countrycode": 'RU',
"postcode": '103265',
"type": 'street',
"osm_type": 'W',
"osm_key": 'highway',
"district": 'Tverskoy',
"osm_value": 'pedestrian',
"name": 'проезд Воскресенские Ворота',
"state": 'Moscow'
}
}
end
end
end
let(:geocoder_response) do
[
geocoder_struct.new(0, 0, geocoder_struct.new(0, 0).data[:features]),
geocoder_struct.new(0.00001, 0.00001, geocoder_struct.new(0.00001, 0.00001).data[:features]),
geocoder_struct.new(0.00002, 0.00002, geocoder_struct.new(0.00002, 0.00002).data[:features])
]
end
subject(:detector) { described_class.new(user, start_at:, end_at:) }
before do
# Create a hash mapping coordinates to mock results
geocoder_results = {
[40.123, -74.456] => [
double(
data: {
'address' => {
'road' => 'First Street',
'city' => 'First City'
# other address components
},
'name' => 'First Place'
}
)
],
[41.789, -73.012] => [
double(
data: {
'address' => {
'road' => 'Second Street',
'city' => 'Second City'
# other address components
},
'name' => 'Second Place'
}
)
]
}
# Set up default stub
allow(Geocoder).to receive(:search) do |coords|
geocoder_results[coords] || []
end
end
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(detector.call).to eq([])
expect(subject.call).to eq([])
end
end
context 'with a simple visit' do
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: start_at + 5.minutes),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: start_at + 10.minutes)
]
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 'creates a visit' do
expect { detector.call }.to change(Visit, :count).by(1)
end
it 'assigns points to the visit' do
visits = detector.call
expect(visits.first.points).to match_array(points)
end
it 'sets correct visit attributes' do
visit = detector.call.first
expect(visit).to have_attributes(
started_at: be_within(1.second).of(start_at),
ended_at: be_within(1.second).of(start_at + 10.minutes),
duration: be_within(1).of(10), # 10 minutes
status: 'suggested'
)
end
end
context 'with points containing geodata' do
let(:geodata) do
{
'features' => [
{
'properties' => {
'type' => 'shop',
'name' => 'Coffee Shop',
'street' => 'Main Street',
'city' => 'Example City',
'state' => 'Example State'
}
}
]
}
end
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at, geodata:),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: start_at + 5.minutes,
geodata:),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: start_at + 10.minutes,
geodata:)
]
end
it 'suggests a name based on geodata' do
visit = detector.call.first
expect(visit.name).to eq('Coffee Shop, Main Street, Example City, Example State')
end
context 'with mixed feature types' do
let(:mixed_geodata1) do
{
'features' => [
{
'properties' => {
'type' => 'shop',
'name' => 'Coffee Shop',
'street' => 'Main Street'
}
}
]
}
end
let(:mixed_geodata2) do
{
'features' => [
{
'properties' => {
'type' => 'restaurant',
'name' => 'Burger Place',
'street' => 'Main Street'
}
}
]
}
end
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: start_at + 5.minutes,
geodata: mixed_geodata1),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)',
timestamp: start_at + 10.minutes,
geodata: mixed_geodata1),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)',
timestamp: start_at + 15.minutes,
geodata: mixed_geodata2)
]
end
it 'uses the most common feature type and name' do
visit = detector.call.first
expect(visit).not_to be_nil
expect(visit.name).to eq('Coffee Shop, Main Street')
end
end
context 'with empty or invalid geodata' do
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at,
geodata: {}),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: start_at + 5.minutes,
geodata: {}),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: start_at + 10.minutes,
geodata: { 'features' => [] })
]
end
it 'falls back to Unknown Location' do
visit = detector.call.first
expect(visit.name).to eq('Suggested place')
end
end
end
context 'with multiple visits to the same place' do
let(:start_at) { DateTime.new(2025, 3, 1, 12, 0, 0) }
let(:end_at) { DateTime.new(2025, 3, 1, 14, 0, 0) } # Extended to 2 hours
let!(:morning_points) do
[
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: start_at + 10.minutes),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)',
timestamp: start_at + 15.minutes),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)',
timestamp: start_at + 20.minutes)
]
end
let!(:afternoon_points) do
[
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: start_at + 90.minutes), # 1.5 hours later
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)',
timestamp: start_at + 95.minutes),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)',
timestamp: start_at + 100.minutes)
]
end
it 'assigns correct points to each visit' do
visits = detector.call
expect(visits.count).to eq(2)
expect(visits.first.points).to match_array(morning_points)
expect(visits.last.points).to match_array(afternoon_points)
end
end
context 'with a known area' do
let!(:area) { create(:area, user:, latitude: 0, longitude: 0, radius: 100, name: 'Home') }
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at + 10.minutes),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: start_at + 15.minutes),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: start_at + 20.minutes)
]
end
it 'associates the visit with the area' do
visits = detector.call
visit = visits.first
expect(visit).not_to be_nil
expect(visit.area).to eq(area)
expect(visit.name).to eq('Home')
end
context 'with geodata present' do
let(:geodata) do
{
'features' => [
{
'properties' => {
'type' => 'shop',
'name' => 'Coffee Shop',
'street' => 'Main Street'
}
}
]
}
end
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: start_at + 10.minutes,
geodata: geodata),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)',
timestamp: start_at + 15.minutes,
geodata: geodata),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)',
timestamp: start_at + 20.minutes,
geodata: geodata)
]
end
it 'prefers area name over geodata' do
visits = detector.call
visit = visits.first
expect(visit).not_to be_nil
expect(visit.name).to eq('Home')
end
end
end
context 'with points too far apart' do
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at),
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at + 5.minutes),
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: start_at + 10.minutes),
create(:point, user:, lonlat: 'POINT(10 10)', timestamp: start_at + 15.minutes),
create(:point, user:, lonlat: 'POINT(10 10)', timestamp: start_at + 20.minutes),
create(:point, user:, lonlat: 'POINT(10 10)', timestamp: start_at + 25.minutes)
]
end
it 'creates separate visits' do
expect { detector.call }.to change(Visit, :count).by(2)
end
end
context 'with points too far apart in time' do
# Use a wider time range to ensure all points are within the detector's window
let(:end_at) { DateTime.new(2025, 3, 1, 15, 0, 0) }
let!(:points) do
[
# First visit with more points to ensure it's significant
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: DateTime.new(2025, 3, 1, 12, 0, 0)),
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: DateTime.new(2025, 3, 1, 12, 5, 0)),
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: DateTime.new(2025, 3, 1, 12, 10, 0)),
# Second visit - with a gap of 40 minutes (beyond MAXIMUM_VISIT_GAP)
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: DateTime.new(2025, 3, 1, 12, 50, 0)),
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: DateTime.new(2025, 3, 1, 12, 55, 0)),
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: DateTime.new(2025, 3, 1, 13, 0, 0))
]
end
it 'creates separate visits' do
expect { detector.call }.to change(Visit, :count).by(2)
end
end
context 'with an existing place' do
let!(:place) { create(:place, latitude: 0, longitude: 0, name: 'Coffee Shop') }
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: start_at + 10.minutes),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)',
timestamp: start_at + 15.minutes),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)',
timestamp: start_at + 20.minutes)
]
end
it 'associates the visit with the place' do
visits = detector.call
visit = visits.first
expect(visit).not_to be_nil
expect(visit.place).to eq(place)
expect(visit.name).to eq('Coffee Shop')
end
context 'with different geodata' do
let(:geodata) do
{
'features' => [
{
'properties' => {
'type' => 'restaurant',
'name' => 'Burger Place',
'street' => 'Main Street'
}
}
]
}
end
let!(:points) do
[
create(:point, user:, lonlat: 'POINT(0 0)',
timestamp: start_at + 10.minutes,
geodata: geodata),
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)',
timestamp: start_at + 15.minutes,
geodata: geodata),
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)',
timestamp: start_at + 20.minutes,
geodata: geodata)
]
end
it 'prefers existing place name over geodata' do
visits = detector.call
visit = visits.first
expect(visit).not_to be_nil
expect(visit.place).to eq(place)
expect(visit.name).to eq('Coffee Shop')
end
it 'delegates to the appropriate services' do
expect(subject.call).to eq(created_visits)
end
end
end