From a4123791aa61f056023ab876d0ad20d4279e3e32 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Sun, 2 Mar 2025 21:24:57 +0100 Subject: [PATCH 01/25] Add visits to the map page --- app/assets/stylesheets/application.css | 90 +---- .../stylesheets/application.tailwind.css | 55 +++ app/controllers/api/v1/visits_controller.rb | 28 +- app/controllers/visits_controller.rb | 3 +- app/javascript/controllers/maps_controller.js | 297 ++++++++++++++- .../visit_modal_places_controller.js | 5 + .../controllers/visits_map_controller.js | 110 ++++++ app/models/visit.rb | 18 +- app/serializers/api/visit_serializer.rb | 64 ++++ .../reverse_geocoding/places/fetch_data.rb | 2 +- app/services/visits/smart_detect.rb | 339 ++++++++++++++++++ app/services/visits/suggest.rb | 80 +---- app/views/visits/_visit.html.erb | 6 +- app/views/visits/index.html.erb | 170 +++++---- config/routes.rb | 2 +- spec/services/visits/smart_detect_spec.rb | 301 ++++++++++++++++ 16 files changed, 1323 insertions(+), 247 deletions(-) create mode 100644 app/javascript/controllers/visits_map_controller.js create mode 100644 app/serializers/api/visit_serializer.rb create mode 100644 app/services/visits/smart_detect.rb create mode 100644 spec/services/visits/smart_detect_spec.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 982d94b0..d6a6ecf3 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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); diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 48e213d2..8b0dac9b 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -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; +} diff --git a/app/controllers/api/v1/visits_controller.rb b/app/controllers/api/v1/visits_controller.rb index 6a6b51fb..3c5a773e 100644 --- a/app/controllers/api/v1/visits_controller.rb +++ b/app/controllers/api/v1/visits_controller.rb @@ -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) diff --git a/app/controllers/visits_controller.rb b/app/controllers/visits_controller.rb index a8469831..0ce00b10 100644 --- a/app/controllers/visits_controller.rb +++ b/app/controllers/visits_controller.rb @@ -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 diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 9868368a..6b2de7fd 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -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 = ` +
+

Recent Visits

+
+

Loading visits...

+
+
+ `; + + // 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 = '

Error loading visits

'; + } + } + } + + displayVisits(visits) { + const container = document.getElementById('visits-list'); + if (!container) return; + + if (!visits || visits.length === 0) { + container.innerHTML = '

No visits found in selected timeframe

'; + 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 ` +
+
${visit.name}
+
+ ${timeDisplay.trim()} + (${durationText}) +
+ ${visit.place?.city ? `
${visit.place.city}, ${visit.place.country}
` : ''} + ${visit.status !== 'confirmed' ? ` +
+ + +
+ ` : ''} +
+ `; + }).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'); + } + }); + }); } } diff --git a/app/javascript/controllers/visit_modal_places_controller.js b/app/javascript/controllers/visit_modal_places_controller.js index b697622e..08ef8b37 100644 --- a/app/javascript/controllers/visit_modal_places_controller.js +++ b/app/javascript/controllers/visit_modal_places_controller.js @@ -46,4 +46,9 @@ export default class extends BaseController { element.textContent = newName; }); } + + updateAll(event) { + const newName = event.detail.name; + this.updateVisitNameOnPage(newName); + } } diff --git a/app/javascript/controllers/visits_map_controller.js b/app/javascript/controllers/visits_map_controller.js new file mode 100644 index 00000000..b1784dd3 --- /dev/null +++ b/app/javascript/controllers/visits_map_controller.js @@ -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; + } + } +} diff --git a/app/models/visit.rb b/app/models/visit.rb index 2fadfb2e..9ed895f3 100644 --- a/app/models/visit.rb +++ b/app/models/visit.rb @@ -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 diff --git a/app/serializers/api/visit_serializer.rb b/app/serializers/api/visit_serializer.rb new file mode 100644 index 00000000..26326180 --- /dev/null +++ b/app/serializers/api/visit_serializer.rb @@ -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 diff --git a/app/services/reverse_geocoding/places/fetch_data.rb b/app/services/reverse_geocoding/places/fetch_data.rb index 9b691d36..2a91b6e2 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_UNITS ) data.reject do |place| diff --git a/app/services/visits/smart_detect.rb b/app/services/visits/smart_detect.rb new file mode 100644 index 00000000..587e6984 --- /dev/null +++ b/app/services/visits/smart_detect.rb @@ -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 diff --git a/app/services/visits/suggest.rb b/app/services/visits/suggest.rb index 4d02a45c..385bb20d 100644 --- a/app/services/visits/suggest.rb +++ b/app/services/visits/suggest.rb @@ -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 Visits 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 diff --git a/app/views/visits/_visit.html.erb b/app/views/visits/_visit.html.erb index c5de1ce7..be72f2ea 100644 --- a/app/views/visits/_visit.html.erb +++ b/app/views/visits/_visit.html.erb @@ -1,4 +1,8 @@ -
+
<%= render 'visits/name', visit: visit %> diff --git a/app/views/visits/index.html.erb b/app/views/visits/index.html.erb index e27dbac2..f547ede4 100644 --- a/app/views/visits/index.html.erb +++ b/app/views/visits/index.html.erb @@ -1,89 +1,103 @@ -<% content_for :title, "Visits" %> +<%# content_for :title, "Visits" %> -
-
- <%= 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')}" %> +
+ <%# Top navigation tabs %> +
+
+ <%= 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')}" %> +
-
-
- <%= 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? %> - <%= @suggested_visits_count %> + <%# Main content area with map and side panel %> +
+ <%# Map container %> +
+
+
+ + <%# Side panel %> +
+ <%# Visit filters %> +
+
+ <%= 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? %> + <%= @suggested_visits_count %> + <% end %> <% end %> - <% end %> - <%= link_to 'Declined', visits_path(status: :declined), role: 'tab', - class: "tab #{active_tab?(visits_path(status: :declined))}" %> -
-
- Order by: - <%= 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' %> -
-
+ <%= link_to 'Declined', visits_path(status: :declined), role: 'tab', + class: "tab #{active_tab?(visits_path(status: :declined))}" %> +
- - - <% if @visits.empty? %> -
-
-
-

Hello there!

-

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

+
+ Order by: + <%= 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' %>
-
- <% else %> -
-
+ + <%# Beta notice %> + + + <%# Visits list %> +
+ <% if @visits.empty? %> +
+

No visits found

+

+ 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! +

+
+ <% else %> +
+ <% @visits.each do |visit| %> +
+
+ <%# Visit name %> +
+ <%= render 'visits/name', visit: visit %> +
+ <%= visit.status %> +
+
+ + <%# Visit time and duration %> +
+
<%= "#{visit.started_at.strftime('%H:%M')} - #{visit.ended_at.strftime('%H:%M')}" %>
+
Duration: <%= (visit.duration / 60.0).round(1) %> hours
+
+ + <%# Action buttons %> +
+ <%= render 'visits/buttons', visit: visit %> + +
+
+
+ <%= render 'visits/modal', visit: visit %> + <% end %> +
+ <% end %> +
+ + <%# Pagination %> +
<%= paginate @visits %>
- -
    - <% @visits.each do |visit| %> -
  • -
    - - - -
    -
    - -
    -
    - <%= render partial: 'visit', locals: { visit: visit } %> -
    -
    -
  • - <% end %> -
- <% end %> +
diff --git a/config/routes.rb b/config/routes.rb index 8cabfc85..4c524761 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/services/visits/smart_detect_spec.rb b/spec/services/visits/smart_detect_spec.rb new file mode 100644 index 00000000..f8bf2f5c --- /dev/null +++ b/spec/services/visits/smart_detect_spec.rb @@ -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 From 414c9e831cc9c21d14061991ddbfdff5ac4179ca Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 3 Mar 2025 20:11:21 +0100 Subject: [PATCH 02/25] Add possible places to visits --- .../v1/visits/possible_places_controller.rb | 13 + app/controllers/api/v1/visits_controller.rb | 2 +- app/javascript/controllers/maps_controller.js | 241 +++++++++++++++++- app/serializers/api/place_serializer.rb | 25 ++ app/serializers/api/visit_serializer.rb | 3 +- .../reverse_geocoding/places/fetch_data.rb | 2 +- app/services/visits/smart_detect.rb | 64 ++++- app/views/shared/_navbar.html.erb | 36 ++- config/routes.rb | 6 +- .../api/v1/visits/possible_places_spec.rb | 7 + 10 files changed, 368 insertions(+), 31 deletions(-) create mode 100644 app/controllers/api/v1/visits/possible_places_controller.rb create mode 100644 app/serializers/api/place_serializer.rb create mode 100644 spec/requests/api/v1/visits/possible_places_spec.rb 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 @@