ice/public/app-google.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

421 lines
15 KiB
JavaScript

document.addEventListener('DOMContentLoaded', async () => {
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);
// Get API configuration
let config = { hasGoogleMaps: false, googleMapsApiKey: null };
try {
const configResponse = await fetch('/api/config');
config = await configResponse.json();
console.log('🔧 API Configuration:', config.hasGoogleMaps ? 'Google Maps enabled' : 'Using Nominatim fallback');
} catch (error) {
console.warn('Failed to load API configuration, using fallback');
}
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;
let googleAutocompleteService = null;
let googleGeocoderService = null;
// Initialize Google Services if API key is available
if (config.hasGoogleMaps) {
try {
// Load Google Maps API
await loadGoogleMapsAPI(config.googleMapsApiKey);
googleAutocompleteService = new google.maps.places.AutocompleteService();
googleGeocoderService = new google.maps.Geocoder();
console.log('✅ Google Maps services initialized');
} catch (error) {
console.warn('❌ Failed to initialize Google Maps, falling back to Nominatim');
}
}
function loadGoogleMapsAPI(apiKey) {
return new Promise((resolve, reject) => {
if (window.google) {
resolve();
return;
}
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
script.async = true;
script.defer = true;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
const clearMarkers = () => {
currentMarkers.forEach(marker => {
map.removeLayer(marker);
});
currentMarkers = [];
};
const showMarkers = locations => {
clearMarkers();
locations.forEach(({ latitude, longitude, address, description, created_at }) => {
if (latitude && longitude) {
const timeAgo = getTimeAgo(created_at);
const marker = L.marker([latitude, longitude])
.addTo(map)
.bindPopup(`<strong>${address}</strong><br>${description || 'No additional details'}<br><small>Reported ${timeAgo}</small>`);
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' : ''}`;
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';
});
};
refreshLocations();
updateInterval = setInterval(refreshLocations, 30000);
// Google Maps Geocoding (Fast!)
const googleGeocode = async (address) => {
const startTime = performance.now();
console.log(`🚀 Google Geocoding: "${address}"`);
return new Promise((resolve, reject) => {
googleGeocoderService.geocode({
address: address,
componentRestrictions: { country: 'US', administrativeArea: 'MI' },
region: 'us'
}, (results, status) => {
const time = (performance.now() - startTime).toFixed(2);
if (status === 'OK' && results.length > 0) {
const result = results[0];
console.log(`✅ Google geocoding successful in ${time}ms`);
console.log(`📍 Found: ${result.formatted_address}`);
resolve({
lat: result.geometry.location.lat(),
lon: result.geometry.location.lng(),
display_name: result.formatted_address
});
} else {
console.log(`❌ Google geocoding failed: ${status} (${time}ms)`);
reject(new Error(`Google geocoding failed: ${status}`));
}
});
});
};
// Nominatim Fallback (Slower but free)
const nominatimGeocode = async (address) => {
const startTime = performance.now();
console.log(`🐌 Nominatim fallback: "${address}"`);
const searches = [
address,
`${address}, Michigan`,
`${address}, MI`,
`${address}, Grand Rapids, MI`
];
for (const query of searches) {
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=1&countrycodes=us`);
const data = await response.json();
if (data && data.length > 0) {
const time = (performance.now() - startTime).toFixed(2);
console.log(`✅ Nominatim successful in ${time}ms`);
return data[0];
}
} catch (error) {
console.warn(`Nominatim query failed: ${query}`);
}
}
const time = (performance.now() - startTime).toFixed(2);
console.error(`💥 All geocoding failed after ${time}ms`);
throw new Error('Address not found');
};
// Main geocoding function
const geocodeAddress = async (address) => {
if (googleGeocoderService) {
try {
return await googleGeocode(address);
} catch (error) {
console.warn('Google geocoding failed, trying Nominatim fallback');
return await nominatimGeocode(address);
}
} else {
return await nominatimGeocode(address);
}
};
// Google Places Autocomplete (Super fast!)
const googleAutocomplete = async (query) => {
const startTime = performance.now();
console.log(`⚡ Google Autocomplete: "${query}"`);
return new Promise((resolve) => {
googleAutocompleteService.getPlacePredictions({
input: query,
componentRestrictions: { country: 'us' },
types: ['address'],
location: new google.maps.LatLng(42.9634, -85.6681),
radius: 50000 // 50km around Grand Rapids
}, (predictions, status) => {
const time = (performance.now() - startTime).toFixed(2);
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
const michiganResults = predictions.filter(p =>
p.description.toLowerCase().includes('mi,') ||
p.description.toLowerCase().includes('michigan')
);
console.log(`⚡ Google autocomplete: ${michiganResults.length} results in ${time}ms`);
resolve(michiganResults.slice(0, 5).map(p => ({
display_name: p.description,
place_id: p.place_id
})));
} else {
console.log(`❌ Google autocomplete failed: ${status} (${time}ms)`);
resolve([]);
}
});
});
};
// Nominatim Autocomplete Fallback
const nominatimAutocomplete = async (query) => {
console.log(`🐌 Nominatim autocomplete fallback: "${query}"`);
try {
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=us&addressdetails=1`);
const data = await response.json();
return data.filter(item =>
item.display_name.toLowerCase().includes('michigan') ||
item.display_name.toLowerCase().includes(', mi,')
).slice(0, 5);
} catch (error) {
console.error('Nominatim autocomplete failed:', error);
return [];
}
};
// Main autocomplete function
const fetchAddressSuggestions = async (query) => {
if (query.length < 3) {
hideAutocomplete();
return;
}
let results = [];
if (googleAutocompleteService) {
try {
results = await googleAutocomplete(query);
} catch (error) {
console.warn('Google autocomplete failed, trying Nominatim');
}
}
if (results.length === 0) {
results = await nominatimAutocomplete(query);
}
autocompleteResults = results;
showAutocomplete(autocompleteResults);
};
// Form submission
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';
geocodeAddress(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 => {
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 UI functions (same as before)
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;
items[selectedIndex]?.classList.remove('selected');
if (direction === 'down') {
selectedIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
} else {
selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
}
items[selectedIndex].classList.add('selected');
items[selectedIndex].scrollIntoView({ block: 'nearest' });
};
// Event listeners
addressInput.addEventListener('input', (e) => {
clearTimeout(autocompleteTimeout);
const query = e.target.value.trim();
if (query.length < 3) {
hideAutocomplete();
return;
}
autocompleteTimeout = setTimeout(() => {
fetchAddressSuggestions(query);
}, 200); // Faster debounce with Google APIs
});
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;
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.autocomplete-container')) {
hideAutocomplete();
}
});
window.addEventListener('beforeunload', () => {
if (updateInterval) {
clearInterval(updateInterval);
}
});
});