diff --git a/app/controllers/api/v1/visits/possible_places_controller.rb b/app/controllers/api/v1/visits/possible_places_controller.rb new file mode 100644 index 00000000..0fc76aec --- /dev/null +++ b/app/controllers/api/v1/visits/possible_places_controller.rb @@ -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 diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 3c5a773e..057b3e4e 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -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 diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 6b2de7fd..25ef66b2 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -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 = ` -
+

Recent Visits

Loading visits...

@@ -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 = ` +
+
+
+ +
+
+ +
+
+ + ${visit.status !== 'confirmed' ? ` + + + ` : ''} +
+
+
+ `; + + // 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 ` -
-
${visit.name}
+
${visit.name}
${timeDisplay.trim()} (${durationText}) @@ -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); + } + }); + } } diff --git a/app/serializers/api/place_serializer.rb b/app/serializers/api/place_serializer.rb new file mode 100644 index 00000000..3df93efc --- /dev/null +++ b/app/serializers/api/place_serializer.rb @@ -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 diff --git a/app/serializers/api/visit_serializer.rb b/app/serializers/api/visit_serializer.rb index 26326180..e2eb4a9a 100644 --- a/app/serializers/api/visit_serializer.rb +++ b/app/serializers/api/visit_serializer.rb @@ -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 diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 2a91b6e2..5c930b5a 100644 --- a/app/services/reverse_geocoding/places/fetch_data.rb +++ b/app/services/reverse_geocoding/places/fetch_data.rb @@ -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| diff --git a/app/services/visits/smart_detect.rb b/app/services/visits/smart_detect.rb index 587e6984..0a337230 100644 --- a/app/services/visits/smart_detect.rb +++ b/app/services/visits/smart_detect.rb @@ -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 diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 96ec7428..fbbadc2e 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -6,15 +6,22 @@
- <%= 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'%>
<% if new_version_available? %> @@ -42,12 +49,19 @@