|
@@ -10,7 +10,233 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // Auto-focus first input on forms
|
|
|
|
|
- const firstInput = document.querySelector('.form-group input:not([type=hidden])');
|
|
|
|
|
- if (firstInput) firstInput.focus();
|
|
|
|
|
|
|
+ // ── 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 = `
|
|
|
|
|
+ <span class="ac-main">${esc(main)}</span>
|
|
|
|
|
+ <span class="ac-sub"> — ${esc(sub)}${esc(elev)}</span>
|
|
|
|
|
+ <span class="ac-coords">${r.lat.toFixed(2)}, ${r.lng.toFixed(2)}</span>
|
|
|
|
|
+ `;
|
|
|
|
|
+ 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;
|
|
|
|
|
+ }
|
|
|
});
|
|
});
|