Add possible places to visits

This commit is contained in:
Eugene Burmakin 2025-03-03 20:11:21 +01:00
parent a4123791aa
commit 414c9e831c
10 changed files with 368 additions and 31 deletions

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class Api::V1::Visits::PossiblePlacesController < ApiController
def index
visit = current_api_user.visits.find(params[:id])
# Assuming you have a method to fetch possible places
possible_places = visit.suggested_places
render json: possible_places
rescue ActiveRecord::RecordNotFound
render json: { error: 'Visit not found' }, status: :not_found
end
end

View file

@ -31,7 +31,7 @@ class Api::V1::VisitsController < ApiController
visit = current_api_user.visits.find(params[:id])
visit = update_visit(visit)
render json: visit
render json: Api::VisitSerializer.new(visit).call
end
private

View file

@ -31,6 +31,7 @@ export default class extends BaseController {
trackedMonthsCache = null;
drawerOpen = false;
visitCircles = L.layerGroup();
currentPopup = null;
connect() {
super.connect();
@ -103,6 +104,11 @@ export default class extends BaseController {
this.map.getPane('areasPane').style.zIndex = 650;
this.map.getPane('areasPane').style.pointerEvents = 'all';
// Create custom pane for visits
this.map.createPane('visitsPane');
this.map.getPane('visitsPane').style.zIndex = 600;
this.map.getPane('visitsPane').style.pointerEvents = 'all';
// Initialize areasLayer as a feature group and add it to the map immediately
this.areasLayer = new L.FeatureGroup();
this.photoMarkers = L.layerGroup();
@ -1382,7 +1388,7 @@ export default class extends BaseController {
drawerButton.innerHTML = this.drawerOpen ? '➡️' : '⬅️';
}
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel');
const controls = document.querySelectorAll('.leaflet-control-layers, .toggle-panel-button, .leaflet-right-panel .drawer-button');
controls.forEach(control => {
control.classList.toggle('controls-shifted');
});
@ -1402,7 +1408,7 @@ export default class extends BaseController {
drawer.style.maxHeight = '100vh';
drawer.innerHTML = `
<div class="p-4">
<div class="p-4 drawer">
<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>
@ -1426,6 +1432,7 @@ export default class extends BaseController {
const startAt = urlParams.get('start_at') || new Date().toISOString();
const endAt = urlParams.get('end_at') || new Date().toISOString();
console.log('Fetching visits for:', startAt, endAt);
const response = await fetch(
`/api/v1/visits?start_at=${encodeURIComponent(startAt)}&end_at=${encodeURIComponent(endAt)}`,
{
@ -1461,22 +1468,215 @@ export default class extends BaseController {
return;
}
// Clear existing circles
// Clear existing visit circles
this.visitCircles.clearLayers();
// Draw circles only for confirmed visits
// Draw circles for all visits
visits
.filter(visit => visit.status === 'confirmed')
.filter(visit => visit.status !== 'declined')
.forEach(visit => {
if (visit.place?.latitude && visit.place?.longitude) {
const isSuggested = visit.status === 'suggested';
const circle = L.circle([visit.place.latitude, visit.place.longitude], {
color: '#4A90E2',
fillColor: '#4A90E2',
fillOpacity: 0.2,
color: isSuggested ? '#FFA500' : '#4A90E2', // Border color
fillColor: isSuggested ? '#FFD700' : '#4A90E2', // Fill color
fillOpacity: isSuggested ? 0.4 : 0.6,
radius: 100,
weight: 2
weight: 2,
interactive: true,
bubblingMouseEvents: false,
pane: 'visitsPane',
dashArray: isSuggested ? '4' : null // Dotted border for suggested
});
// Add the circle to the map
this.visitCircles.addLayer(circle);
// Fetch possible places for the visit
const fetchPossiblePlaces = async () => {
try {
// Close any existing popup before opening a new one
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
}
const response = await fetch(`/api/v1/visits/${visit.id}/possible_places`, {
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
}
});
if (!response.ok) throw new Error('Failed to fetch possible places');
const possiblePlaces = await response.json();
// Create popup content with form and dropdown
const defaultName = visit.name;
const popupContent = `
<div class="p-3">
<form class="visit-name-form" data-visit-id="${visit.id}">
<div class="form-control">
<input type="text"
class="input input-bordered input-sm w-full"
value="${defaultName}"
placeholder="Enter visit name">
</div>
<div class="form-control mt-2">
<select class="select select-bordered select-sm w-full" name="place">
${possiblePlaces.map(place => `
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
${place.name}
</option>
`).join('')}
</select>
</div>
<div class="flex gap-2 mt-2">
<button type="submit" class="btn btn-xs btn-primary">Save</button>
${visit.status !== 'confirmed' ? `
<button type="button" class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">Confirm</button>
<button type="button" class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">Decline</button>
` : ''}
</div>
</form>
</div>
`;
// Create and store the popup
const popup = L.popup({
closeButton: true,
closeOnClick: false,
autoClose: false,
maxWidth: 300, // Set maximum width
minWidth: 200 // Set minimum width
})
.setLatLng([visit.place.latitude, visit.place.longitude])
.setContent(popupContent);
// Store the current popup
this.currentPopup = popup;
// Open the popup
popup.openOn(this.map);
// Add form submit handler
const form = document.querySelector(`.visit-name-form[data-visit-id="${visit.id}"]`);
if (form) {
form.addEventListener('submit', async (event) => {
event.preventDefault(); // Prevent form submission
event.stopPropagation(); // Stop event bubbling
const newName = event.target.querySelector('input').value;
const selectedPlaceId = event.target.querySelector('select[name="place"]').value;
// Get the selected place name from the dropdown
const selectedOption = event.target.querySelector(`select[name="place"] option[value="${selectedPlaceId}"]`);
const selectedPlaceName = selectedOption ? selectedOption.textContent.trim() : '';
console.log('Selected new place:', selectedPlaceName);
try {
const response = await fetch(`/api/v1/visits/${visit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
visit: {
name: newName,
place_id: selectedPlaceId
}
})
});
if (!response.ok) throw new Error('Failed to update visit');
// Get the updated visit data from the response
const updatedVisit = await response.json();
// Update the local visit object with the latest data
// This ensures that if the popup is opened again, it will show the updated values
visit.name = updatedVisit.name || newName;
visit.place = updatedVisit.place;
// Use the selected place name for the update
const updatedName = selectedPlaceName || newName;
console.log('Updating visit name in drawer to:', updatedName);
// Update the visit name in the drawer panel
const drawerVisitItem = document.querySelector(`.drawer .visit-item[data-id="${visit.id}"]`);
if (drawerVisitItem) {
const nameElement = drawerVisitItem.querySelector('.font-semibold');
if (nameElement) {
console.log('Previous name in drawer:', nameElement.textContent);
nameElement.textContent = updatedName;
// Add a highlight effect to make the change visible
nameElement.style.backgroundColor = 'rgba(255, 255, 0, 0.3)';
setTimeout(() => {
nameElement.style.backgroundColor = '';
}, 2000);
console.log('Updated name in drawer to:', nameElement.textContent);
}
}
// Close the popup
this.map.closePopup(popup);
this.currentPopup = null;
showFlashMessage('notice', 'Visit updated successfully');
} catch (error) {
console.error('Error updating visit:', error);
showFlashMessage('error', 'Failed to update visit');
}
});
// Unified handler for confirm/decline
const handleStatusChange = async (event, status) => {
event.preventDefault();
event.stopPropagation();
try {
const response = await fetch(`/api/v1/visits/${visit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
visit: {
status: status
}
})
});
if (!response.ok) throw new Error(`Failed to ${status} visit`);
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
this.fetchAndDisplayVisits();
showFlashMessage('notice', `Visit ${status}d successfully`);
} catch (error) {
console.error(`Error ${status}ing visit:`, error);
showFlashMessage('error', `Failed to ${status} visit`);
}
};
// Add event listeners for confirm and decline buttons
const confirmBtn = form.querySelector('.confirm-visit');
const declineBtn = form.querySelector('.decline-visit');
confirmBtn?.addEventListener('click', (event) => handleStatusChange(event, 'confirmed'));
declineBtn?.addEventListener('click', (event) => handleStatusChange(event, 'declined'));
}
} catch (error) {
console.error('Error fetching possible places:', error);
showFlashMessage('error', 'Failed to load possible places');
}
};
// Attach click event to the circle
circle.on('click', fetchPossiblePlaces);
}
});
@ -1507,14 +1707,16 @@ export default class extends BaseController {
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';
const bgClass = visit.status === 'suggested' ? 'bg-neutral border-dashed border-2 border-sky-500' : 'bg-base-200';
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
return `
<div class="p-3 rounded-lg hover:bg-base-300 transition-colors visit-item ${bgClass}"
<div class="w-full p-3 rounded-lg hover:bg-base-300 transition-colors visit-item ${bgClass}"
style="${visitStyle}"
data-lat="${visit.place?.latitude || ''}"
data-lng="${visit.place?.longitude || ''}"
data-id="${visit.id}">
<div class="font-semibold">${visit.name}</div>
<div class="font-semibold overflow-hidden">${visit.name}</div>
<div class="text-sm text-gray-600">
${timeDisplay.trim()}
<span class="text-gray-500">(${durationText})</span>
@ -1619,5 +1821,20 @@ export default class extends BaseController {
});
});
}
truncateVisitNames() {
// Find all visit name elements in the drawer
const visitNameElements = document.querySelectorAll('.drawer .visit-item .font-semibold');
visitNameElements.forEach(element => {
// Add CSS classes for truncation
element.classList.add('truncate', 'max-w-[200px]', 'inline-block');
// Add tooltip with full name for hover
if (element.textContent) {
element.setAttribute('title', element.textContent);
}
});
}
}

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Api::PlaceSerializer
def initialize(place)
@place = place
end
def call
{
id: place.id,
name: place.name,
longitude: place.longitude,
latitude: place.latitude,
city: place.city,
country: place.country,
source: place.source,
geodata: place.geodata,
reverse_geocoded_at: place.reverse_geocoded_at
}
end
private
attr_reader :place
end

View file

@ -17,7 +17,8 @@ class Api::VisitSerializer
status: status,
place: {
latitude: visit.place&.latitude || visit.area&.latitude,
longitude: visit.place&.longitude || visit.area&.longitude
longitude: visit.place&.longitude || visit.area&.longitude,
id: visit.place&.id
}
}
end

View file

@ -101,7 +101,7 @@ class ReverseGeocoding::Places::FetchData
limit: 10,
distance_sort: true,
radius: 1,
units: ::DISTANCE_UNITS
units: ::DISTANCE_UNIT
)
data.reject do |place|

View file

@ -1,5 +1,5 @@
class Visits::SmartDetect
MINIMUM_VISIT_DURATION = 10.minutes
MINIMUM_VISIT_DURATION = 5.minutes
MAXIMUM_VISIT_GAP = 30.minutes
MINIMUM_POINTS_FOR_VISIT = 3
SIGNIFICANT_PLACE_VISITS = 2 # Number of visits to consider a place significant
@ -321,14 +321,72 @@ class Visits::SmartDetect
)
unless place.persisted?
place.name = visit_data[:suggested_name] || Place::DEFAULT_NAME
place.source = Place.sources[:manual]
# Get reverse geocoding data
geocoded_data = Geocoder.search([lat, lon])
if geocoded_data.present?
first_result = geocoded_data.first
data = first_result.data
properties = data['properties'] || {}
# Build a descriptive name from available components
name_components = [
properties['name'],
properties['street'],
properties['housenumber'],
properties['postcode'],
properties['city']
].compact.uniq
place.name = name_components.any? ? name_components.join(', ') : Place::DEFAULT_NAME
place.city = properties['city']
place.country = properties['country']
place.geodata = data
place.source = :photon
# Fetch nearby organizations
nearby_organizations = fetch_nearby_organizations(geocoded_data.drop(1))
# Save each organization as a possible place
nearby_organizations.each do |org|
Place.create!(
name: org[:name],
latitude: org[:latitude],
longitude: org[:longitude],
city: org[:city],
country: org[:country],
geodata: org[:geodata],
source: :suggested,
status: :possible
)
end
else
place.name = visit_data[:suggested_name] || Place::DEFAULT_NAME
place.source = :manual
end
place.save!
end
place
end
def fetch_nearby_organizations(geocoded_results)
geocoded_results.map do |result|
data = result.data
properties = data['properties'] || {}
{
name: properties['name'] || 'Unknown Organization',
latitude: result.latitude,
longitude: result.longitude,
city: properties['city'],
country: properties['country'],
geodata: data
}
end
end
def generate_visit_name(area, place, suggested_name)
return area.name if area
return place.name if place

