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

635 lines
23 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 = { hasMapbox: false, mapboxAccessToken: null };
try {
const configResponse = await fetch('/api/config');
config = await configResponse.json();
console.log('🔧 API Configuration:', config.hasMapbox ? 'MapBox 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');
// View toggle elements
const mapViewBtn = document.getElementById('map-view-btn');
const tableViewBtn = document.getElementById('table-view-btn');
const mapView = document.getElementById('map-view');
const tableView = document.getElementById('table-view');
const reportsTableBody = document.getElementById('reports-tbody');
const tableLocationCount = document.getElementById('table-location-count');
let autocompleteTimeout;
let selectedIndex = -1;
let autocompleteResults = [];
let currentMarkers = [];
let updateInterval;
let currentLocations = [];
let currentView = 'map'; // 'map' or 'table'
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 => {
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
// Toggle between map and table view
const switchView = (viewType) => {
currentView = viewType;
if (viewType === 'map') {
mapView.style.display = 'block';
tableView.style.display = 'none';
mapViewBtn.classList.add('active');
tableViewBtn.classList.remove('active');
// Invalidate map size after showing
setTimeout(() => map.invalidateSize(), 100);
} else {
mapView.style.display = 'none';
tableView.style.display = 'block';
mapViewBtn.classList.remove('active');
tableViewBtn.classList.add('active');
// Render table with current data
renderTable(currentLocations);
}
};
// Render table view
const renderTable = (locations) => {
if (!locations || locations.length === 0) {
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">No active reports</td></tr>';
tableLocationCount.textContent = '0 active reports';
return;
}
// Sort by most recent first
const sortedLocations = [...locations].sort((a, b) =>
new Date(b.created_at) - new Date(a.created_at)
);
const tableHTML = sortedLocations.map(location => {
const timeAgo = getTimeAgo(location.created_at);
const timeRemaining = getTimeRemaining(location.created_at, location.persistent);
const remainingClass = getRemainingClass(location.created_at, location.persistent);
const reportedTime = new Date(location.created_at).toLocaleString();
return `
<tr>
<td class="location-cell" title="${location.address}">${location.address}</td>
<td class="details-cell" title="${location.description || 'No additional details'}">
${location.description || '<em>No additional details</em>'}
</td>
<td class="time-cell" title="${reportedTime}">${timeAgo}</td>
<td class="remaining-cell ${remainingClass}">${timeRemaining}</td>
</tr>
`;
}).join('');
reportsTableBody.innerHTML = tableHTML;
tableLocationCount.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
};
// getTimeRemaining and getRemainingClass functions are now available from utils.js
const refreshLocations = () => {
fetch('/api/locations')
.then(res => res.json())
.then(locations => {
currentLocations = locations;
// Update map view
showMarkers(locations);
const countElement = document.getElementById('location-count');
countElement.textContent = `${locations.length} active report${locations.length !== 1 ? 's' : ''}`;
// Update table view if it's currently visible
if (currentView === 'table') {
renderTable(locations);
}
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';
if (currentView === 'table') {
reportsTableBody.innerHTML = '<tr><td colspan="4" class="loading">Error loading reports</td></tr>';
}
});
};
refreshLocations();
updateInterval = setInterval(refreshLocations, 30000);
// MapBox Geocoding (Fast and Accurate!)
const mapboxGeocode = async (address) => {
const startTime = performance.now();
console.log(`🚀 MapBox Geocoding: "${address}"`);
try {
const encodedAddress = encodeURIComponent(address);
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedAddress}.json?` +
`access_token=${config.mapboxAccessToken}&` +
`country=US&` +
`region=MI&` +
`proximity=-85.6681,42.9634&` + // Grand Rapids coordinates
`limit=1`
);
if (!response.ok) {
throw new Error(`MapBox API error: ${response.status}`);
}
const data = await response.json();
const time = (performance.now() - startTime).toFixed(2);
if (data.features && data.features.length > 0) {
const result = data.features[0];
console.log(`✅ MapBox geocoding successful in ${time}ms`);
console.log(`📍 Found: ${result.place_name}`);
return {
lat: result.center[1], // MapBox returns [lng, lat]
lon: result.center[0],
display_name: result.place_name
};
} else {
console.log(`❌ MapBox geocoding: no results (${time}ms)`);
throw new Error('No results found');
}
} catch (error) {
const time = (performance.now() - startTime).toFixed(2);
console.warn(`🚫 MapBox geocoding failed (${time}ms):`, error);
throw error;
}
};
// 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 (config.hasMapbox) {
try {
return await mapboxGeocode(address);
} catch (error) {
console.warn('MapBox geocoding failed, trying Nominatim fallback');
return await nominatimGeocode(address);
}
} else {
return await nominatimGeocode(address);
}
};
// MapBox Autocomplete (Lightning Fast!)
const mapboxAutocomplete = async (query) => {
const startTime = performance.now();
console.log(`⚡ MapBox Autocomplete: "${query}"`);
try {
const encodedQuery = encodeURIComponent(query);
const response = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodedQuery}.json?` +
`access_token=${config.mapboxAccessToken}&` +
`country=US&` +
`region=MI&` +
`proximity=-85.6681,42.9634&` + // Grand Rapids coordinates
`types=address,poi&` +
`autocomplete=true&` +
`limit=5`
);
if (!response.ok) {
throw new Error(`MapBox API error: ${response.status}`);
}
const data = await response.json();
const time = (performance.now() - startTime).toFixed(2);
if (data.features && data.features.length > 0) {
// Filter for Michigan results
const michiganResults = data.features.filter(feature =>
feature.place_name.toLowerCase().includes('michigan') ||
feature.place_name.toLowerCase().includes(', mi,') ||
feature.context?.some(ctx => ctx.short_code === 'us-mi')
);
console.log(`⚡ MapBox autocomplete: ${michiganResults.length} results in ${time}ms`);
return michiganResults.slice(0, 5).map(feature => ({
display_name: feature.place_name,
mapbox_id: feature.id
}));
} else {
console.log(`❌ MapBox autocomplete: no results (${time}ms)`);
return [];
}
} catch (error) {
const time = (performance.now() - startTime).toFixed(2);
console.warn(`🚫 MapBox autocomplete failed (${time}ms):`, error);
return [];
}
};
// 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 (config.hasMapbox) {
try {
results = await mapboxAutocomplete(query);
} catch (error) {
console.warn('MapBox 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 => {
if (!res.ok) {
return res.json().then(errorData => {
throw { status: res.status, data: errorData };
});
}
return res.json();
})
.then(location => {
refreshLocations();
messageDiv.textContent = 'Location reported successfully!';
messageDiv.className = 'message success';
locationForm.reset();
})
.catch(err => {
console.error('Error reporting location:', err);
// Handle specific profanity rejection
if (err.status === 400 && err.data && err.data.error === 'Submission rejected') {
messageDiv.textContent = err.data.message;
messageDiv.className = 'message error profanity-rejection';
// Clear the description field to encourage rewriting
document.getElementById('description').value = '';
document.getElementById('description').focus();
} else if (err.data && err.data.error) {
messageDiv.textContent = err.data.error;
messageDiv.className = 'message error';
} else {
messageDiv.textContent = 'Error reporting location. Please try again.';
messageDiv.className = 'message error';
}
})
.finally(() => {
submitBtn.disabled = false;
submitText.style.display = 'inline';
submitLoading.style.display = 'none';
messageDiv.style.display = 'block';
// Longer timeout for profanity rejection messages
const timeout = messageDiv.className.includes('profanity-rejection') ? 15000 : 3000;
setTimeout(() => {
messageDiv.style.display = 'none';
}, timeout);
});
});
// Autocomplete UI functions
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;
}
// Ultra-fast debounce with MapBox
autocompleteTimeout = setTimeout(() => {
fetchAddressSuggestions(query);
}, config.hasMapbox ? 150 : 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;
}
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.autocomplete-container')) {
hideAutocomplete();
}
});
// View toggle event listeners
mapViewBtn.addEventListener('click', () => switchView('map'));
tableViewBtn.addEventListener('click', () => switchView('table'));
window.addEventListener('beforeunload', () => {
if (updateInterval) {
clearInterval(updateInterval);
}
});
// Initialize theme toggle
initializeTheme();
});
// Theme toggle functionality
function initializeTheme() {
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.querySelector('.theme-icon');
if (!themeToggle || !themeIcon) {
console.warn('Theme toggle elements not found');
return;
}
// Check for saved theme preference or default to auto (follows system)
const savedTheme = localStorage.getItem('theme') || 'auto';
applyTheme(savedTheme);
// Update icon based on current theme
updateThemeIcon(savedTheme, themeIcon);
// Add click listener for cycling through themes
themeToggle.addEventListener('click', () => {
const currentTheme = localStorage.getItem('theme') || 'auto';
let newTheme;
// Cycle: auto → light → dark → auto
switch(currentTheme) {
case 'auto':
newTheme = 'light';
break;
case 'light':
newTheme = 'dark';
break;
case 'dark':
newTheme = 'auto';
break;
default:
newTheme = 'auto';
}
localStorage.setItem('theme', newTheme);
applyTheme(newTheme);
updateThemeIcon(newTheme, themeIcon);
console.log(`Theme switched to: ${newTheme}`);
});
// Listen for system theme changes when in auto mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const currentTheme = localStorage.getItem('theme') || 'auto';
if (currentTheme === 'auto') {
applyTheme('auto');
}
});
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
}
function updateThemeIcon(theme, iconElement) {
switch(theme) {
case 'auto':
iconElement.textContent = '🌍'; // Globe (auto)
break;
case 'light':
iconElement.textContent = '☀️'; // Sun (light)
break;
case 'dark':
iconElement.textContent = '🌙'; // Moon (dark)
break;
}
}