mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Add visits to the map page
This commit is contained in:
parent
ff6d5f1c97
commit
a4123791aa
16 changed files with 1323 additions and 247 deletions
|
|
@ -14,94 +14,18 @@
|
|||
*= require_self
|
||||
*/
|
||||
|
||||
.emoji-icon {
|
||||
font-size: 36px; /* Adjust size as needed */
|
||||
text-align: center;
|
||||
line-height: 36px; /* Same as font-size for perfect centering */
|
||||
}
|
||||
|
||||
.timeline-box {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Style for the settings panel */
|
||||
.leaflet-settings-panel {
|
||||
background-color: white;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaflet-settings-panel label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.leaflet-settings-panel input {
|
||||
/* Leaflet map container styles */
|
||||
[data-controller="visits-map"] {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.leaflet-settings-panel button {
|
||||
padding: 5px 10px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leaflet-settings-panel button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.photo-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.photo-marker img {
|
||||
border-radius: 50%;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.leaflet-loading-control {
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
|
||||
margin: 10px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
color: gray;
|
||||
}
|
||||
|
||||
.loading-spinner::before {
|
||||
content: '🔵';
|
||||
font-size: 18px;
|
||||
animation: spinner 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-spinner.done::before {
|
||||
content: '✅';
|
||||
animation: none;
|
||||
[data-visits-map-target="container"] {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Loading spinner animation */
|
||||
@keyframes spinner {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
|
|
|||
|
|
@ -20,3 +20,58 @@
|
|||
transition: opacity 150ms ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leaflet Panel Styles */
|
||||
.leaflet-right-panel {
|
||||
margin-top: 80px; /* Give space for controls above */
|
||||
margin-right: 10px;
|
||||
transform: none;
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 400;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.leaflet-right-panel.controls-shifted {
|
||||
right: 310px;
|
||||
}
|
||||
|
||||
.leaflet-control-button {
|
||||
background-color: white !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-button:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
/* Drawer Panel Styles */
|
||||
.leaflet-drawer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
z-index: 450;
|
||||
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.leaflet-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Controls transition */
|
||||
.leaflet-control-layers,
|
||||
.leaflet-control-button,
|
||||
.toggle-panel-button {
|
||||
transition: right 0.3s ease-in-out;
|
||||
z-index: 500;
|
||||
}
|
||||
|
||||
.controls-shifted {
|
||||
right: 300px !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::VisitsController < ApiController
|
||||
def index
|
||||
start_time = begin
|
||||
Time.zone.parse(params[:start_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.beginning_of_day
|
||||
end
|
||||
end_time = begin
|
||||
Time.zone.parse(params[:end_at])
|
||||
rescue StandardError
|
||||
Time.zone.now.end_of_day
|
||||
end
|
||||
|
||||
visits =
|
||||
Visit
|
||||
.includes(:place)
|
||||
.where(user: current_api_user)
|
||||
.where('started_at >= ? AND ended_at <= ?', start_time, end_time)
|
||||
.order(started_at: :desc)
|
||||
|
||||
serialized_visits = visits.map do |visit|
|
||||
Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
||||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def update
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
visit = update_visit(visit)
|
||||
|
|
@ -11,7 +37,7 @@ class Api::V1::VisitsController < ApiController
|
|||
private
|
||||
|
||||
def visit_params
|
||||
params.require(:visit).permit(:name, :place_id)
|
||||
params.require(:visit).permit(:name, :place_id, :status)
|
||||
end
|
||||
|
||||
def update_visit(visit)
|
||||
|
|
|
|||
|
|
@ -11,11 +11,10 @@ class VisitsController < ApplicationController
|
|||
visits = current_user
|
||||
.visits
|
||||
.where(status:)
|
||||
.includes(%i[suggested_places area])
|
||||
.includes(%i[suggested_places area points])
|
||||
.order(started_at: order_by)
|
||||
|
||||
@suggested_visits_count = current_user.visits.suggested.count
|
||||
|
||||
@visits = visits.page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export default class extends BaseController {
|
|||
layerControl = null;
|
||||
visitedCitiesCache = new Map();
|
||||
trackedMonthsCache = null;
|
||||
drawerOpen = false;
|
||||
visitCircles = L.layerGroup();
|
||||
|
||||
connect() {
|
||||
super.connect();
|
||||
|
|
@ -248,6 +250,12 @@ export default class extends BaseController {
|
|||
|
||||
// Start monitoring
|
||||
this.tileMonitor.startMonitoring();
|
||||
|
||||
// Add the drawer button
|
||||
this.addDrawerButton();
|
||||
|
||||
// Fetch and display visits when map loads
|
||||
this.fetchAndDisplayVisits();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
|
@ -1322,11 +1330,294 @@ export default class extends BaseController {
|
|||
formatDuration(seconds) {
|
||||
const days = Math.floor(seconds / (24 * 60 * 60));
|
||||
const hours = Math.floor((seconds % (24 * 60 * 60)) / (60 * 60));
|
||||
const minutes = Math.floor((seconds % (60 * 60)) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0 && days === 0) parts.push(`${minutes}m`); // Only show minutes if less than a day
|
||||
|
||||
return parts.join(' ') || '< 1m';
|
||||
}
|
||||
|
||||
addDrawerButton() {
|
||||
const DrawerControl = L.Control.extend({
|
||||
onAdd: (map) => {
|
||||
const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button');
|
||||
button.innerHTML = '⬅️'; // Left arrow icon
|
||||
button.style.width = '32px';
|
||||
button.style.height = '32px';
|
||||
button.style.border = 'none';
|
||||
button.style.cursor = 'pointer';
|
||||
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
|
||||
button.style.borderRadius = '4px';
|
||||
button.style.padding = '0';
|
||||
button.style.lineHeight = '32px';
|
||||
button.style.textAlign = 'center';
|
||||
|
||||
L.DomEvent.disableClickPropagation(button);
|
||||
L.DomEvent.on(button, 'click', () => {
|
||||
this.toggleDrawer();
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
this.map.addControl(new DrawerControl({ position: 'topright' }));
|
||||
}
|
||||
|
||||
toggleDrawer() {
|
||||
this.drawerOpen = !this.drawerOpen;
|
||||
|
||||
let drawer = document.querySelector('.leaflet-drawer');
|
||||
if (!drawer) {
|
||||
drawer = this.createDrawer();
|
||||
}
|
||||
return `${hours}h`;
|
||||
|
||||
drawer.classList.toggle('open');
|
||||
|
||||
const drawerButton = document.querySelector('.drawer-button');
|
||||
if (drawerButton) {
|
||||
drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️';
|
||||
}
|
||||
|
||||
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel');
|
||||
controls.forEach(control => {
|
||||
control.classList.toggle('controls-shifted');
|
||||
});
|
||||
|
||||
// Update the drawer content if it's being opened
|
||||
if (this.drawerOpen) {
|
||||
this.fetchAndDisplayVisits();
|
||||
}
|
||||
}
|
||||
|
||||
createDrawer() {
|
||||
const drawer = document.createElement('div');
|
||||
drawer.className = 'leaflet-drawer';
|
||||
|
||||
// Add styles to make the drawer scrollable
|
||||
drawer.style.overflowY = 'auto';
|
||||
drawer.style.maxHeight = '100vh';
|
||||
|
||||
drawer.innerHTML = `
|
||||
<div class="p-4">
|
||||
<h2 class="text-xl font-bold mb-4">Recent Visits</h2>
|
||||
<div id="visits-list" class="space-y-2">
|
||||
<p class="text-gray-500">Loading visits...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Prevent map zoom when scrolling the drawer
|
||||
L.DomEvent.disableScrollPropagation(drawer);
|
||||
// Prevent map pan/interaction when interacting with drawer
|
||||
L.DomEvent.disableClickPropagation(drawer);
|
||||
|
||||
this.map.getContainer().appendChild(drawer);
|
||||
return drawer;
|
||||
}
|
||||
|
||||
async fetchAndDisplayVisits() {
|
||||
try {
|
||||
// Get current timeframe from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const startAt = urlParams.get('start_at') || new Date().toISOString();
|
||||
const endAt = urlParams.get('end_at') || new Date().toISOString();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v1/visits?start_at=${encodeURIComponent(startAt)}&end_at=${encodeURIComponent(endAt)}`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const visits = await response.json();
|
||||
this.displayVisits(visits);
|
||||
} catch (error) {
|
||||
console.error('Error fetching visits:', error);
|
||||
const container = document.getElementById('visits-list');
|
||||
if (container) {
|
||||
container.innerHTML = '<p class="text-red-500">Error loading visits</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayVisits(visits) {
|
||||
const container = document.getElementById('visits-list');
|
||||
if (!container) return;
|
||||
|
||||
if (!visits || visits.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500">No visits found in selected timeframe</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing circles
|
||||
this.visitCircles.clearLayers();
|
||||
|
||||
// Draw circles only for confirmed visits
|
||||
visits
|
||||
.filter(visit => visit.status === 'confirmed')
|
||||
.forEach(visit => {
|
||||
if (visit.place?.latitude && visit.place?.longitude) {
|
||||
const circle = L.circle([visit.place.latitude, visit.place.longitude], {
|
||||
color: '#4A90E2',
|
||||
fillColor: '#4A90E2',
|
||||
fillOpacity: 0.2,
|
||||
radius: 100,
|
||||
weight: 2
|
||||
});
|
||||
this.visitCircles.addLayer(circle);
|
||||
}
|
||||
});
|
||||
|
||||
const html = visits
|
||||
// Filter out declined visits
|
||||
.filter(visit => visit.status !== 'declined')
|
||||
.map(visit => {
|
||||
const startDate = new Date(visit.started_at);
|
||||
const endDate = new Date(visit.ended_at);
|
||||
const isSameDay = startDate.toDateString() === endDate.toDateString();
|
||||
|
||||
let timeDisplay;
|
||||
if (isSameDay) {
|
||||
timeDisplay = `
|
||||
${startDate.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })},
|
||||
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
|
||||
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||
`;
|
||||
} else {
|
||||
timeDisplay = `
|
||||
${startDate.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })},
|
||||
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
|
||||
${endDate.toLocaleDateString(undefined, { month: 'long', day: 'numeric' })},
|
||||
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
|
||||
`;
|
||||
}
|
||||
|
||||
const durationText = this.formatDuration(visit.duration * 60);
|
||||
|
||||
// Add opacity class for suggested visits
|
||||
const bgClass = visit.status === 'suggested' ? 'bg-neutral border-dashed border-2 border-red-500' : 'bg-base-200';
|
||||
|
||||
return `
|
||||
<div class="p-3 rounded-lg hover:bg-base-300 transition-colors visit-item ${bgClass}"
|
||||
data-lat="${visit.place?.latitude || ''}"
|
||||
data-lng="${visit.place?.longitude || ''}"
|
||||
data-id="${visit.id}">
|
||||
<div class="font-semibold">${visit.name}</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
${timeDisplay.trim()}
|
||||
<span class="text-gray-500">(${durationText})</span>
|
||||
</div>
|
||||
${visit.place?.city ? `<div class="text-sm">${visit.place.city}, ${visit.place.country}</div>` : ''}
|
||||
${visit.status !== 'confirmed' ? `
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Add the circles layer to the map
|
||||
this.visitCircles.addTo(this.map);
|
||||
|
||||
// Add click handlers to visit items and buttons
|
||||
const visitItems = container.querySelectorAll('.visit-item');
|
||||
visitItems.forEach(item => {
|
||||
// Location click handler
|
||||
item.addEventListener('click', (event) => {
|
||||
// Don't trigger if clicking on buttons
|
||||
if (event.target.classList.contains('btn')) return;
|
||||
|
||||
const lat = parseFloat(item.dataset.lat);
|
||||
const lng = parseFloat(item.dataset.lng);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lng)) {
|
||||
this.map.setView([lat, lng], 15, {
|
||||
animate: true,
|
||||
duration: 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Confirm button handler
|
||||
const confirmBtn = item.querySelector('.confirm-visit');
|
||||
confirmBtn?.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const visitId = event.target.dataset.id;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
visit: {
|
||||
status: 'confirmed'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to confirm visit');
|
||||
|
||||
// Refresh visits list
|
||||
this.fetchAndDisplayVisits();
|
||||
showFlashMessage('notice', 'Visit confirmed successfully');
|
||||
} catch (error) {
|
||||
console.error('Error confirming visit:', error);
|
||||
showFlashMessage('error', 'Failed to confirm visit');
|
||||
}
|
||||
});
|
||||
|
||||
// Decline button handler
|
||||
const declineBtn = item.querySelector('.decline-visit');
|
||||
declineBtn?.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
const visitId = event.target.dataset.id;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
visit: {
|
||||
status: 'declined'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to decline visit');
|
||||
|
||||
// Refresh visits list
|
||||
this.fetchAndDisplayVisits();
|
||||
showFlashMessage('notice', 'Visit declined successfully');
|
||||
} catch (error) {
|
||||
console.error('Error declining visit:', error);
|
||||
showFlashMessage('error', 'Failed to decline visit');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,4 +46,9 @@ export default class extends BaseController {
|
|||
element.textContent = newName;
|
||||
});
|
||||
}
|
||||
|
||||
updateAll(event) {
|
||||
const newName = event.detail.name;
|
||||
this.updateVisitNameOnPage(newName);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
110
app/javascript/controllers/visits_map_controller.js
Normal file
110
app/javascript/controllers/visits_map_controller.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import BaseController from "./base_controller"
|
||||
import L from "leaflet"
|
||||
import { osmMapLayer } from "../maps/layers"
|
||||
|
||||
export default class extends BaseController {
|
||||
static targets = ["container"]
|
||||
|
||||
connect() {
|
||||
this.initializeMap();
|
||||
this.visits = new Map();
|
||||
this.highlightedVisit = null;
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
// Initialize the map with a default center (will be updated when visits are added)
|
||||
this.map = L.map(this.containerTarget).setView([0, 0], 2);
|
||||
osmMapLayer(this.map, "OpenStreetMap");
|
||||
|
||||
// Add all visits to the map
|
||||
const visitElements = document.querySelectorAll('[data-visit-id]');
|
||||
if (visitElements.length > 0) {
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
visitElements.forEach(element => {
|
||||
const visitId = element.dataset.visitId;
|
||||
const lat = parseFloat(element.dataset.centerLat);
|
||||
const lon = parseFloat(element.dataset.centerLon);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
const marker = L.circleMarker([lat, lon], {
|
||||
radius: 8,
|
||||
fillColor: this.getVisitColor(element),
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
fillOpacity: 0.8
|
||||
}).addTo(this.map);
|
||||
|
||||
// Store the marker reference
|
||||
this.visits.set(visitId, {
|
||||
marker,
|
||||
element
|
||||
});
|
||||
|
||||
bounds.extend([lat, lon]);
|
||||
}
|
||||
});
|
||||
|
||||
// Fit the map to show all visits
|
||||
if (!bounds.isEmpty()) {
|
||||
this.map.fitBounds(bounds, {
|
||||
padding: [50, 50]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getVisitColor(element) {
|
||||
// Check if the visit has a status badge
|
||||
const badge = element.querySelector('.badge');
|
||||
if (badge) {
|
||||
if (badge.classList.contains('badge-success')) {
|
||||
return '#2ecc71'; // Green for confirmed
|
||||
} else if (badge.classList.contains('badge-warning')) {
|
||||
return '#f1c40f'; // Yellow for suggested
|
||||
}
|
||||
}
|
||||
return '#e74c3c'; // Red for declined or unknown
|
||||
}
|
||||
|
||||
highlightVisit(event) {
|
||||
const visitId = event.currentTarget.dataset.visitId;
|
||||
const visit = this.visits.get(visitId);
|
||||
|
||||
if (visit) {
|
||||
// Reset previous highlight if any
|
||||
if (this.highlightedVisit) {
|
||||
this.highlightedVisit.marker.setStyle({
|
||||
radius: 8,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight the current visit
|
||||
visit.marker.setStyle({
|
||||
radius: 12,
|
||||
fillOpacity: 1
|
||||
});
|
||||
visit.marker.bringToFront();
|
||||
|
||||
// Center the map on the visit
|
||||
this.map.panTo(visit.marker.getLatLng());
|
||||
|
||||
this.highlightedVisit = visit;
|
||||
}
|
||||
}
|
||||
|
||||
unhighlightVisit(event) {
|
||||
const visitId = event.currentTarget.dataset.visitId;
|
||||
const visit = this.visits.get(visitId);
|
||||
|
||||
if (visit && this.highlightedVisit === visit) {
|
||||
visit.marker.setStyle({
|
||||
radius: 8,
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
this.highlightedVisit = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,23 @@ class Visit < ApplicationRecord
|
|||
end
|
||||
|
||||
def center
|
||||
area.present? ? area.to_coordinates : place.to_coordinates
|
||||
if area.present?
|
||||
area.to_coordinates
|
||||
elsif place.present?
|
||||
place.to_coordinates
|
||||
else
|
||||
center_from_points
|
||||
end
|
||||
end
|
||||
|
||||
def center_from_points
|
||||
return [0, 0] if points.empty?
|
||||
|
||||
lat_sum = points.sum(&:lat)
|
||||
lon_sum = points.sum(&:lon)
|
||||
count = points.size.to_f
|
||||
|
||||
[lat_sum / count, lon_sum / count]
|
||||
end
|
||||
|
||||
def async_reverse_geocode
|
||||
|
|
|
|||
64
app/serializers/api/visit_serializer.rb
Normal file
64
app/serializers/api/visit_serializer.rb
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::VisitSerializer
|
||||
def initialize(visit)
|
||||
@visit = visit
|
||||
end
|
||||
|
||||
def call
|
||||
{
|
||||
id: id,
|
||||
area_id: area_id,
|
||||
user_id: user_id,
|
||||
started_at: started_at,
|
||||
ended_at: ended_at,
|
||||
duration: duration,
|
||||
name: name,
|
||||
status: status,
|
||||
place: {
|
||||
latitude: visit.place&.latitude || visit.area&.latitude,
|
||||
longitude: visit.place&.longitude || visit.area&.longitude
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :visit
|
||||
|
||||
def id
|
||||
visit.id
|
||||
end
|
||||
|
||||
def area_id
|
||||
visit.area_id
|
||||
end
|
||||
|
||||
def user_id
|
||||
visit.user_id
|
||||
end
|
||||
|
||||
def started_at
|
||||
visit.started_at
|
||||
end
|
||||
|
||||
def ended_at
|
||||
visit.ended_at
|
||||
end
|
||||
|
||||
def duration
|
||||
visit.duration
|
||||
end
|
||||
|
||||
def name
|
||||
visit.name
|
||||
end
|
||||
|
||||
def status
|
||||
visit.status
|
||||
end
|
||||
|
||||
def place_id
|
||||
visit.place_id
|
||||
end
|
||||
end
|
||||
|
|
@ -101,7 +101,7 @@ class ReverseGeocoding::Places::FetchData
|
|||
limit: 10,
|
||||
distance_sort: true,
|
||||
radius: 1,
|
||||
units: DISTANCE_UNITS
|
||||
units: ::DISTANCE_UNITS
|
||||
)
|
||||
|
||||
data.reject do |place|
|
||||
|
|
|
|||
339
app/services/visits/smart_detect.rb
Normal file
339
app/services/visits/smart_detect.rb
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
class Visits::SmartDetect
|
||||
MINIMUM_VISIT_DURATION = 10.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
|
||||
|
||||
attr_reader :user, :start_at, :end_at, :points
|
||||
|
||||
def initialize(user, start_at:, end_at:)
|
||||
@user = user
|
||||
@start_at = start_at.to_i
|
||||
@end_at = end_at.to_i
|
||||
@points = user.tracked_points.not_visited
|
||||
.order(timestamp: :asc)
|
||||
.where(timestamp: start_at..end_at)
|
||||
end
|
||||
|
||||
def call
|
||||
return [] if points.empty?
|
||||
|
||||
potential_visits = detect_potential_visits
|
||||
merged_visits = merge_consecutive_visits(potential_visits)
|
||||
significant_visits = filter_significant_visits(merged_visits)
|
||||
create_visits(significant_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
|
||||
end
|
||||
|
||||
# Handle the last visit
|
||||
visits << finalize_visit(current_visit) if current_visit && valid_visit?(current_visit)
|
||||
|
||||
visits
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
merged << current_merged
|
||||
merged
|
||||
end
|
||||
|
||||
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 filter_significant_visits(visits)
|
||||
# Group nearby visits to identify significant places
|
||||
grouped_visits = group_nearby_visits(visits)
|
||||
|
||||
grouped_visits.select do |group|
|
||||
group.size >= SIGNIFICANT_PLACE_VISITS ||
|
||||
significant_duration?(group) ||
|
||||
near_known_place?(group.first)
|
||||
end.flatten
|
||||
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
|
||||
)
|
||||
|
||||
visit_data[:points].each { |point| point.update!(visit_id: visit.id) }
|
||||
|
||||
visit
|
||||
end
|
||||
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)
|
||||
# Round coordinates to reduce duplicate places
|
||||
lat = visit_data[:center_lat].round(5)
|
||||
lon = visit_data[:center_lon].round(5)
|
||||
|
||||
place = Place.find_or_initialize_by(
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
)
|
||||
|
||||
unless place.persisted?
|
||||
place.name = visit_data[:suggested_name] || Place::DEFAULT_NAME
|
||||
place.source = Place.sources[:manual]
|
||||
place.save!
|
||||
end
|
||||
|
||||
place
|
||||
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
|
||||
|
|
@ -13,61 +13,17 @@ class Visits::Suggest
|
|||
end
|
||||
|
||||
def call
|
||||
prepared_visits = Visits::Prepare.new(points).call
|
||||
|
||||
visited_places = create_places(prepared_visits)
|
||||
visits = create_visits(visited_places)
|
||||
|
||||
create_visits_notification(user)
|
||||
visits = Visits::SmartDetect.new(user, start_at:, end_at:).call
|
||||
create_visits_notification(user) if visits.any?
|
||||
|
||||
return nil unless DawarichSettings.reverse_geocoding_enabled?
|
||||
|
||||
reverse_geocode(visits)
|
||||
visits.each(&:async_reverse_geocode)
|
||||
visits
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_places(prepared_visits)
|
||||
prepared_visits.flat_map do |date|
|
||||
date[:visits] = handle_visits(date[:visits])
|
||||
|
||||
date
|
||||
end
|
||||
end
|
||||
|
||||
def create_visits(visited_places)
|
||||
visited_places.flat_map do |date|
|
||||
date[:visits].map do |visit_data|
|
||||
ActiveRecord::Base.transaction do
|
||||
search_params = {
|
||||
user_id: user.id,
|
||||
duration: visit_data[:duration],
|
||||
started_at: Time.zone.at(visit_data[:points].first.timestamp)
|
||||
}
|
||||
|
||||
if visit_data[:area].present?
|
||||
search_params[:area_id] = visit_data[:area].id
|
||||
elsif visit_data[:place].present?
|
||||
search_params[:place_id] = visit_data[:place].id
|
||||
end
|
||||
|
||||
visit = Visit.find_or_initialize_by(search_params)
|
||||
visit.name = visit_data[:place]&.name || visit_data[:area]&.name if visit.name.blank?
|
||||
visit.ended_at = Time.zone.at(visit_data[:points].last.timestamp)
|
||||
visit.save!
|
||||
|
||||
visit_data[:points].each { |point| point.update!(visit_id: visit.id) }
|
||||
|
||||
visit
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def reverse_geocode(visits)
|
||||
visits.each(&:async_reverse_geocode)
|
||||
end
|
||||
|
||||
def create_visits_notification(user)
|
||||
content = <<~CONTENT
|
||||
New visits have been suggested based on your location data from #{Time.zone.at(start_at)} to #{Time.zone.at(end_at)}. You can review them on the <a href="#{visits_path}" class="link">Visits</a> page.
|
||||
|
|
@ -79,32 +35,4 @@ class Visits::Suggest
|
|||
content:
|
||||
)
|
||||
end
|
||||
|
||||
def create_place(visit)
|
||||
place = Place.find_or_initialize_by(
|
||||
latitude: visit[:latitude].to_f.round(5),
|
||||
longitude: visit[:longitude].to_f.round(5)
|
||||
)
|
||||
|
||||
place.name = Place::DEFAULT_NAME
|
||||
place.source = Place.sources[:manual]
|
||||
|
||||
place.save!
|
||||
|
||||
place
|
||||
end
|
||||
|
||||
def handle_visits(visits)
|
||||
visits.map do |visit|
|
||||
area = Area.near([visit[:latitude], visit[:longitude]], 0.100).first
|
||||
|
||||
if area.present?
|
||||
visit.merge(area:)
|
||||
else
|
||||
place = create_place(visit)
|
||||
|
||||
visit.merge(place:)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
<div class="group relative timeline-box">
|
||||
<div class="group relative timeline-box"
|
||||
data-action="mouseenter->visits-map#highlightVisit mouseleave->visits-map#unhighlightVisit"
|
||||
data-visit-id="<%= visit.id %>"
|
||||
data-center-lat="<%= visit.center[0] %>"
|
||||
data-center-lon="<%= visit.center[1] %>">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<%= render 'visits/name', visit: visit %>
|
||||
|
|
|
|||
|
|
@ -1,89 +1,103 @@
|
|||
<% content_for :title, "Visits" %>
|
||||
<%# content_for :title, "Visits" %>
|
||||
|
||||
<div class="w-full my-5">
|
||||
<div role="tablist" class="tabs tabs-lifted tabs-lg">
|
||||
<%= link_to 'Visits', visits_path(status: :confirmed), role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('visits')}" %>
|
||||
<%= link_to 'Places', places_path, role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('places')}" %>
|
||||
<div class="w-full h-screen flex flex-col">
|
||||
<%# Top navigation tabs %>
|
||||
<div class="w-full">
|
||||
<div role="tablist" class="tabs tabs-lifted tabs-lg">
|
||||
<%= link_to 'Visits', visits_path(status: :confirmed), role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('visits')}" %>
|
||||
<%= link_to 'Places', places_path, role: 'tab', class: "tab font-bold text-xl #{active_visit_places_tab?('places')}" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div role="tablist" class="tabs tabs-boxed">
|
||||
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :confirmed))}" %>
|
||||
<%= link_to visits_path(status: :suggested), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :suggested))}" do %>
|
||||
Suggested
|
||||
<% if @suggested_visits_count.positive? %>
|
||||
<span class="badge badge-sm badge-primary mx-1"><%= @suggested_visits_count %></span>
|
||||
<%# Main content area with map and side panel %>
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<%# Map container %>
|
||||
<div class="w-2/3 h-full relative" data-controller="visits-map">
|
||||
<div data-visits-map-target="container" class="w-full h-full"></div>
|
||||
</div>
|
||||
|
||||
<%# Side panel %>
|
||||
<div class="w-1/3 h-full flex flex-col bg-base-200 p-4">
|
||||
<%# Visit filters %>
|
||||
<div class="flex flex-col gap-4 mb-4">
|
||||
<div role="tablist" class="tabs tabs-boxed">
|
||||
<%= link_to 'Confirmed', visits_path(status: :confirmed), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :confirmed))}" %>
|
||||
<%= link_to visits_path(status: :suggested), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :suggested))}" do %>
|
||||
Suggested
|
||||
<% if @suggested_visits_count.positive? %>
|
||||
<span class="badge badge-sm badge-primary mx-1"><%= @suggested_visits_count %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to 'Declined', visits_path(status: :declined), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :declined))}" %>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="mr-2">Order by:</span>
|
||||
<%= link_to 'Newest', visits_path(order_by: :desc, status: params[:status]), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
<%= link_to 'Oldest', visits_path(order_by: :asc, status: params[:status]), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to 'Declined', visits_path(status: :declined), role: 'tab',
|
||||
class: "tab #{active_tab?(visits_path(status: :declined))}" %>
|
||||
</div>
|
||||
|
||||
<div role="alert" class="alert my-5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>Visits suggestion feature is currently in beta stage. Expect bugs and problems and don't hesitate to report them to <a href='https://github.com/Freika/dawarich/issues' class='link'>Github Issues</a>.</span>
|
||||
</div>
|
||||
|
||||
<% if @visits.empty? %>
|
||||
<div class="hero min-h-80 bg-base-200">
|
||||
<div class="hero-content text-center">
|
||||
<div class="max-w-md">
|
||||
<h1 class="text-5xl font-bold">Hello there!</h1>
|
||||
<p class="py-6">
|
||||
Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
|
||||
</p>
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="mr-2">Order by:</span>
|
||||
<%= link_to 'Newest', visits_path(order_by: :desc, status: params[:status]), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
<%= link_to 'Oldest', visits_path(order_by: :asc, status: params[:status]), class: 'btn btn-xs btn-primary mx-1' %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center my-5">
|
||||
<div class='flex'>
|
||||
|
||||
<%# Beta notice %>
|
||||
<div role="alert" class="alert mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-info h-6 w-6 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span>Visits suggestion feature is currently in beta stage. Expect bugs and problems and don't hesitate to report them to <a href='https://github.com/Freika/dawarich/issues' class='link'>Github Issues</a>.</span>
|
||||
</div>
|
||||
|
||||
<%# Visits list %>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<% if @visits.empty? %>
|
||||
<div class="text-center py-8">
|
||||
<h2 class="text-2xl font-bold mb-4">No visits found</h2>
|
||||
<p class="text-base-content/70">
|
||||
Here you'll find your <%= params[:status] %> visits, but now there are none. Create some areas on your map and pretty soon you'll see visit suggestions on this page!
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<% @visits.each do |visit| %>
|
||||
<div class="card bg-base-100 shadow-xl"
|
||||
data-action="mouseenter->visits-map#highlightVisit mouseleave->visits-map#unhighlightVisit"
|
||||
data-visit-id="<%= visit.id %>"
|
||||
data-center-lat="<%= visit.center[0] %>"
|
||||
data-center-lon="<%= visit.center[1] %>">
|
||||
<div class="card-body p-4">
|
||||
<%# Visit name %>
|
||||
<div class="flex items-center justify-between">
|
||||
<%= render 'visits/name', visit: visit %>
|
||||
<div class="badge <%= visit.confirmed? ? 'badge-success' : 'badge-warning' %>">
|
||||
<%= visit.status %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Visit time and duration %>
|
||||
<div class="text-sm text-base-content/70">
|
||||
<div><%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %></div>
|
||||
<div>Duration: <%= (visit.duration / 60.0).round(1) %> hours</div>
|
||||
</div>
|
||||
|
||||
<%# Action buttons %>
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<%= render 'visits/buttons', visit: visit %>
|
||||
<label for="visit_details_popup_<%= visit.id %>" class='btn btn-xs btn-info'>Map</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%= render 'visits/modal', visit: visit %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%# Pagination %>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<%= paginate @visits %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="timeline timeline-snap-icon max-md:timeline-compact timeline-vertical">
|
||||
<% @visits.each do |visit| %>
|
||||
<li>
|
||||
<div class="timeline-middle">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="<%= visit.confirmed? ? 'green' : 'currentColor' %>"
|
||||
class="h-5 w-5">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="timeline-start md:text-end">
|
||||
<time class="font-mono italic"><%= visit.started_at.strftime('%A, %d %B %Y') %></time>
|
||||
</div>
|
||||
<div class="timeline-end md:text-end">
|
||||
<%= render partial: 'visit', locals: { visit: visit } %>
|
||||
</div>
|
||||
<hr />
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ Rails.application.routes.draw do
|
|||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[index create update destroy]
|
||||
resources :visits, only: %i[update]
|
||||
resources :visits, only: %i[index update]
|
||||
resources :stats, only: :index
|
||||
|
||||
namespace :overland do
|
||||
|
|
|
|||
301
spec/services/visits/smart_detect_spec.rb
Normal file
301
spec/services/visits/smart_detect_spec.rb
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Visits::SmartDetect do
|
||||
let(:user) { create(:user) }
|
||||
let(:start_at) { 1.day.ago }
|
||||
let(:end_at) { Time.current }
|
||||
|
||||
subject(:detector) { described_class.new(user, start_at:, end_at:) }
|
||||
|
||||
describe '#call' do
|
||||
context 'when there are no points' do
|
||||
it 'returns an empty array' do
|
||||
expect(detector.call).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a simple visit' do
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: 1.hour.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago)
|
||||
]
|
||||
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(1.hour.ago),
|
||||
ended_at: be_within(1.second).of(40.minutes.ago),
|
||||
duration: be_within(1).of(20), # 20 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: 1.hour.ago,
|
||||
geodata: geodata),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago,
|
||||
geodata: geodata),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago,
|
||||
geodata: 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: 1.hour.ago,
|
||||
geodata: mixed_geodata1),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago,
|
||||
geodata: mixed_geodata1),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago,
|
||||
geodata: mixed_geodata2)
|
||||
]
|
||||
end
|
||||
|
||||
it 'uses the most common feature type and name' do
|
||||
visit = detector.call.first
|
||||
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: 1.hour.ago,
|
||||
geodata: {}),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago,
|
||||
geodata: nil),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago,
|
||||
geodata: { 'features' => [] })
|
||||
]
|
||||
end
|
||||
|
||||
it 'falls back to Unknown Location' do
|
||||
visit = detector.call.first
|
||||
expect(visit.name).to eq('Unknown Location')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple visits to the same place' do
|
||||
let!(:morning_points) do
|
||||
[
|
||||
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: 8.hours.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 7.hours.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 6.hours.ago)
|
||||
]
|
||||
end
|
||||
|
||||
let!(:afternoon_points) do
|
||||
[
|
||||
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: 3.hours.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 2.hours.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 1.hour.ago)
|
||||
]
|
||||
end
|
||||
|
||||
it 'creates two visits' do
|
||||
expect { detector.call }.to change(Visit, :count).by(2)
|
||||
end
|
||||
|
||||
it 'assigns correct points to each visit' do
|
||||
visits = detector.call
|
||||
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: 1.hour.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago)
|
||||
]
|
||||
end
|
||||
|
||||
it 'associates the visit with the area' do
|
||||
visit = detector.call.first
|
||||
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: 1.hour.ago,
|
||||
geodata: geodata),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago,
|
||||
geodata: geodata),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago,
|
||||
geodata: geodata)
|
||||
]
|
||||
end
|
||||
|
||||
it 'prefers area name over geodata' do
|
||||
visit = detector.call.first
|
||||
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: 1.hour.ago),
|
||||
create(:point, user:, lonlat: 'POINT(1 1)', timestamp: 50.minutes.ago), # Far away
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago)
|
||||
]
|
||||
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
|
||||
let!(:points) do
|
||||
[
|
||||
create(:point, user:, lonlat: 'POINT(0 0)', timestamp: 2.hours.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 1.hour.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 5.minutes.ago)
|
||||
]
|
||||
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: 1.hour.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago)
|
||||
]
|
||||
end
|
||||
|
||||
it 'associates the visit with the place' do
|
||||
visit = detector.call.first
|
||||
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: 1.hour.ago,
|
||||
geodata: geodata),
|
||||
create(:point, user:, lonlat: 'POINT(0.00001 0.00001)', timestamp: 50.minutes.ago,
|
||||
geodata: geodata),
|
||||
create(:point, user:, lonlat: 'POINT(0.00002 0.00002)', timestamp: 40.minutes.ago,
|
||||
geodata: geodata)
|
||||
]
|
||||
end
|
||||
|
||||
it 'prefers existing place name over geodata' do
|
||||
visit = detector.call.first
|
||||
expect(visit.place).to eq(place)
|
||||
expect(visit.name).to eq('Coffee Shop')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue