dawarich/app/javascript/maps/visits.js

1633 lines
57 KiB
JavaScript

import L from "leaflet";
import { showFlashMessage } from "./helpers";
import { applyThemeToButton } from "./theme_utils";
/**
* Manages visits functionality including displaying, fetching, and interacting with visits
*/
export class VisitsManager {
constructor(map, apiKey, userTheme = 'dark') {
this.map = map;
this.apiKey = apiKey;
this.userTheme = userTheme;
// Create custom panes for different visit types
if (!map.getPane('confirmedVisitsPane')) {
map.createPane('confirmedVisitsPane');
map.getPane('confirmedVisitsPane').style.zIndex = 450; // Above default overlay pane (400)
}
if (!map.getPane('suggestedVisitsPane')) {
map.createPane('suggestedVisitsPane');
map.getPane('suggestedVisitsPane').style.zIndex = 460; // Below confirmed visits but above base layers
}
this.visitCircles = L.layerGroup();
this.confirmedVisitCircles = L.layerGroup().addTo(map); // Always visible layer for confirmed visits
this.currentPopup = null;
this.drawerOpen = false;
this.selectionMode = false;
this.selectionRect = null;
this.isSelectionActive = false;
this.selectedPoints = [];
this.highlightedVisitId = null;
this.highlightedCircles = []; // Track multiple circles instead of just one
// Add CSS for visit highlighting
const style = document.createElement('style');
style.textContent = `
.visit-highlighted {
transition: all 0.3s ease-in-out;
}
`;
document.head.appendChild(style);
}
/**
* Formats a duration in seconds to a human-readable string
* @param {number} seconds - Duration in seconds
* @returns {string} Formatted duration string
*/
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);
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';
}
/**
* Adds a button to toggle the visits drawer
*/
addDrawerButton() {
const DrawerControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-control-button drawer-button');
button.innerHTML = '⬅️'; // Left arrow icon
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
L.DomEvent.disableClickPropagation(button);
L.DomEvent.on(button, 'click', () => {
this.toggleDrawer();
});
return button;
}
});
this.map.addControl(new DrawerControl({ position: 'topright' }));
// Add the selection tool button
this.addSelectionButton();
}
/**
* Adds a button to enable/disable the area selection tool
*/
addSelectionButton() {
const SelectionControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'leaflet-bar leaflet-control leaflet-control-custom');
button.innerHTML = '⚓️';
button.title = 'Select Area';
button.id = 'selection-tool-button';
// Style the button with theme-aware styling
applyThemeToButton(button, this.userTheme);
button.style.width = '48px';
button.style.height = '48px';
button.style.borderRadius = '4px';
button.style.padding = '0';
button.style.lineHeight = '48px';
button.style.fontSize = '18px';
button.style.textAlign = 'center';
button.onclick = () => this.toggleSelectionMode();
return button;
}
});
new SelectionControl({ position: 'topright' }).addTo(this.map);
// Add CSS for selection button active state
const style = document.createElement('style');
style.textContent = `
#selection-tool-button.active {
border: 2px dashed #3388ff !important;
box-shadow: 0 0 8px rgba(51, 136, 255, 0.5) !important;
}
`;
document.head.appendChild(style);
}
/**
* Toggles the area selection mode
*/
toggleSelectionMode() {
// Clear any existing highlight
this.clearVisitHighlight();
this.isSelectionActive = !this.isSelectionActive;
if (this.selectionMode) {
// Disable selection mode
this.selectionMode = false;
this.map.dragging.enable();
document.getElementById('selection-tool-button').classList.remove('active');
this.map.off('mousedown', this.onMouseDown, this);
} else {
// Enable selection mode
this.selectionMode = true;
document.getElementById('selection-tool-button').classList.add('active');
this.map.dragging.disable();
this.map.on('mousedown', this.onMouseDown, this);
showFlashMessage('info', 'Selection mode enabled. Click and drag to select an area.');
}
}
/**
* Handles the mousedown event to start the selection
*/
onMouseDown(e) {
// Clear any existing selection
this.clearSelection();
// Store start point and create rectangle
this.startPoint = e.latlng;
// Add mousemove and mouseup listeners
this.map.on('mousemove', this.onMouseMove, this);
this.map.on('mouseup', this.onMouseUp, this);
}
/**
* Handles the mousemove event to update the selection rectangle
*/
onMouseMove(e) {
if (!this.startPoint) return;
// If we already have a rectangle, update its bounds
if (this.selectionRect) {
const bounds = L.latLngBounds(this.startPoint, e.latlng);
this.selectionRect.setBounds(bounds);
} else {
// Create a new rectangle
this.selectionRect = L.rectangle(
L.latLngBounds(this.startPoint, e.latlng),
{ color: '#3388ff', weight: 2, fillOpacity: 0.1 }
).addTo(this.map);
}
}
/**
* Handles the mouseup event to complete the selection
*/
onMouseUp(e) {
// Remove the mouse event listeners
this.map.off('mousemove', this.onMouseMove, this);
this.map.off('mouseup', this.onMouseUp, this);
if (!this.selectionRect) return;
// Finalize the selection
this.isSelectionActive = true;
// Re-enable map dragging
this.map.dragging.enable();
// Disable selection mode
this.selectionMode = false;
document.getElementById('selection-tool-button').classList.remove('active');
this.map.off('mousedown', this.onMouseDown, this);
// Fetch visits within the selection
this.fetchVisitsInSelection();
}
/**
* Clears the selection rectangle and resets selection state
*/
clearSelection() {
if (this.selectionRect) {
this.map.removeLayer(this.selectionRect);
this.selectionRect = null;
}
this.isSelectionActive = false;
this.startPoint = null;
this.selectedPoints = [];
// Clear all visit circles immediately
this.visitCircles.clearLayers();
this.confirmedVisitCircles.clearLayers();
// Always refresh visits data regardless of drawer state
// Layer visibility is now controlled by the layer control, not the drawer
this.fetchAndDisplayVisits();
// Reset drawer title
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
if (drawerTitle) {
drawerTitle.textContent = 'Recent Visits';
}
}
/**
* Fetches visits within the selected area
*/
async fetchVisitsInSelection() {
if (!this.selectionRect) return;
const bounds = this.selectionRect.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
try {
const response = await fetch(
`/api/v1/visits?selection=true&sw_lat=${sw.lat}&sw_lng=${sw.lng}&ne_lat=${ne.lat}&ne_lng=${ne.lng}`,
{
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();
// Filter points in the selected area from DOM data
this.filterPointsInSelection(bounds);
// Set selection as active to ensure date summary is displayed
this.isSelectionActive = true;
this.displayVisits(visits);
// Make sure the drawer is open
if (!this.drawerOpen) {
this.toggleDrawer();
}
// Add cancel selection button to the drawer
this.addSelectionCancelButton();
} catch (error) {
console.error('Error fetching visits in selection:', error);
showFlashMessage('error', 'Failed to load visits in selected area');
}
}
/**
* Filters points from DOM data that are within the selection bounds
* @param {L.LatLngBounds} bounds - The bounds of the selection rectangle
*/
filterPointsInSelection(bounds) {
if (!bounds) {
this.selectedPoints = [];
return;
}
// Get points from the DOM
const allPoints = this.getPointsData();
if (!allPoints || !allPoints.length) {
this.selectedPoints = [];
return;
}
// Filter points that are within the bounds
this.selectedPoints = allPoints.filter(point => {
// Point format is expected to be [lat, lng, ...other data]
const lat = parseFloat(point[0]);
const lng = parseFloat(point[1]);
if (isNaN(lat) || isNaN(lng)) return false;
return bounds.contains([lat, lng]);
});
}
/**
* Gets points data from the DOM
* @returns {Array} Array of points with coordinates and timestamps
*/
getPointsData() {
const mapElement = document.getElementById('map');
if (!mapElement) return [];
// Get coordinates data from the data attribute
const coordinatesAttr = mapElement.getAttribute('data-coordinates');
if (!coordinatesAttr) return [];
try {
return JSON.parse(coordinatesAttr);
} catch (e) {
console.error('Error parsing coordinates data:', e);
return [];
}
}
/**
* Groups visits by date
* @param {Array} visits - Array of visit objects
* @returns {Object} Object with dates as keys and counts as values
*/
groupVisitsByDate(visits) {
const dateGroups = {};
visits.forEach(visit => {
const startDate = new Date(visit.started_at);
const dateStr = startDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
if (!dateGroups[dateStr]) {
dateGroups[dateStr] = {
count: 0,
points: 0,
date: startDate
};
}
dateGroups[dateStr].count++;
});
// If we have selected points, count them by date
if (this.selectedPoints && this.selectedPoints.length > 0) {
this.selectedPoints.forEach(point => {
// Point timestamp is at index 4
const timestamp = point[4];
if (!timestamp) return;
// Convert timestamp to date string
const pointDate = new Date(parseInt(timestamp) * 1000);
const dateStr = pointDate.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
if (!dateGroups[dateStr]) {
dateGroups[dateStr] = {
count: 0,
points: 0,
date: pointDate
};
}
dateGroups[dateStr].points++;
});
}
return dateGroups;
}
/**
* Creates HTML for date summary panel
* @param {Object} dateGroups - Object with dates as keys and count/points values
* @returns {string} HTML string for date summary panel
*/
createDateSummaryHtml(dateGroups) {
// If there are no date groups, return empty string
if (Object.keys(dateGroups).length === 0) {
return '';
}
// Sort dates chronologically
const sortedDates = Object.keys(dateGroups).sort((a, b) => {
return dateGroups[a].date - dateGroups[b].date;
});
// Create HTML for each date group
const dateItems = sortedDates.map(dateStr => {
const pointsCount = dateGroups[dateStr].points || 0;
const visitsCount = dateGroups[dateStr].count || 0;
return `
<div class="flex justify-between items-center py-1 border-b border-base-300 last:border-0 my-2 hover:bg-accent hover:text-accent-content transition-colors">
<div class="font-medium">${dateStr}</div>
<div class="flex gap-2">
${pointsCount > 0 ? `<div class="badge badge-secondary">${pointsCount} pts</div>` : ''}
${visitsCount > 0 ? `<div class="badge badge-primary">${visitsCount} visits</div>` : ''}
</div>
</div>
`;
}).join('');
// Create the whole panel
return `
<div class="bg-base-100 rounded-lg p-3 mb-4 shadow-sm">
<h3 class="text-lg font-bold mb-2">Data in Selected Area</h3>
<div class="divide-y divide-base-300">
${dateItems}
</div>
</div>
`;
}
/**
* Adds a cancel button to the drawer to clear the selection
*/
addSelectionCancelButton() {
const container = document.getElementById('visits-list');
if (!container) return;
// Add cancel button at the top of the drawer if it doesn't exist
if (!document.getElementById('cancel-selection-button')) {
const cancelButton = document.createElement('button');
cancelButton.id = 'cancel-selection-button';
cancelButton.className = 'btn btn-sm btn-warning mb-4 w-full';
cancelButton.textContent = 'Cancel Area Selection';
cancelButton.onclick = () => this.clearSelection();
// Insert at the beginning of the container
container.insertBefore(cancelButton, container.firstChild);
}
}
/**
* Toggles the visibility of the visits drawer
*/
toggleDrawer() {
// Clear any existing highlight when drawer is toggled
this.clearVisitHighlight();
this.drawerOpen = !this.drawerOpen;
let drawer = document.getElementById('visits-drawer');
if (!drawer) {
drawer = this.createDrawer();
}
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, .drawer-button, #selection-tool-button');
controls.forEach(control => {
control.classList.toggle('controls-shifted');
});
// Update the drawer content if it's being opened - but don't fetch visits automatically
if (this.drawerOpen) {
const container = document.getElementById('visits-list');
if (container) {
container.innerHTML = `
<div class="text-gray-500 text-center p-4">
<p class="mb-2">No visits data loaded</p>
<p class="text-sm">Enable "Suggested Visits" or "Confirmed Visits" layers from the map controls to view visits.</p>
</div>
`;
}
}
// Note: Layer visibility is now controlled by the layer control, not the drawer state
}
/**
* Creates the drawer element for displaying visits
* @returns {HTMLElement} The created drawer element
*/
createDrawer() {
const drawer = document.createElement('div');
drawer.id = 'visits-drawer';
drawer.className = 'fixed top-0 right-0 h-full w-64 bg-base-100 shadow-lg transform translate-x-full transition-transform duration-300 ease-in-out z-39 overflow-y-auto leaflet-drawer';
// Add styles to make the drawer scrollable
drawer.style.overflowY = 'auto';
drawer.style.maxHeight = '100vh';
drawer.innerHTML = `
<div class="p-3 drawer">
<h2 class="text-xl font-bold mb-4 text-accent-content">Recent Visits</h2>
<div id="visits-list" class="space-y-2">
<p class="text-gray-500">Loading visits...</p>
</div>
</div>
`;
// Prevent map zoom when scrolling the drawer
L.DomEvent.disableScrollPropagation(drawer);
// Prevent map pan/interaction when interacting with drawer
L.DomEvent.disableClickPropagation(drawer);
this.map.getContainer().appendChild(drawer);
return drawer;
}
/**
* Fetches visits data from the API and displays them
*/
async fetchAndDisplayVisits() {
try {
console.log('fetchAndDisplayVisits called');
// Clear any existing highlight before fetching new visits
this.clearVisitHighlight();
// If there's an active selection, don't perform time-based fetch
if (this.isSelectionActive && this.selectionRect) {
console.log('Active selection found, fetching visits in selection');
this.fetchVisitsInSelection();
return;
}
// 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();
console.log('Fetching visits for date range:', { startAt, endAt });
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) {
console.error('Visits API response not ok:', response.status, response.statusText);
throw new Error('Network response was not ok');
}
const visits = await response.json();
console.log('Visits API response:', { count: visits.length, visits });
this.displayVisits(visits);
// Let the layer control manage visibility instead of drawer state
console.log('Visit circles populated - layer control will manage visibility');
console.log('visitCircles layer count:', this.visitCircles.getLayers().length);
console.log('confirmedVisitCircles layer count:', this.confirmedVisitCircles.getLayers().length);
// Check if the layers are currently enabled in the layer control and ensure they're visible
const layerControl = this.map._layers;
let suggestedVisitsEnabled = false;
let confirmedVisitsEnabled = false;
// Check layer control state
Object.values(layerControl || {}).forEach(layer => {
if (layer.name === 'Suggested Visits' && this.map.hasLayer(layer.layer)) {
suggestedVisitsEnabled = true;
}
if (layer.name === 'Confirmed Visits' && this.map.hasLayer(layer.layer)) {
confirmedVisitsEnabled = true;
}
});
console.log('Layer control state:', { suggestedVisitsEnabled, confirmedVisitsEnabled });
} catch (error) {
console.error('Error fetching visits:', error);
const container = document.getElementById('visits-list');
if (container) {
container.innerHTML = '<p class="text-red-500">Error loading visits</p>';
}
}
}
/**
* Creates visit circles on the map (independent of drawer UI)
* @param {Array} visits - Array of visit objects
*/
createMapCircles(visits) {
if (!visits || visits.length === 0) {
console.log('No visits to create circles for');
return;
}
// Clear existing visit circles
console.log('Clearing existing visit circles');
this.visitCircles.clearLayers();
this.confirmedVisitCircles.clearLayers();
let suggestedCount = 0;
let confirmedCount = 0;
// Draw circles for all visits
visits
.filter(visit => visit.status !== 'declined')
.forEach(visit => {
if (visit.place?.latitude && visit.place?.longitude) {
const isConfirmed = visit.status === 'confirmed';
const isSuggested = visit.status === 'suggested';
console.log('Creating circle for visit:', {
id: visit.id,
status: visit.status,
lat: visit.place.latitude,
lng: visit.place.longitude,
isConfirmed,
isSuggested
});
const circle = L.circle([visit.place.latitude, visit.place.longitude], {
color: isSuggested ? '#FFA500' : '#4A90E2', // Border color
fillColor: isSuggested ? '#FFD700' : '#4A90E2', // Fill color
fillOpacity: isSuggested ? 0.3 : 0.5,
radius: isConfirmed ? 110 : 80, // Increased size for confirmed visits
weight: 2,
interactive: true,
bubblingMouseEvents: false,
pane: isConfirmed ? 'confirmedVisitsPane' : 'suggestedVisitsPane', // Use appropriate pane
dashArray: isSuggested ? '4' : null // Dotted border for suggested
});
// Add the circle to the appropriate layer
if (isConfirmed) {
this.confirmedVisitCircles.addLayer(circle);
confirmedCount++;
console.log('Added confirmed visit circle to layer');
} else {
this.visitCircles.addLayer(circle);
suggestedCount++;
console.log('Added suggested visit circle to layer');
}
// Attach click event to the circle
circle.on('click', () => this.fetchPossiblePlaces(visit));
} else {
console.warn('Visit missing coordinates:', visit);
}
});
console.log('Visit circles created:', { suggestedCount, confirmedCount });
}
/**
* Displays visits on the map and in the drawer
* @param {Array} visits - Array of visit objects
*/
displayVisits(visits) {
// Always create map circles regardless of drawer state
this.createMapCircles(visits);
// Update drawer UI only if container exists
const container = document.getElementById('visits-list');
if (!container) {
console.log('No visits-list container found - skipping drawer UI update');
return;
}
// Update the drawer title if selection is active
if (this.isSelectionActive && this.selectionRect) {
const visitsCount = visits ? visits.filter(visit => visit.status !== 'declined').length : 0;
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
if (drawerTitle) {
drawerTitle.textContent = `${visitsCount} visits found`;
}
} else {
// Reset title to default when not in selection mode
const drawerTitle = document.querySelector('#visits-drawer .drawer h2');
if (drawerTitle) {
drawerTitle.textContent = 'Recent Visits';
}
}
// Group visits by date and count
const dateGroups = this.groupVisitsByDate(visits || []);
// If we have points data and are in selection mode, calculate points per date
let dateGroupsHtml = '';
if (this.isSelectionActive && this.selectionRect) {
// Create a date summary panel
dateGroupsHtml = this.createDateSummaryHtml(dateGroups);
}
if (!visits || visits.length === 0) {
let noVisitsHtml = '<p class="text-gray-500">No visits found in selected timeframe</p>';
container.innerHTML = dateGroupsHtml + noVisitsHtml;
return;
}
// Map circles are handled by createMapCircles() - just generate drawer HTML
const visitsHtml = 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: 'short', 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: 'short', day: 'numeric' })},
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
${endDate.toLocaleDateString(undefined, { month: 'short', 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-sky-500' : 'bg-base-200';
const visitStyle = visit.status === 'suggested' ? 'border: 2px dashed #60a5fa;' : '';
return `
<div class="w-full p-3 m-2 rounded-lg hover:bg-base-300 transition-colors visit-item relative ${bgClass}"
style="${visitStyle}"
data-lat="${visit.place?.latitude || ''}"
data-lng="${visit.place?.longitude || ''}"
data-id="${visit.id}">
<div class="absolute top-2 left-2 opacity-0 transition-opacity duration-200 visit-checkbox-container">
<input type="checkbox" class="checkbox checkbox-sm visit-checkbox" data-id="${visit.id}">
</div>
<div class="font-semibold overflow-hidden text-ellipsis whitespace-nowrap pl-6" title="${visit.name}">${this.truncateText(visit.name, 30)}</div>
<div class="text-sm text-gray-600">
${timeDisplay.trim()}
<div class="text-gray-500">(${durationText})</div>
</div>
${visit.place?.city ? `<div class="text-sm">${visit.place.city}, ${visit.place.country}</div>` : ''}
${visit.status !== 'confirmed' ? `
<div class="flex gap-2 mt-2">
<button class="btn btn-xs btn-success confirm-visit" data-id="${visit.id}">
Confirm
</button>
<button class="btn btn-xs btn-error decline-visit" data-id="${visit.id}">
Decline
</button>
</div>
` : ''}
</div>
`;
}).join('');
// Combine date summary and visits HTML
container.innerHTML = dateGroupsHtml + visitsHtml;
// Add the circles layer to the map
this.visitCircles.addTo(this.map);
// Add click handlers to visit items and buttons
this.addVisitItemEventListeners(container);
// Add merge functionality
this.setupMergeFunctionality(container);
// Ensure all checkboxes are hidden by default
container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => {
checkboxContainer.style.opacity = '0';
checkboxContainer.style.pointerEvents = 'none';
});
}
/**
* Sets up the merge functionality for visits
* @param {HTMLElement} container - The container with visit items
*/
setupMergeFunctionality(container) {
const visitItems = container.querySelectorAll('.visit-item');
// Add hover event to show checkboxes
visitItems.forEach(item => {
// Show checkbox on hover only if no checkboxes are currently checked
item.addEventListener('mouseenter', () => {
const allChecked = container.querySelectorAll('.visit-checkbox:checked');
if (allChecked.length === 0) {
const checkbox = item.querySelector('.visit-checkbox-container');
if (checkbox) {
checkbox.style.opacity = '1';
checkbox.style.pointerEvents = 'auto';
}
}
});
// Hide checkbox on mouse leave if not checked and if no other checkboxes are checked
item.addEventListener('mouseleave', () => {
const allChecked = container.querySelectorAll('.visit-checkbox:checked');
if (allChecked.length === 0) {
const checkbox = item.querySelector('.visit-checkbox-container');
const checkboxInput = item.querySelector('.visit-checkbox');
if (checkbox && checkboxInput && !checkboxInput.checked) {
checkbox.style.opacity = '0';
checkbox.style.pointerEvents = 'none';
}
}
});
});
// Add change event to checkboxes
const checkboxes = container.querySelectorAll('.visit-checkbox');
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.updateMergeUI(container);
});
});
}
/**
* Updates the merge UI based on selected checkboxes
* @param {HTMLElement} container - The container with visit items
*/
updateMergeUI(container) {
// Remove any existing action buttons
const existingActionButtons = container.querySelector('.visit-bulk-actions');
if (existingActionButtons) {
existingActionButtons.remove();
}
// Get all checked checkboxes
const checkedBoxes = container.querySelectorAll('.visit-checkbox:checked');
// Hide all checkboxes first
container.querySelectorAll('.visit-checkbox-container').forEach(checkboxContainer => {
checkboxContainer.style.opacity = '0';
checkboxContainer.style.pointerEvents = 'none';
});
// If no checkboxes are checked, we're done
if (checkedBoxes.length === 0) {
return;
}
// Get all visit items and their data
const visitItems = Array.from(container.querySelectorAll('.visit-item'));
// For each checked visit, show checkboxes for adjacent visits
Array.from(checkedBoxes).forEach(checkbox => {
const visitItem = checkbox.closest('.visit-item');
const visitId = checkbox.dataset.id;
const index = visitItems.indexOf(visitItem);
// Show checkbox for the current visit
const currentCheckbox = visitItem.querySelector('.visit-checkbox-container');
if (currentCheckbox) {
currentCheckbox.style.opacity = '1';
currentCheckbox.style.pointerEvents = 'auto';
}
// Show checkboxes for visits above and below
// Above visit
if (index > 0) {
const aboveVisitItem = visitItems[index - 1];
const aboveCheckbox = aboveVisitItem.querySelector('.visit-checkbox-container');
if (aboveCheckbox) {
aboveCheckbox.style.opacity = '1';
aboveCheckbox.style.pointerEvents = 'auto';
}
}
// Below visit
if (index < visitItems.length - 1) {
const belowVisitItem = visitItems[index + 1];
const belowCheckbox = belowVisitItem.querySelector('.visit-checkbox-container');
if (belowCheckbox) {
belowCheckbox.style.opacity = '1';
belowCheckbox.style.pointerEvents = 'auto';
}
}
});
// If 2 or more checkboxes are checked, show action buttons
if (checkedBoxes.length >= 2) {
// Find the lowest checked visit item
let lowestVisitItem = null;
let lowestPosition = -1;
checkedBoxes.forEach(checkbox => {
const visitItem = checkbox.closest('.visit-item');
const position = visitItems.indexOf(visitItem);
if (lowestPosition === -1 || position > lowestPosition) {
lowestPosition = position;
lowestVisitItem = visitItem;
}
});
// Create action buttons container
if (lowestVisitItem) {
// Create a container for the action buttons to ensure proper spacing
const actionsContainer = document.createElement('div');
actionsContainer.className = 'w-full p-2 visit-bulk-actions';
// Create button grid
const buttonGrid = document.createElement('div');
buttonGrid.className = 'grid grid-cols-3 gap-2';
// Merge button
const mergeButton = document.createElement('button');
mergeButton.className = 'btn btn-xs btn-primary';
mergeButton.textContent = 'Merge';
mergeButton.addEventListener('click', () => {
this.mergeVisits(Array.from(checkedBoxes).map(cb => cb.dataset.id));
});
// Confirm button
const confirmButton = document.createElement('button');
confirmButton.className = 'btn btn-xs btn-success';
confirmButton.textContent = 'Confirm';
confirmButton.addEventListener('click', () => {
this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'confirmed');
});
// Decline button
const declineButton = document.createElement('button');
declineButton.className = 'btn btn-xs btn-error';
declineButton.textContent = 'Decline';
declineButton.addEventListener('click', () => {
this.bulkUpdateVisitStatus(Array.from(checkedBoxes).map(cb => cb.dataset.id), 'declined');
});
// Add buttons to grid
buttonGrid.appendChild(mergeButton);
buttonGrid.appendChild(confirmButton);
buttonGrid.appendChild(declineButton);
// Add selection count text
const selectionText = document.createElement('div');
selectionText.className = 'text-sm text-center mt-1 text-gray-500';
selectionText.textContent = `${checkedBoxes.length} visits selected`;
// Add cancel selection button
const cancelButton = document.createElement('button');
cancelButton.className = 'btn btn-xs btn-neutral w-full mt-2';
cancelButton.textContent = 'Cancel Selection';
cancelButton.addEventListener('click', () => {
// Uncheck all checkboxes
checkedBoxes.forEach(checkbox => {
checkbox.checked = false;
});
// Update UI to remove action buttons
this.updateMergeUI(container);
});
// Add elements to container
actionsContainer.appendChild(buttonGrid);
actionsContainer.appendChild(selectionText);
actionsContainer.appendChild(cancelButton);
// Insert after the lowest visit item
lowestVisitItem.insertAdjacentElement('afterend', actionsContainer);
}
}
// Show all checkboxes when at least one is checked
const checkboxContainers = container.querySelectorAll('.visit-checkbox-container');
checkboxContainers.forEach(checkboxContainer => {
checkboxContainer.style.opacity = '1';
checkboxContainer.style.pointerEvents = 'auto';
});
}
/**
* Sends a request to merge the selected visits
* @param {Array} visitIds - Array of visit IDs to merge
*/
async mergeVisits(visitIds) {
if (!visitIds || visitIds.length < 2) {
showFlashMessage('error', 'At least 2 visits must be selected for merging');
return;
}
try {
const response = await fetch('/api/v1/visits/merge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
visit_ids: visitIds
})
});
if (!response.ok) {
throw new Error('Failed to merge visits');
}
showFlashMessage('notice', 'Visits merged successfully');
// Refresh the visits list
this.fetchAndDisplayVisits();
} catch (error) {
console.error('Error merging visits:', error);
showFlashMessage('error', 'Failed to merge visits');
}
}
/**
* Sends a request to update status for multiple visits
* @param {Array} visitIds - Array of visit IDs to update
* @param {string} status - The new status ('confirmed' or 'declined')
*/
async bulkUpdateVisitStatus(visitIds, status) {
if (!visitIds || visitIds.length === 0) {
showFlashMessage('error', 'No visits selected');
return;
}
try {
const response = await fetch('/api/v1/visits/bulk_update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
visit_ids: visitIds,
status: status
})
});
if (!response.ok) {
throw new Error(`Failed to ${status} visits`);
}
showFlashMessage('notice', `${visitIds.length} visits ${status === 'confirmed' ? 'confirmed' : 'declined'} successfully`);
// Refresh the visits list
this.fetchAndDisplayVisits();
} catch (error) {
console.error(`Error ${status}ing visits:`, error);
showFlashMessage('error', `Failed to ${status} visits`);
}
}
/**
* Adds event listeners to visit items in the drawer
* @param {HTMLElement} container - The container element with visit items
*/
addVisitItemEventListeners(container) {
const visitItems = container.querySelectorAll('.visit-item');
// Remove existing highlight if any
this.clearVisitHighlight();
visitItems.forEach(item => {
// Location click handler
item.addEventListener('click', (event) => {
// Don't trigger if clicking on buttons or checkboxes
if (event.target.classList.contains('btn') ||
event.target.classList.contains('checkbox') ||
event.target.closest('.visit-checkbox-container')) {
return;
}
const visitId = item.dataset.id;
const lat = parseFloat(item.dataset.lat);
const lng = parseFloat(item.dataset.lng);
// Highlight the clicked visit
this.highlightVisit(visitId, item, [lat, 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');
}
});
});
}
/**
* Highlights a visit both in the panel and on the map
* @param {string} visitId - The ID of the visit to highlight
* @param {HTMLElement} item - The visit item element in the drawer
* @param {Array} coords - The coordinates [lat, lng] of the visit
*/
highlightVisit(visitId, item, coords) {
// Clear existing highlight
this.clearVisitHighlight();
// Store the current highlighted visit ID
this.highlightedVisitId = visitId;
// Highlight in the drawer panel
if (item) {
item.classList.add('visit-highlighted');
item.style.border = '2px solid #60a5fa';
item.style.boxShadow = '0 0 0 2px #60a5fa';
}
// Find and highlight the circle on the map
if (coords && !isNaN(coords[0]) && !isNaN(coords[1])) {
console.log(`Highlighting visit ID: ${visitId} at coordinates [${coords[0]}, ${coords[1]}]`);
// Create a Leaflet LatLng object from the coords
const targetLatLng = L.latLng(coords[0], coords[1]);
// Helper function to find and highlight circles that are very close to the coords
const findAndHighlightCircles = (layerGroup) => {
layerGroup.eachLayer(layer => {
if (layer instanceof L.Circle) {
// Calculate the distance between circle center and target coordinates
const distance = targetLatLng.distanceTo(layer.getLatLng());
// Use a small distance threshold (2 meters)
if (distance < 2) {
console.log(`Found matching circle at distance: ${distance.toFixed(2)}m`);
// Store original style for restoration
const originalStyle = {
color: layer.options.color,
weight: layer.options.weight,
fillOpacity: layer.options.fillOpacity
};
layer._originalStyle = originalStyle;
// Apply highlighting
layer.setStyle({
color: '#f59e0b', // Amber color for highlighting
weight: 4,
fillOpacity: 0.7
});
// Add to the tracked highlights
this.highlightedCircles.push(layer);
}
}
});
};
// Check in both layer groups
findAndHighlightCircles(this.visitCircles);
findAndHighlightCircles(this.confirmedVisitCircles);
console.log(`Found ${this.highlightedCircles.length} circles to highlight`);
}
}
/**
* Clears any existing visit highlight
*/
clearVisitHighlight() {
// Clear panel highlight
const highlightedItems = document.querySelectorAll('.visit-highlighted');
highlightedItems.forEach(el => {
el.classList.remove('visit-highlighted');
el.style.border = '';
el.style.boxShadow = '';
});
// Restore original circle styles for all highlighted circles
console.log(`Clearing ${this.highlightedCircles.length} highlighted circles`);
this.highlightedCircles.forEach(circle => {
if (circle && circle._originalStyle) {
circle.setStyle(circle._originalStyle);
} else if (circle) {
console.warn('Circle missing original style during cleanup');
}
});
// Clear the array of highlighted circles
this.highlightedCircles = [];
this.highlightedVisitId = null;
}
/**
* Fetches possible places for a visit and displays them in a popup
* @param {Object} visit - The visit object
*/
async fetchPossiblePlaces(visit) {
try {
// Close any existing popup before opening a new one
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
}
// Find and highlight the corresponding visit item in the drawer
if (visit.id) {
const visitItem = document.querySelector(`.visit-item[data-id="${visit.id}"]`);
if (visitItem && visit.place?.latitude && visit.place?.longitude) {
this.highlightVisit(visit.id, visitItem, [visit.place.latitude, visit.place.longitude]);
}
}
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();
// Format date and time
const startDate = new Date(visit.started_at);
const endDate = new Date(visit.ended_at);
const isSameDay = startDate.toDateString() === endDate.toDateString();
let dateTimeDisplay;
if (isSameDay) {
dateTimeDisplay = `
${startDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', 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 {
dateTimeDisplay = `
${startDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })},
${startDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })} -
${endDate.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })},
${endDate.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false })}
`;
}
// Format duration
const durationText = this.formatDuration(visit.duration * 60);
// Status with color coding
const statusColorClass = visit.status === 'confirmed' ? 'text-success' : 'text-warning';
// Create popup content with form and dropdown
const defaultName = visit.name;
const popupContent = `
<div class="p-4 bg-base-100 text-base-content rounded-lg shadow-lg">
<div class="mb-4">
<div class="text-sm mb-2 text-base-content/80 font-medium">
${dateTimeDisplay.trim()}
</div>
<div class="space-y-1">
<div class="text-sm text-base-content/60">
Duration: ${durationText}
</div>
<div class="text-sm ${statusColorClass} font-semibold">
Status: ${visit.status.charAt(0).toUpperCase() + visit.status.slice(1)}
</div>
<div class="text-xs text-base-content/50 font-mono">
${visit.place.latitude}, ${visit.place.longitude}
</div>
</div>
</div>
<form class="visit-name-form space-y-3" data-visit-id="${visit.id}">
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-medium">Visit Name</span>
</label>
<input type="text"
class="input input-bordered input-sm w-full bg-base-200 text-base-content placeholder:text-base-content/50"
value="${defaultName}"
placeholder="Enter visit name">
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-sm font-medium">Location</span>
</label>
<select class="select select-bordered select-sm text-xs w-full bg-base-200 text-base-content" name="place">
${possiblePlaces.length > 0 ? possiblePlaces.map(place => `
<option value="${place.id}" ${place.id === visit.place.id ? 'selected' : ''}>
${place.name}
</option>
`).join('') : `
<option value="${visit.place.id}" selected>
${visit.place.name || 'Current Location'}
</option>
`}
</select>
</div>
<div class="flex gap-2 mt-4 pt-2 border-t border-base-300">
<button type="submit" class="btn btn-sm btn-primary flex-1">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Save
</button>
${visit.status !== 'confirmed' ? `
<button type="button" class="btn btn-sm btn-success confirm-visit" data-id="${visit.id}">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4"></path>
</svg>
Confirm
</button>
<button type="button" class="btn btn-sm btn-error decline-visit" data-id="${visit.id}">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Decline
</button>
` : ''}
</div>
<div class="mt-2">
<button type="button" class="btn btn-sm btn-outline btn-error w-full delete-visit" data-id="${visit.id}">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Delete Visit
</button>
</div>
</form>
</div>
`;
// Create and store the popup
const popup = L.popup({
closeButton: true,
closeOnClick: true,
autoClose: true,
closeOnEscapeKey: true,
maxWidth: 420, // Set maximum width
minWidth: 320, // Set minimum width
className: 'visit-popup' // Add custom class for additional styling
})
.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
this.addPopupFormEventListeners(visit);
} catch (error) {
console.error('Error fetching possible places:', error);
showFlashMessage('error', 'Failed to load possible places');
}
}
/**
* Adds event listeners to the popup form
* @param {Object} visit - The visit object
*/
addPopupFormEventListeners(visit) {
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;
// Validate that we have a valid place_id
if (!selectedPlaceId || selectedPlaceId === '') {
showFlashMessage('error', 'Please select a valid location');
return;
}
// 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(this.currentPopup);
this.currentPopup = null;
showFlashMessage('notice', 'Visit updated successfully');
} catch (error) {
console.error('Error updating visit:', error);
showFlashMessage('error', 'Failed to update visit');
}
});
// Add event listeners for confirm and decline buttons
const confirmBtn = form.querySelector('.confirm-visit');
const declineBtn = form.querySelector('.decline-visit');
const deleteBtn = form.querySelector('.delete-visit');
confirmBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'confirmed'));
declineBtn?.addEventListener('click', (event) => this.handleStatusChange(event, visit.id, 'declined'));
deleteBtn?.addEventListener('click', (event) => this.handleDeleteVisit(event, visit.id));
}
}
/**
* Handles status change for a visit (confirm/decline)
* @param {Event} event - The click event
* @param {string} visitId - The visit ID
* @param {string} status - The new status ('confirmed' or 'declined')
*/
async handleStatusChange(event, visitId, status) {
event.preventDefault();
event.stopPropagation();
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: status
}
})
});
if (!response.ok) throw new Error(`Failed to ${status} visit`);
if (this.currentPopup) {
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`);
}
}
/**
* Handles deletion of a visit with confirmation
* @param {Event} event - The click event
* @param {string} visitId - The visit ID to delete
*/
async handleDeleteVisit(event, visitId) {
event.preventDefault();
event.stopPropagation();
// Show confirmation dialog
const confirmDelete = confirm('Are you sure you want to delete this visit? This action cannot be undone.');
if (!confirmDelete) {
return;
}
try {
const response = await fetch(`/api/v1/visits/${visitId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
}
});
if (response.ok) {
// Close the popup
if (this.currentPopup) {
this.map.closePopup(this.currentPopup);
this.currentPopup = null;
}
// Refresh the visits list
this.fetchAndDisplayVisits();
showFlashMessage('notice', 'Visit deleted successfully');
} else {
const errorData = await response.json();
const errorMessage = errorData.error || 'Failed to delete visit';
showFlashMessage('error', errorMessage);
}
} catch (error) {
console.error('Error deleting visit:', error);
showFlashMessage('error', 'Failed to delete visit');
}
}
/**
* Truncates text to a specified length and adds ellipsis if needed
* @param {string} text - The text to truncate
* @param {number} maxLength - The maximum length
* @returns {string} Truncated text
*/
truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
/**
* Gets the visits layer group for adding to the map controls
* @returns {L.LayerGroup} The visits layer group
*/
getVisitCirclesLayer() {
return this.visitCircles;
}
/**
* Gets the confirmed visits layer group that's always visible
* @returns {L.LayerGroup} The confirmed visits layer group
*/
getConfirmedVisitCirclesLayer() {
return this.confirmedVisitCircles;
}
}