app.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. /* ── dashboard app JS ──────────────────────────────────────────────── */
  2. document.addEventListener('DOMContentLoaded', () => {
  3. // Confirm delete buttons
  4. document.querySelectorAll('[data-confirm]').forEach(el => {
  5. el.addEventListener('click', (e) => {
  6. if (!confirm(el.dataset.confirm || 'Are you sure?')) {
  7. e.preventDefault();
  8. }
  9. });
  10. });
  11. // ── person form: assemble ISO datetime on submit ──────────────────
  12. const form = document.getElementById('person-form');
  13. if (form) {
  14. form.addEventListener('submit', (e) => {
  15. const date = document.getElementById('birth_date').value;
  16. const time = document.getElementById('birth_time').value;
  17. const tz = document.getElementById('tz').value;
  18. if (!date || !time || !tz) {
  19. e.preventDefault();
  20. alert('Birth date, time, and timezone are required.');
  21. return;
  22. }
  23. // Build ISO string with timezone offset
  24. const offset = getTzOffsetMinutes(tz, date, time);
  25. const sign = offset >= 0 ? '+' : '-';
  26. const abs = Math.abs(offset);
  27. const oh = String(Math.floor(abs / 60)).padStart(2, '0');
  28. const om = String(abs % 60).padStart(2, '0');
  29. const iso = `${date}T${time}${sign}${oh}:${om}`;
  30. // Add a hidden field with the assembled datetime
  31. let hidden = document.getElementById('birth_datetime_assembled');
  32. if (!hidden) {
  33. hidden = document.createElement('input');
  34. hidden.type = 'hidden';
  35. hidden.id = 'birth_datetime_assembled';
  36. hidden.name = 'birth_datetime';
  37. form.appendChild(hidden);
  38. }
  39. hidden.value = iso;
  40. });
  41. }
  42. // ── UTC offset display ─────────────────────────────────────────────
  43. const tzSelect = document.getElementById('tz');
  44. const dateInput = document.getElementById('birth_date');
  45. const timeInput = document.getElementById('birth_time');
  46. const offsetDisplay = document.getElementById('utc-offset-display');
  47. function updateOffsetDisplay() {
  48. const tz = tzSelect.value;
  49. const date = dateInput.value;
  50. const time = timeInput.value;
  51. if (!tz || !date || !time) {
  52. offsetDisplay.textContent = '';
  53. return;
  54. }
  55. const offset = getTzOffsetMinutes(tz, date, time);
  56. const sign = offset >= 0 ? '+' : '-';
  57. const abs = Math.abs(offset);
  58. const oh = String(Math.floor(abs / 60)).padStart(2, '0');
  59. const om = String(abs % 60).padStart(2, '0');
  60. const tzAbbr = getTzAbbreviation(tz, date);
  61. offsetDisplay.textContent = `UTC${sign}${oh}:${om} (${tzAbbr})`;
  62. offsetDisplay.style.color = '#3fb950';
  63. }
  64. if (tzSelect && dateInput && timeInput) {
  65. tzSelect.addEventListener('change', updateOffsetDisplay);
  66. dateInput.addEventListener('input', updateOffsetDisplay);
  67. timeInput.addEventListener('input', updateOffsetDisplay);
  68. // Initial display if values are pre-filled
  69. updateOffsetDisplay();
  70. }
  71. // ── GeoNames autocomplete for birthplace ───────────────────────────
  72. const bpInput = document.getElementById('birthplace');
  73. const bpResults = document.getElementById('birthplace-results');
  74. const bpStatus = document.getElementById('birthplace-status');
  75. let debounceTimer = null;
  76. let activeIndex = -1;
  77. if (bpInput && bpResults) {
  78. bpInput.addEventListener('input', () => {
  79. clearTimeout(debounceTimer);
  80. const q = bpInput.value.trim();
  81. if (q.length < 2) {
  82. bpResults.style.display = 'none';
  83. bpStatus.textContent = '';
  84. return;
  85. }
  86. bpStatus.textContent = 'Searching...';
  87. debounceTimer = setTimeout(() => searchGeoNames(q), 300);
  88. });
  89. bpInput.addEventListener('keydown', (e) => {
  90. const items = bpResults.querySelectorAll('.autocomplete-item');
  91. if (!items.length) return;
  92. if (e.key === 'ArrowDown') {
  93. e.preventDefault();
  94. activeIndex = Math.min(activeIndex + 1, items.length - 1);
  95. updateActive(items);
  96. } else if (e.key === 'ArrowUp') {
  97. e.preventDefault();
  98. activeIndex = Math.max(activeIndex - 1, 0);
  99. updateActive(items);
  100. } else if (e.key === 'Enter') {
  101. e.preventDefault();
  102. if (activeIndex >= 0 && items[activeIndex]) {
  103. items[activeIndex].click();
  104. }
  105. } else if (e.key === 'Escape') {
  106. bpResults.style.display = 'none';
  107. activeIndex = -1;
  108. }
  109. });
  110. // Close dropdown on outside click
  111. document.addEventListener('click', (e) => {
  112. if (!bpInput.contains(e.target) && !bpResults.contains(e.target)) {
  113. bpResults.style.display = 'none';
  114. }
  115. });
  116. }
  117. function updateActive(items) {
  118. items.forEach((item, i) => {
  119. item.classList.toggle('active', i === activeIndex);
  120. });
  121. }
  122. async function searchGeoNames(q) {
  123. try {
  124. const resp = await fetch(`/api/geonames/search?q=${encodeURIComponent(q)}`);
  125. const results = await resp.json();
  126. if (!results.length) {
  127. bpStatus.textContent = 'No results found.';
  128. bpResults.style.display = 'none';
  129. return;
  130. }
  131. bpStatus.textContent = '';
  132. activeIndex = -1;
  133. bpResults.innerHTML = '';
  134. results.forEach((r, i) => {
  135. const div = document.createElement('div');
  136. div.className = 'autocomplete-item';
  137. const parts = [r.name, r.admin1, r.country].filter(Boolean);
  138. const main = parts[0];
  139. const sub = parts.slice(1).join(', ');
  140. const elev = r.elevation ? ` ${r.elevation}m` : '';
  141. div.innerHTML = `
  142. <span class="ac-main">${esc(main)}</span>
  143. <span class="ac-sub"> — ${esc(sub)}${esc(elev)}</span>
  144. <span class="ac-coords">${r.lat.toFixed(2)}, ${r.lng.toFixed(2)}</span>
  145. `;
  146. div.addEventListener('click', () => selectPlace(r));
  147. bpResults.appendChild(div);
  148. });
  149. bpResults.style.display = 'block';
  150. } catch (err) {
  151. bpStatus.textContent = 'Search failed.';
  152. bpResults.style.display = 'none';
  153. }
  154. }
  155. function selectPlace(r) {
  156. bpInput.value = [r.name, r.admin1, r.country].filter(Boolean).join(', ');
  157. document.getElementById('latitude').value = r.lat;
  158. document.getElementById('longitude').value = r.lng;
  159. if (r.elevation) {
  160. document.getElementById('elevation').value = r.elevation;
  161. }
  162. if (r.timezone) {
  163. const tzSel = document.getElementById('tz');
  164. // Check if the timezone exists in our select
  165. let found = false;
  166. for (const opt of tzSel.options) {
  167. if (opt.value === r.timezone) {
  168. tzSel.value = r.timezone;
  169. found = true;
  170. break;
  171. }
  172. }
  173. if (!found) {
  174. // Add it as a new option
  175. const opt = document.createElement('option');
  176. opt.value = r.timezone;
  177. opt.textContent = `${r.timezone} (auto-detected)`;
  178. tzSel.appendChild(opt);
  179. tzSel.value = r.timezone;
  180. }
  181. updateOffsetDisplay();
  182. }
  183. bpResults.style.display = 'none';
  184. bpStatus.textContent = `✓ ${r.timezone ? 'Timezone auto-filled' : 'Coordinates filled'}`;
  185. bpStatus.style.color = '#3fb950';
  186. }
  187. // ── helpers ─────────────────────────────────────────────────────────
  188. function getTzOffsetMinutes(tz, date, time) {
  189. try {
  190. const dt = new Date(`${date}T${time}:00`);
  191. // Use Intl to get the offset for the specific timezone
  192. const formatter = new Intl.DateTimeFormat('en-US', {
  193. timeZone: tz,
  194. year: 'numeric', month: '2-digit', day: '2-digit',
  195. hour: '2-digit', minute: '2-digit', second: '2-digit',
  196. hour12: false,
  197. });
  198. const parts = formatter.formatToParts(dt);
  199. const p = {};
  200. parts.forEach(part => { p[part.type] = part.value; });
  201. const local = new Date(`${p.year}-${p.month}-${p.day}T${p.hour}:${p.second}:00`);
  202. return Math.round((local - dt) / 60000);
  203. } catch (e) {
  204. return 0;
  205. }
  206. }
  207. function getTzAbbreviation(tz, date) {
  208. try {
  209. const dt = new Date(`${date}T12:00:00`);
  210. const formatter = new Intl.DateTimeFormat('en-US', {
  211. timeZone: tz,
  212. timeZoneName: 'short',
  213. });
  214. const parts = formatter.formatToParts(dt);
  215. const tzPart = parts.find(p => p.type === 'timeZoneName');
  216. return tzPart ? tzPart.value : tz;
  217. } catch (e) {
  218. return tz;
  219. }
  220. }
  221. function esc(str) {
  222. const d = document.createElement('div');
  223. d.textContent = str;
  224. return d.innerHTML;
  225. }
  226. });