/* ── 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;
}
});