app.js 9.3 KB

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