ice/public/app.js
Deco Vander a063d5a2c9 Create shared utility module to eliminate function duplication
- 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
2025-07-04 13:22:17 -04:00

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);
}
});
});