Add visits to the map page

This commit is contained in:
Eugene Burmakin 2025-03-02 21:24:57 +01:00
parent ff6d5f1c97
commit a4123791aa
16 changed files with 1323 additions and 247 deletions

View file

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

View file

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

View file

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

View file

@ -11,11 +11,10 @@ class VisitsController < ApplicationController
visits = current_user
.visits
.where(status:)
.includes(%i[suggested_places area])
.includes(%i[suggested_places area points])
.order(started_at: order_by)
@suggested_visits_count = current_user.visits.suggested.count
@visits = visits.page(params[:page]).per(10)
end

View file

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

View file

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

View file

@ -0,0 +1,110 @@
import BaseController from "./base_controller"
import L from "leaflet"
import { osmMapLayer } from "../maps/layers"
export default class extends BaseController {
static targets = ["container"]
connect() {
this.initializeMap();
this.visits = new Map();
this.highlightedVisit = null;
}
initializeMap() {
// Initialize the map with a default center (will be updated when visits are added)
this.map = L.map(this.containerTarget).setView([0, 0], 2);
osmMapLayer(this.map, "OpenStreetMap");
// Add all visits to the map
const visitElements = document.querySelectorAll('[data-visit-id]');
if (visitElements.length > 0) {
const bounds = L.latLngBounds([]);
visitElements.forEach(element => {
const visitId = element.dataset.visitId;
const lat = parseFloat(element.dataset.centerLat);
const lon = parseFloat(element.dataset.centerLon);
if (!isNaN(lat) && !isNaN(lon)) {
const marker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: this.getVisitColor(element),
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(this.map);
// Store the marker reference
this.visits.set(visitId, {
marker,
element
});
bounds.extend([lat, lon]);
}
});
// Fit the map to show all visits
if (!bounds.isEmpty()) {
this.map.fitBounds(bounds, {
padding: [50, 50]
});
}
}
}
getVisitColor(element) {
// Check if the visit has a status badge
const badge = element.querySelector('.badge');
if (badge) {
if (badge.classList.contains('badge-success')) {
return '#2ecc71'; // Green for confirmed
} else if (badge.classList.contains('badge-warning')) {
return '#f1c40f'; // Yellow for suggested
}
}
return '#e74c3c'; // Red for declined or unknown
}
highlightVisit(event) {
const visitId = event.currentTarget.dataset.visitId;
const visit = this.visits.get(visitId);
if (visit) {
// Reset previous highlight if any
if (this.highlightedVisit) {
this.highlightedVisit.marker.setStyle({
radius: 8,
fillOpacity: 0.8
});
}
// Highlight the current visit
visit.marker.setStyle({
radius: 12,
fillOpacity: 1
});
visit.marker.bringToFront();
// Center the map on the visit
this.map.panTo(visit.marker.getLatLng());
this.highlightedVisit = visit;
}
}
unhighlightVisit(event) {
const visitId = event.currentTarget.dataset.visitId;
const visit = this.visits.get(visitId);
if (visit && this.highlightedVisit === visit) {
visit.marker.setStyle({
radius: 8,
fillOpacity: 0.8
});
this.highlightedVisit = null;
}
}
}

View file

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

View 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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View 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