View file

@ -6,15 +6,22 @@
</label>
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<li>
<details>
<summary>My data</summary>
<ul class="p-2 bg-base-100">
<li><%= link_to 'Points', points_url, class: "#{active_class?(points_url)}" %></li>
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "#{active_class?(visits_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
</details>
</li>
</ul>
</div>
<%= link_to 'DaWarIch', root_path, class: 'btn btn-ghost normal-case text-xl'%>
<%= link_to 'Dawarich', root_path, class: 'btn btn-ghost normal-case text-xl'%>
<div class="badge mx-4 <%= 'badge-outline' if new_version_available? %>">
<a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
<% if new_version_available? %>
@ -42,12 +49,19 @@
<div class="navbar-center hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "mx-1 #{active_class?(visits_url)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "mx-1 #{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "mx-1 #{active_class?(exports_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
<li>
<details>
<summary>My data</summary>
<ul class="p-2 bg-base-100 rounded-box shadow-md z-10">
<li><%= link_to 'Points', points_url, class: "mx-1 #{active_class?(points_url)}" %></li>
<li><%= link_to 'Visits & Places<sup>α</sup>'.html_safe, visits_url(status: :confirmed), class: "mx-1 #{active_class?(visits_url)}" %></li>
<li><%= link_to 'Imports', imports_url, class: "#{active_class?(imports_url)}" %></li>
<li><%= link_to 'Exports', exports_url, class: "#{active_class?(exports_url)}" %></li>
</ul>
</details>
</li>
</ul>
</div>
<div class="navbar-end">

View file

@ -76,8 +76,10 @@ 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[index update]
resources :stats, only: :index
resources :visits, only: %i[index update] do
get 'possible_places', to: 'visits/possible_places#index', on: :member
end
resources :stats, only: :index
namespace :overland do
resources :batches, only: :create

View file

@ -0,0 +1,7 @@
require 'rails_helper'
RSpec.describe "Api::V1::Visits::PossiblePlaces", type: :request do
describe "GET /index" do
pending "add some examples (or delete) #{__FILE__}"
end
end