/* ── dashboard app JS ──────────────────────────────────────────────── */ document.addEventListener('DOMContentLoaded', () => { // Confirm delete buttons document.querySelectorAll('[data-confirm]').forEach(el => { el.addEventListener('click', (e) => { if (!confirm(el.dataset.confirm || 'Are you sure?')) { e.preventDefault(); } }); }); // ── person form: assemble ISO datetime on submit ────────────────── const form = document.getElementById('person-form'); if (form) { form.addEventListener('submit', (e) => { const date = document.getElementById('birth_date').value; const time = document.getElementById('birth_time').value; const tz = document.getElementById('tz').value; if (!date || !time || !tz) { e.preventDefault(); alert('Birth date, time, and timezone are required.'); return; } // Build ISO string with timezone offset const offset = getTzOffsetMinutes(tz, date, time); const sign = offset >= 0 ? '+' : '-'; const abs = Math.abs(offset); const oh = String(Math.floor(abs / 60)).padStart(2, '0'); const om = String(abs % 60).padStart(2, '0'); const iso = `${date}T${time}${sign}${oh}:${om}`; // Add a hidden field with the assembled datetime let hidden = document.getElementById('birth_datetime_assembled'); if (!hidden) { hidden = document.createElement('input'); hidden.type = 'hidden'; hidden.id = 'birth_datetime_assembled'; hidden.name = 'birth_datetime'; form.appendChild(hidden); } hidden.value = iso; }); } // ── UTC offset display ───────────────────────────────────────────── const tzSelect = document.getElementById('tz'); const dateInput = document.getElementById('birth_date'); const timeInput = document.getElementById('birth_time'); const offsetDisplay = document.getElementById('utc-offset-display'); function updateOffsetDisplay() { const tz = tzSelect.value; const date = dateInput.value; const time = timeInput.value; if (!tz || !date || !time) { offsetDisplay.textContent = ''; return; } const offset = getTzOffsetMinutes(tz, date, time); const sign = offset >= 0 ? '+' : '-'; const abs = Math.abs(offset); const oh = String(Math.floor(abs / 60)).padStart(2, '0'); const om = String(abs % 60).padStart(2, '0'); const tzAbbr = getTzAbbreviation(tz, date); offsetDisplay.textContent = `UTC${sign}${oh}:${om} (${tzAbbr})`; offsetDisplay.style.color = '#3fb950'; } if (tzSelect && dateInput && timeInput) { tzSelect.addEventListener('change', updateOffsetDisplay); dateInput.addEventListener('input', updateOffsetDisplay); timeInput.addEventListener('input', updateOffsetDisplay); // Initial display if values are pre-filled updateOffsetDisplay(); } // ── GeoNames autocomplete for birthplace ─────────────────────────── const bpInput = document.getElementById('birthplace'); const bpResults = document.getElementById('birthplace-results'); const bpStatus = document.getElementById('birthplace-status'); let debounceTimer = null; let activeIndex = -1; if (bpInput && bpResults) { bpInput.addEventListener('input', () => { clearTimeout(debounceTimer); const q = bpInput.value.trim(); if (q.length < 2) { bpResults.style.display = 'none'; bpStatus.textContent = ''; return; } bpStatus.textContent = 'Searching...'; debounceTimer = setTimeout(() => searchGeoNames(q), 300); }); bpInput.addEventListener('keydown', (e) => { const items = bpResults.querySelectorAll('.autocomplete-item'); if (!items.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, items.length - 1); updateActive(items); } else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); updateActive(items); } else if (e.key === 'Enter') { e.preventDefault(); if (activeIndex >= 0 && items[activeIndex]) { items[activeIndex].click(); } } else if (e.key === 'Escape') { bpResults.style.display = 'none'; activeIndex = -1; } }); // Close dropdown on outside click document.addEventListener('click', (e) => { if (!bpInput.contains(e.target) && !bpResults.contains(e.target)) { bpResults.style.display = 'none'; } }); } function updateActive(items) { items.forEach((item, i) => { item.classList.toggle('active', i === activeIndex); }); } async function searchGeoNames(q) { try { const resp = await fetch(`/api/geonames/search?q=${encodeURIComponent(q)}`); const results = await resp.json(); if (!results.length) { bpStatus.textContent = 'No results found.'; bpResults.style.display = 'none'; return; } bpStatus.textContent = ''; activeIndex = -1; bpResults.innerHTML = ''; results.forEach((r, i) => { const div = document.createElement('div'); div.className = 'autocomplete-item'; const parts = [r.name, r.admin1, r.country].filter(Boolean); const main = parts[0]; const sub = parts.slice(1).join(', '); const elev = r.elevation ? ` ${r.elevation}m` : ''; div.innerHTML = ` ${esc(main)} — ${esc(sub)}${esc(elev)} ${r.lat.toFixed(2)}, ${r.lng.toFixed(2)} `; div.addEventListener('click', () => selectPlace(r)); bpResults.appendChild(div); }); bpResults.style.display = 'block'; } catch (err) { bpStatus.textContent = 'Search failed.'; bpResults.style.display = 'none'; } } function selectPlace(r) { bpInput.value = [r.name, r.admin1, r.country].filter(Boolean).join(', '); document.getElementById('latitude').value = r.lat; document.getElementById('longitude').value = r.lng; if (r.elevation) { document.getElementById('elevation').value = r.elevation; } if (r.timezone) { const tzSel = document.getElementById('tz'); // Check if the timezone exists in our select let found = false; for (const opt of tzSel.options) { if (opt.value === r.timezone) { tzSel.value = r.timezone; found = true; break; } } if (!found) { // Add it as a new option const opt = document.createElement('option'); opt.value = r.timezone; opt.textContent = `${r.timezone} (auto-detected)`; tzSel.appendChild(opt); tzSel.value = r.timezone; } updateOffsetDisplay(); } bpResults.style.display = 'none'; bpStatus.textContent = `✓ ${r.timezone ? 'Timezone auto-filled' : 'Coordinates filled'}`; bpStatus.style.color = '#3fb950'; } // ── helpers ───────────────────────────────────────────────────────── function getTzOffsetMinutes(tz, date, time) { try { const dt = new Date(`${date}T${time}:00`); // Use Intl to get the offset for the specific timezone const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = formatter.formatToParts(dt); const p = {}; parts.forEach(part => { p[part.type] = part.value; }); const local = new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.second}:00`); return Math.round((local - dt) / 60000); } catch (e) { return 0; } } function getTzAbbreviation(tz, date) { try { const dt = new Date(`${date}T12:00:00`); const formatter = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'short', }); const parts = formatter.formatToParts(dt); const tzPart = parts.find(p => p.type === 'timeZoneName'); return tzPart ? tzPart.value : tz; } catch (e) { return tz; } } function esc(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; } });