mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Implement visits merging
This commit is contained in:
parent
c00bd2e387
commit
6b356d24b1
24 changed files with 1420 additions and 868 deletions
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
17
app/javascript/styles/visits.css
Normal file
17
app/javascript/styles/visits.css
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
.visit-checkbox-container {
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.visit-item {
|
||||
position: relative;
|
||||
}
|
||||
.visit-item:hover .visit-checkbox-container {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.leaflet-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.merge-visits-button {
|
||||
margin: 8px 0;
|
||||
}
|
||||
47
app/services/visits/bulk_update_service.rb
Normal file
47
app/services/visits/bulk_update_service.rb
Normal 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
|
||||
61
app/services/visits/creator.rb
Normal file
61
app/services/visits/creator.rb
Normal 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
|
||||
158
app/services/visits/detector.rb
Normal file
158
app/services/visits/detector.rb
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Visits
|
||||
# Detects potential visits from a collection of tracked points
|
||||
class Detector
|
||||
MINIMUM_VISIT_DURATION = 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
|
||||
69
app/services/visits/merge_service.rb
Normal file
69
app/services/visits/merge_service.rb
Normal 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
|
||||
81
app/services/visits/merger.rb
Normal file
81
app/services/visits/merger.rb
Normal 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
|
||||
134
app/services/visits/place_finder.rb
Normal file
134
app/services/visits/place_finder.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
75
spec/services/visits/creator_spec.rb
Normal file
75
spec/services/visits/creator_spec.rb
Normal 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
|
||||
75
spec/services/visits/detector_spec.rb
Normal file
75
spec/services/visits/detector_spec.rb
Normal 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
|
||||
98
spec/services/visits/merge_service_spec.rb
Normal file
98
spec/services/visits/merge_service_spec.rb
Normal 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
|
||||
80
spec/services/visits/merger_spec.rb
Normal file
80
spec/services/visits/merger_spec.rb
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::Merger do
|
||||
let(:user) { create(:user) }
|
||||
let(:points) { double('Points') }
|
||||
|
||||
subject { described_class.new(points) }
|
||||
|
||||
describe '#merge_visits' do
|
||||
let(:visit1) do
|
||||
{
|
||||
start_time: 2.hours.ago.to_i,
|
||||
end_time: 1.hour.ago.to_i,
|
||||
center_lat: 40.7128,
|
||||
center_lon: -74.0060,
|
||||
points: [double('Point1'), double('Point2')]
|
||||
}
|
||||
end
|
||||
|
||||
let(:visit2) do
|
||||
{
|
||||
start_time: 50.minutes.ago.to_i,
|
||||
end_time: 40.minutes.ago.to_i,
|
||||
center_lat: 40.7129,
|
||||
center_lon: -74.0061,
|
||||
points: [double('Point3'), double('Point4')]
|
||||
}
|
||||
end
|
||||
|
||||
let(:visit3) do
|
||||
{
|
||||
start_time: 30.minutes.ago.to_i,
|
||||
end_time: 20.minutes.ago.to_i,
|
||||
center_lat: 40.7500,
|
||||
center_lon: -74.0500,
|
||||
points: [double('Point5'), double('Point6')]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when visits can be merged' do
|
||||
let(:visits) { [visit1, visit2, visit3] }
|
||||
before do
|
||||
allow(subject).to receive(:can_merge_visits?).with(visit1, visit2).and_return(true)
|
||||
allow(subject).to receive(:can_merge_visits?).with(anything, visit3).and_return(false)
|
||||
end
|
||||
|
||||
it 'merges consecutive visits that meet criteria' do
|
||||
merged = subject.merge_visits(visits)
|
||||
|
||||
expect(merged.size).to eq(2)
|
||||
expect(merged.first[:points].size).to eq(4)
|
||||
expect(merged.first[:end_time]).to eq(visit2[:end_time])
|
||||
expect(merged.last).to eq(visit3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when visits cannot be merged' do
|
||||
let(:visits) { [visit1, visit2, visit3] }
|
||||
|
||||
before do
|
||||
allow(subject).to receive(:can_merge_visits?).and_return(false)
|
||||
end
|
||||
|
||||
it 'keeps visits separate' do
|
||||
merged = subject.merge_visits(visits)
|
||||
|
||||
expect(merged.size).to eq(3)
|
||||
expect(merged).to eq(visits)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty visits array' do
|
||||
it 'returns an empty array' do
|
||||
expect(subject.merge_visits([])).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
81
spec/services/visits/place_finder_spec.rb
Normal file
81
spec/services/visits/place_finder_spec.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue