- Create public/utils.js with shared frontend utility functions - Extract parseUTCDate, getTimeAgo, getTimeRemaining, getRemainingClass to utils.js - Remove duplicate functions from admin.js, app-mapbox.js, app-google.js, and app.js - Add utils.js script import to index.html and admin.html - Add comprehensive JSDoc documentation for all utility functions - Ensure consistent UTC timestamp parsing across all frontend scripts This addresses Copilot AI feedback about function duplication across multiple frontend scripts. Now all timestamp and time calculation logic is centralized in one maintainable module. Benefits: - Single source of truth for time-related utilities - Easier maintenance and updates - Consistent behavior across all frontend components - Better code organization and documentation - Reduced bundle size through deduplication
429 lines
17 KiB
JavaScript
429 lines
17 KiB
JavaScript
document.addEventListener('DOMContentLoaded', () => {
|
|
const map = L.map('map').setView([42.9634, -85.6681], 10);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 18,
|
|
}).addTo(map);
|
|
|
|
const locationForm = document.getElementById('location-form');
|
|
const messageDiv = document.getElementById('message');
|
|
const submitBtn = document.getElementById('submit-btn');
|
|
const submitText = document.getElementById('submit-text');
|
|
const submitLoading = document.getElementById('submit-loading');
|
|
const addressInput = document.getElementById('address');
|
|
const autocompleteList = document.getElementById('autocomplete-list');
|
|
|
|
let autocompleteTimeout;
|
|
let selectedIndex = -1;
|
|
let autocompleteResults = [];
|
|
let currentMarkers = [];
|
|
let updateInterval;
|
|
|
|
const clearMarkers = () => {
|
|
currentMarkers.forEach(marker => {
|
|
map.removeLayer(marker);
|
|
});
|
|
currentMarkers = [];
|
|
};
|
|
|
|
// Create custom icons for different report types
|
|
const createCustomIcon = (isPersistent = false) => {
|
|
const iconColor = isPersistent ? '#28a745' : '#dc3545'; // Green for persistent, red for temporary
|
|
const iconSymbol = isPersistent ? '🔒' : '⚠️'; // Lock for persistent, warning for temporary
|
|
|
|
return L.divIcon({
|
|
className: 'custom-marker',
|
|
html: `
|
|
<div style="
|
|
background-color: ${iconColor};
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 50%;
|
|
border: 3px solid white;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
|
cursor: pointer;
|
|
">${iconSymbol}</div>
|
|
`,
|
|
iconSize: [30, 30],
|
|
iconAnchor: [15, 15],
|
|
popupAnchor: [0, -15]
|
|
});
|
|
};
|
|
|
|
const showMarkers = locations => {
|
|
// Clear existing markers first
|
|
clearMarkers();
|
|
|
|
locations.forEach(({ latitude, longitude, address, description, created_at, persistent }) => {
|
|
if (latitude && longitude) {
|
|
const timeAgo = getTimeAgo(created_at);
|
|
const isPersistent = !!persistent;
|
|
const persistentText = isPersistent ? '<br><small><strong>📌 Persistent Report</strong></small>' : '';
|
|
|
|
const marker = L.marker([latitude, longitude], {
|
|
icon: createCustomIcon(isPersistent)
|
|
})
|
|
.addTo(map)
|
|
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>${persistentText}`);
|
|
currentMarkers.push(marker);
|
|
}
|
|
});
|
|
};
|
|
|
|
// getTimeAgo function is now available from utils.js
|
|
|
|
const refreshLocations = () => {
|
|
fetch('/api/locations')
|
|
.then(res => res.json())
|
|
.then(locations => {
|
|
showMarkers(locations);
|
|
const countElement = document.getElementById('location-count');
|
|
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
|
|
|
|
// Add visual indicator of last update
|
|
const now = new Date();
|
|
const timeStr = now.toLocaleTimeString('en-US', {
|
|
hour12: false,
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
});
|
|
countElement.title = `Last updated: ${timeStr}`;
|
|
})
|
|
.catch(err => {
|
|
console.error('Error fetching locations:', err);
|
|
document.getElementById('location-count').textContent = 'Error loading locations';
|
|
});
|
|
};
|
|
|
|
// Initial load
|
|
refreshLocations();
|
|
|
|
// Set up real-time updates every 30 seconds
|
|
updateInterval = setInterval(refreshLocations, 30000);
|
|
|
|
locationForm.addEventListener('submit', e => {
|
|
e.preventDefault();
|
|
|
|
const address = document.getElementById('address').value;
|
|
const description = document.getElementById('description').value;
|
|
|
|
if (!address) return;
|
|
|
|
submitBtn.disabled = true;
|
|
submitText.style.display = 'none';
|
|
submitLoading.style.display = 'inline';
|
|
|
|
// Try multiple geocoding strategies for intersections
|
|
const tryGeocode = async (query) => {
|
|
const cleanQuery = query.trim();
|
|
const startTime = performance.now();
|
|
|
|
console.log(`🔍 Starting geocoding for: "${cleanQuery}"`);
|
|
|
|
// Generate multiple search variations
|
|
const searches = [
|
|
cleanQuery, // Original query
|
|
cleanQuery.replace(' & ', ' and '), // Replace & with 'and'
|
|
cleanQuery.replace(' and ', ' & '), // Replace 'and' with &
|
|
cleanQuery.replace(' at ', ' & '), // Replace 'at' with &
|
|
`${cleanQuery}, Michigan`, // Add Michigan if not present
|
|
`${cleanQuery}, MI`, // Add MI if not present
|
|
];
|
|
|
|
// Add street type variations
|
|
const streetVariations = [];
|
|
searches.forEach(search => {
|
|
streetVariations.push(search);
|
|
streetVariations.push(search.replace(' St ', ' Street '));
|
|
streetVariations.push(search.replace(' Street ', ' St '));
|
|
streetVariations.push(search.replace(' Ave ', ' Avenue '));
|
|
streetVariations.push(search.replace(' Avenue ', ' Ave '));
|
|
streetVariations.push(search.replace(' Rd ', ' Road '));
|
|
streetVariations.push(search.replace(' Road ', ' Rd '));
|
|
streetVariations.push(search.replace(' Blvd ', ' Boulevard '));
|
|
streetVariations.push(search.replace(' Boulevard ', ' Blvd '));
|
|
});
|
|
|
|
// Remove duplicates
|
|
const uniqueSearches = [...new Set(streetVariations)];
|
|
console.log(`📋 Generated ${uniqueSearches.length} search variations`);
|
|
|
|
let attemptCount = 0;
|
|
for (const searchQuery of uniqueSearches) {
|
|
attemptCount++;
|
|
const queryStartTime = performance.now();
|
|
|
|
try {
|
|
console.log(`🌐 Attempt ${attemptCount}: "${searchQuery}"`);
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&extratags=1&q=${encodeURIComponent(searchQuery)}`);
|
|
const data = await response.json();
|
|
const queryTime = (performance.now() - queryStartTime).toFixed(2);
|
|
|
|
if (data && data.length > 0) {
|
|
// Prefer results that are in Michigan/Grand Rapids
|
|
const michiganResults = data.filter(item =>
|
|
item.display_name.toLowerCase().includes('michigan') ||
|
|
item.display_name.toLowerCase().includes('grand rapids') ||
|
|
item.display_name.toLowerCase().includes(', mi')
|
|
);
|
|
|
|
const result = michiganResults.length > 0 ? michiganResults[0] : data[0];
|
|
const totalTime = (performance.now() - startTime).toFixed(2);
|
|
|
|
console.log(`✅ Geocoding successful in ${totalTime}ms (query: ${queryTime}ms)`);
|
|
console.log(`📍 Found: ${result.display_name}`);
|
|
console.log(`🎯 Coordinates: ${result.lat}, ${result.lon}`);
|
|
|
|
return result;
|
|
} else {
|
|
console.log(`❌ No results found (${queryTime}ms)`);
|
|
}
|
|
} catch (error) {
|
|
const queryTime = (performance.now() - queryStartTime).toFixed(2);
|
|
console.warn(`🚫 Geocoding failed for: "${searchQuery}" (${queryTime}ms)`, error);
|
|
}
|
|
}
|
|
|
|
// If intersection search fails, try individual streets for approximate location
|
|
if (cleanQuery.includes('&') || cleanQuery.includes(' and ')) {
|
|
console.log(`🔄 Trying fallback strategy for intersection`);
|
|
const streets = cleanQuery.split(/\s+(?:&|and)\s+/i);
|
|
if (streets.length >= 2) {
|
|
const firstStreet = streets[0].trim() + ', Grand Rapids, MI';
|
|
const fallbackStartTime = performance.now();
|
|
|
|
try {
|
|
console.log(`🌐 Fallback: "${firstStreet}"`);
|
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(firstStreet)}`);
|
|
const data = await response.json();
|
|
const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2);
|
|
|
|
if (data && data.length > 0) {
|
|
const totalTime = (performance.now() - startTime).toFixed(2);
|
|
console.log(`⚠️ Using approximate location from first street (${totalTime}ms total)`);
|
|
console.log(`📍 Approximate: ${data[0].display_name}`);
|
|
return data[0];
|
|
} else {
|
|
console.log(`❌ Fallback failed - no results (${fallbackTime}ms)`);
|
|
}
|
|
} catch (error) {
|
|
const fallbackTime = (performance.now() - fallbackStartTime).toFixed(2);
|
|
console.warn(`🚫 Fallback search failed: "${firstStreet}" (${fallbackTime}ms)`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const totalTime = (performance.now() - startTime).toFixed(2);
|
|
console.error(`💥 All geocoding strategies failed after ${totalTime}ms`);
|
|
throw new Error('Address not found with any search strategy');
|
|
};
|
|
|
|
tryGeocode(address)
|
|
.then(result => {
|
|
const { lat, lon } = result;
|
|
|
|
return fetch('/api/locations', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
address,
|
|
latitude: parseFloat(lat),
|
|
longitude: parseFloat(lon),
|
|
description,
|
|
}),
|
|
});
|
|
})
|
|
.then(res => res.json())
|
|
.then(location => {
|
|
// Immediately refresh all locations to show the new one
|
|
refreshLocations();
|
|
messageDiv.textContent = 'Location reported successfully!';
|
|
messageDiv.className = 'message success';
|
|
locationForm.reset();
|
|
})
|
|
.catch(err => {
|
|
console.error('Error reporting location:', err);
|
|
messageDiv.textContent = 'Error reporting location.';
|
|
messageDiv.className = 'message error';
|
|
})
|
|
.finally(() => {
|
|
submitBtn.disabled = false;
|
|
submitText.style.display = 'inline';
|
|
submitLoading.style.display = 'none';
|
|
messageDiv.style.display = 'block';
|
|
setTimeout(() => {
|
|
messageDiv.style.display = 'none';
|
|
}, 3000);
|
|
});
|
|
});
|
|
|
|
// Autocomplete functionality
|
|
const fetchAddressSuggestions = async (query) => {
|
|
if (query.length < 3) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Create search variations for better intersection handling
|
|
const searchQueries = [
|
|
query,
|
|
query.replace(' & ', ' and '),
|
|
query.replace(' and ', ' & '),
|
|
`${query}, Grand Rapids, MI`,
|
|
`${query}, Michigan`
|
|
];
|
|
|
|
let allResults = [];
|
|
|
|
// Try each search variation
|
|
for (const searchQuery of searchQueries) {
|
|
try {
|
|
const response = await fetch(
|
|
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery)}&limit=3&countrycodes=us&addressdetails=1`
|
|
);
|
|
const data = await response.json();
|
|
|
|
if (data && data.length > 0) {
|
|
allResults = allResults.concat(data);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Autocomplete search failed for: ${searchQuery}`);
|
|
}
|
|
}
|
|
|
|
// Filter and deduplicate results
|
|
const michiganResults = allResults.filter(item => {
|
|
return item.display_name.toLowerCase().includes('michigan') ||
|
|
item.display_name.toLowerCase().includes(', mi,') ||
|
|
item.display_name.toLowerCase().includes('grand rapids');
|
|
});
|
|
|
|
// Remove duplicates based on display_name
|
|
const uniqueResults = michiganResults.filter((item, index, arr) =>
|
|
arr.findIndex(other => other.display_name === item.display_name) === index
|
|
);
|
|
|
|
autocompleteResults = uniqueResults.slice(0, 5);
|
|
showAutocomplete(autocompleteResults);
|
|
} catch (error) {
|
|
console.error('Error fetching address suggestions:', error);
|
|
hideAutocomplete();
|
|
}
|
|
};
|
|
|
|
const showAutocomplete = (results) => {
|
|
if (results.length === 0) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
autocompleteList.innerHTML = '';
|
|
selectedIndex = -1;
|
|
|
|
results.forEach((result, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'autocomplete-item';
|
|
item.textContent = result.display_name;
|
|
item.addEventListener('click', () => selectAddress(result));
|
|
autocompleteList.appendChild(item);
|
|
});
|
|
|
|
autocompleteList.style.display = 'block';
|
|
};
|
|
|
|
const hideAutocomplete = () => {
|
|
autocompleteList.style.display = 'none';
|
|
selectedIndex = -1;
|
|
};
|
|
|
|
const selectAddress = (result) => {
|
|
addressInput.value = result.display_name;
|
|
hideAutocomplete();
|
|
addressInput.focus();
|
|
};
|
|
|
|
const updateSelection = (direction) => {
|
|
const items = autocompleteList.querySelectorAll('.autocomplete-item');
|
|
|
|
if (items.length === 0) return;
|
|
|
|
// Remove previous selection
|
|
items[selectedIndex]?.classList.remove('selected');
|
|
|
|
// Update index
|
|
if (direction === 'down') {
|
|
selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
|
|
} else {
|
|
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
|
|
}
|
|
|
|
// Add new selection
|
|
items[selectedIndex].classList.add('selected');
|
|
items[selectedIndex].scrollIntoView({ block: 'nearest' });
|
|
};
|
|
|
|
// Event listeners for autocomplete
|
|
addressInput.addEventListener('input', (e) => {
|
|
clearTimeout(autocompleteTimeout);
|
|
const query = e.target.value.trim();
|
|
|
|
if (query.length < 3) {
|
|
hideAutocomplete();
|
|
return;
|
|
}
|
|
|
|
// Debounce the API calls
|
|
autocompleteTimeout = setTimeout(() => {
|
|
fetchAddressSuggestions(query);
|
|
}, 300);
|
|
});
|
|
|
|
addressInput.addEventListener('keydown', (e) => {
|
|
const isListVisible = autocompleteList.style.display === 'block';
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
if (isListVisible) {
|
|
e.preventDefault();
|
|
updateSelection('down');
|
|
}
|
|
break;
|
|
case 'ArrowUp':
|
|
if (isListVisible) {
|
|
e.preventDefault();
|
|
updateSelection('up');
|
|
}
|
|
break;
|
|
case 'Enter':
|
|
if (isListVisible && selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
selectAddress(autocompleteResults[selectedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
hideAutocomplete();
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Hide autocomplete when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.autocomplete-container')) {
|
|
hideAutocomplete();
|
|
}
|
|
});
|
|
|
|
// Cleanup interval when page is unloaded
|
|
window.addEventListener('beforeunload', () => {
|
|
if (updateInterval) {
|
|
clearInterval(updateInterval);
|
|
}
|
|
});
|
|
});
|
|
|