Jelajahi Sumber

Enhance person form: date/time picker, GeoNames autocomplete, UTC offset display

- Split birth datetime into date + time + timezone select (50+ IANA zones grouped by region)
- GeoNames autocomplete API endpoint (/api/geonames/search) with debounced search
- Autocomplete dropdown with keyboard nav, auto-fills lat/lon/elevation/timezone
- Live UTC offset display (e.g. 'UTC+01:00 (CEST)') using Intl API
- Client-side ISO datetime assembly on form submit
- GEONAMES_USERNAME config in .env (graceful fallback if not set)
- All 102 tests pass
Lukas Goldschmidt 1 bulan lalu
induk
melakukan
20a251bbe7
5 mengubah file dengan 398 tambahan dan 21 penghapusan
  1. 2 0
      src/astro_mcp/config.py
  2. 44 1
      src/astro_mcp/dashboard.py
  3. 229 3
      static/app.js
  4. 34 0
      static/style.css
  5. 89 17
      templates/person-form.html

+ 2 - 0
src/astro_mcp/config.py

@@ -20,3 +20,5 @@ DB_PATH = Path(os.getenv("ASTRO_DB_PATH", str(DATA_DIR / "astro.sqlite3")))
 EPHEMERIS_MCP_URL = os.getenv(
     "EPHEMERIS_MCP_URL", "http://192.168.0.200:7015/mcp/sse"
 )
+
+GEONAMES_USERNAME = os.getenv("GEONAMES_USERNAME", "")

+ 44 - 1
src/astro_mcp/dashboard.py

@@ -14,6 +14,7 @@ from fastapi import APIRouter, Form, Request
 from fastapi.responses import HTMLResponse, RedirectResponse
 
 from . import storage
+from .config import GEONAMES_USERNAME
 from .server import templates
 
 logger = logging.getLogger("astro-mcp.dashboard")
@@ -43,7 +44,49 @@ async def dashboard_home(request: Request):
     )
 
 
-# ── person list ──────────────────────────────────────────────────────
+# ── geonames autocomplete ─────────────────────────────────────────────
+
+@router.get("/api/geonames/search")
+async def geonames_search(q: str, max_rows: int = 8):
+    """Proxy to GeoNames search API for birthplace autocomplete."""
+    if not GEONAMES_USERNAME:
+        return []
+    if len(q) < 2:
+        return []
+
+    import httpx
+    try:
+        url = "http://api.geonames.org/searchJSON"
+        params = {
+            "q": q,
+            "maxRows": max_rows,
+            "username": GEONAMES_USERNAME,
+            "style": "FULL",
+            "order_by": "population",
+            "is_name_required": "true",
+        }
+        async with httpx.AsyncClient(timeout=5.0) as client:
+            resp = await client.get(url, params=params)
+            data = resp.json()
+        results = []
+        for item in data.get("geonames", []):
+            results.append({
+                "name": item.get("name", ""),
+                "admin1": item.get("adminName1", ""),
+                "admin2": item.get("adminName2", ""),
+                "country": item.get("countryName", ""),
+                "country_code": item.get("countryCode", ""),
+                "lat": float(item.get("lat", 0)),
+                "lng": float(item.get("lng", 0)),
+                "elevation": item.get("elevation"),
+                "timezone": item.get("timezone", {}).get("timeZoneId", ""),
+                "population": item.get("population", 0),
+                "score": item.get("score", 0),
+            })
+        return results
+    except Exception as e:
+        logger.warning(f"geonames search failed: {e}")
+        return []
 
 @router.get("/dashboard/persons", response_class=HTMLResponse)
 async def person_list(request: Request):

+ 229 - 3
static/app.js

@@ -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;
+  }
 });

+ 34 - 0
static/style.css

@@ -159,3 +159,37 @@ tr:hover td { background: #1c2128; }
   .toolbar { flex-direction: column; align-items: stretch; }
   .toolbar .search-input { max-width: none; }
 }
+
+/* ── autocomplete ──────────────────────────────────────────────────── */
+.autocomplete-dropdown {
+  position: absolute;
+  z-index: 100;
+  background: #1c2128;
+  border: 1px solid #30363d;
+  border-radius: 6px;
+  max-height: 280px;
+  overflow-y: auto;
+  box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+}
+.autocomplete-item {
+  padding: 0.55rem 0.75rem;
+  cursor: pointer;
+  font-size: 0.85rem;
+  border-bottom: 1px solid #21262d;
+}
+.autocomplete-item:last-child { border-bottom: none; }
+.autocomplete-item:hover,
+.autocomplete-item.active {
+  background: #1f6feb;
+  color: #fff;
+}
+.autocomplete-item .ac-main { font-weight: 500; }
+.autocomplete-item .ac-sub { font-size: 0.75rem; color: #8b949e; }
+.autocomplete-item:hover .ac-sub,
+.autocomplete-item.active .ac-sub { color: #c9d1d9; }
+.autocomplete-item .ac-coords { font-size: 0.7rem; color: #6e7681; float: right; }
+.autocomplete-item:hover .ac-coords,
+.autocomplete-item.active .ac-coords { color: #8b949e; }
+
+/* form-group needs relative positioning for the dropdown */
+.form-group { position: relative; }

+ 89 - 17
templates/person-form.html

@@ -11,7 +11,7 @@
   {% endif %}
 </div>
 
-<form method="post"
+<form method="post" id="person-form"
   {% if action == 'add' %}
   action="/dashboard/persons"
   {% else %}
@@ -70,19 +70,98 @@
     <h2>Birth Data *</h2>
     <div class="form-row">
       <div class="form-group">
-        <label for="birth_datetime">Birth Datetime *</label>
-        <input type="text" id="birth_datetime" name="birth_datetime" required
-          value="{{ person.birth_datetime if person else '' }}"
-          placeholder="2000-01-01T12:00:00+01:00">
-        <div class="form-hint">ISO 8601 with timezone, e.g. 2000-01-01T12:00:00+01:00</div>
+        <label for="birth_date">Birth Date *</label>
+        <input type="date" id="birth_date" required
+          value="{{ person.birth_datetime[:10] if person and person.birth_datetime else '' }}">
       </div>
       <div class="form-group">
-        <label for="birthplace">Birthplace</label>
-        <input type="text" id="birthplace" name="birthplace"
-          value="{{ person.birthplace if person else '' }}"
-          placeholder="e.g. Graz, Austria">
+        <label for="birth_time">Birth Time *</label>
+        <input type="time" id="birth_time" required step="1"
+          value="{{ person.birth_datetime[11:16] if person and person.birth_datetime else '' }}">
+        <div class="form-hint">HH:MM (seconds optional)</div>
+      </div>
+      <div class="form-group">
+        <label for="tz">Timezone *</label>
+        <select id="tz" name="tz" required>
+          <option value="">— select —</option>
+          <optgroup label="Europe">
+            <option value="Europe/Vienna" {% if person and person.timezone == 'Europe/Vienna' %}selected{% endif %}>Europe/Vienna (CET/CEST)</option>
+            <option value="Europe/Berlin" {% if person and person.timezone == 'Europe/Berlin' %}selected{% endif %}>Europe/Berlin (CET/CEST)</option>
+            <option value="Europe/Zurich" {% if person and person.timezone == 'Europe/Zurich' %}selected{% endif %}>Europe/Zurich (CET/CEST)</option>
+            <option value="Europe/London" {% if person and person.timezone == 'Europe/London' %}selected{% endif %}>Europe/London (GMT/BST)</option>
+            <option value="Europe/Paris" {% if person and person.timezone == 'Europe/Paris' %}selected{% endif %}>Europe/Paris (CET/CEST)</option>
+            <option value="Europe/Amsterdam" {% if person and person.timezone == 'Europe/Amsterdam' %}selected{% endif %}>Europe/Amsterdam (CET/CEST)</option>
+            <option value="Europe/Brussels" {% if person and person.timezone == 'Europe/Brussels' %}selected{% endif %}>Europe/Brussels (CET/CEST)</option>
+            <option value="Europe/Madrid" {% if person and person.timezone == 'Europe/Madrid' %}selected{% endif %}>Europe/Madrid (CET/CEST)</option>
+            <option value="Europe/Rome" {% if person and person.timezone == 'Europe/Rome' %}selected{% endif %}>Europe/Rome (CET/CEST)</option>
+            <option value="Europe/Stockholm" {% if person and person.timezone == 'Europe/Stockholm' %}selected{% endif %}>Europe/Stockholm (CET/CEST)</option>
+            <option value="Europe/Oslo" {% if person and person.timezone == 'Europe/Oslo' %}selected{% endif %}>Europe/Oslo (CET/CEST)</option>
+            <option value="Europe/Copenhagen" {% if person and person.timezone == 'Europe/Copenhagen' %}selected{% endif %}>Europe/Copenhagen (CET/CEST)</option>
+            <option value="Europe/Warsaw" {% if person and person.timezone == 'Europe/Warsaw' %}selected{% endif %}>Europe/Warsaw (CET/CEST)</option>
+            <option value="Europe/Prague" {% if person and person.timezone == 'Europe/Prague' %}selected{% endif %}>Europe/Prague (CET/CEST)</option>
+            <option value="Europe/Budapest" {% if person and person.timezone == 'Europe/Budapest' %}selected{% endif %}>Europe/Budapest (CET/CEST)</option>
+            <option value="Europe/Athens" {% if person and person.timezone == 'Europe/Athens' %}selected{% endif %}>Europe/Athens (EET/EEST)</option>
+            <option value="Europe/Helsinki" {% if person and person.timezone == 'Europe/Helsinki' %}selected{% endif %}>Europe/Helsinki (EET/EEST)</option>
+            <option value="Europe/Moscow" {% if person and person.timezone == 'Europe/Moscow' %}selected{% endif %}>Europe/Moscow (MSK)</option>
+            <option value="Europe/Istanbul" {% if person and person.timezone == 'Europe/Istanbul' %}selected{% endif %}>Europe/Istanbul (TRT)</option>
+            <option value="Europe/Lisbon" {% if person and person.timezone == 'Europe/Lisbon' %}selected{% endif %}>Europe/Lisbon (WET/WEST)</option>
+            <option value="Europe/Dublin" {% if person and person.timezone == 'Europe/Dublin' %}selected{% endif %}>Europe/Dublin (GMT/IST)</option>
+            <option value="Europe/Zagreb" {% if person and person.timezone == 'Europe/Zagreb' %}selected{% endif %}>Europe/Zagreb (CET/CEST)</option>
+            <option value="Europe/Bucharest" {% if person and person.timezone == 'Europe/Bucharest' %}selected{% endif %}>Europe/Bucharest (EET/EEST)</option>
+            <option value="Europe/Sofia" {% if person and person.timezone == 'Europe/Sofia' %}selected{% endif %}>Europe/Sofia (EET/EEST)</option>
+          </optgroup>
+          <optgroup label="Americas">
+            <option value="America/New_York" {% if person and person.timezone == 'America/New_York' %}selected{% endif %}>America/New_York (ET)</option>
+            <option value="America/Chicago" {% if person and person.timezone == 'America/Chicago' %}selected{% endif %}>America/Chicago (CT)</option>
+            <option value="America/Denver" {% if person and person.timezone == 'America/Denver' %}selected{% endif %}>America/Denver (MT)</option>
+            <option value="America/Los_Angeles" {% if person and person.timezone == 'America/Los_Angeles' %}selected{% endif %}>America/Los_Angeles (PT)</option>
+            <option value="America/Toronto" {% if person and person.timezone == 'America/Toronto' %}selected{% endif %}>America/Toronto (ET)</option>
+            <option value="America/Vancouver" {% if person and person.timezone == 'America/Vancouver' %}selected{% endif %}>America/Vancouver (PT)</option>
+            <option value="America/Mexico_City" {% if person and person.timezone == 'America/Mexico_City' %}selected{% endif %}>America/Mexico_City (CT)</option>
+            <option value="America/Sao_Paulo" {% if person and person.timezone == 'America/Sao_Paulo' %}selected{% endif %}>America/Sao_Paulo (BRT)</option>
+            <option value="America/Buenos_Aires" {% if person and person.timezone == 'America/Buenos_Aires' %}selected{% endif %}>America/Buenos_Aires (ART)</option>
+            <option value="America/Bogota" {% if person and person.timezone == 'America/Bogota' %}selected{% endif %}>America/Bogota (COT)</option>
+          </optgroup>
+          <optgroup label="Asia & Oceania">
+            <option value="Asia/Tokyo" {% if person and person.timezone == 'Asia/Tokyo' %}selected{% endif %}>Asia/Tokyo (JST)</option>
+            <option value="Asia/Shanghai" {% if person and person.timezone == 'Asia/Shanghai' %}selected{% endif %}>Asia/Shanghai (CST)</option>
+            <option value="Asia/Hong_Kong" {% if person and person.timezone == 'Asia/Hong_Kong' %}selected{% endif %}>Asia/Hong_Kong (HKT)</option>
+            <option value="Asia/Singapore" {% if person and person.timezone == 'Asia/Singapore' %}selected{% endif %}>Asia/Singapore (SGT)</option>
+            <option value="Asia/Kolkata" {% if person and person.timezone == 'Asia/Kolkata' %}selected{% endif %}>Asia/Kolkata (IST)</option>
+            <option value="Asia/Dubai" {% if person and person.timezone == 'Asia/Dubai' %}selected{% endif %}>Asia/Dubai (GST)</option>
+            <option value="Asia/Bangkok" {% if person and person.timezone == 'Asia/Bangkok' %}selected{% endif %}>Asia/Bangkok (ICT)</option>
+            <option value="Asia/Seoul" {% if person and person.timezone == 'Asia/Seoul' %}selected{% endif %}>Asia/Seoul (KST)</option>
+            <option value="Australia/Sydney" {% if person and person.timezone == 'Australia/Sydney' %}selected{% endif %}>Australia/Sydney (AEST/AEDT)</option>
+            <option value="Australia/Melbourne" {% if person and person.timezone == 'Australia/Melbourne' %}selected{% endif %}>Australia/Melbourne (AEST/AEDT)</option>
+            <option value="Australia/Perth" {% if person and person.timezone == 'Australia/Perth' %}selected{% endif %}>Australia/Perth (AWST)</option>
+            <option value="Pacific/Auckland" {% if person and person.timezone == 'Pacific/Auckland' %}selected{% endif %}>Pacific/Auckland (NZST/NZDT)</option>
+          </optgroup>
+          <optgroup label="Africa & Middle East">
+            <option value="Africa/Cairo" {% if person and person.timezone == 'Africa/Cairo' %}selected{% endif %}>Africa/Cairo (EET)</option>
+            <option value="Africa/Johannesburg" {% if person and person.timezone == 'Africa/Johannesburg' %}selected{% endif %}>Africa/Johannesburg (SAST)</option>
+            <option value="Africa/Lagos" {% if person and person.timezone == 'Africa/Lagos' %}selected{% endif %}>Africa/Lagos (WAT)</option>
+            <option value="Asia/Jerusalem" {% if person and person.timezone == 'Asia/Jerusalem' %}selected{% endif %}>Asia/Jerusalem (IST/IDT)</option>
+            <option value="Asia/Riyadh" {% if person and person.timezone == 'Asia/Riyadh' %}selected{% endif %}>Asia/Riyadh (AST)</option>
+            <option value="Asia/Tehran" {% if person and person.timezone == 'Asia/Tehran' %}selected{% endif %}>Asia/Tehran (IRST/IRDT)</option>
+          </optgroup>
+          <optgroup label="UTC">
+            <option value="UTC" {% if person and person.timezone == 'UTC' %}selected{% endif %}>UTC</option>
+          </optgroup>
+        </select>
       </div>
     </div>
+    <div id="utc-offset-display" class="form-hint" style="margin-top:-0.5rem;margin-bottom:0.5rem"></div>
+
+    <div class="form-group">
+      <label for="birthplace">Birthplace *</label>
+      <input type="text" id="birthplace" name="birthplace" required
+        value="{{ person.birthplace if person else '' }}"
+        placeholder="Start typing a city name..."
+        autocomplete="off">
+      <div id="birthplace-results" class="autocomplete-dropdown" style="display:none"></div>
+      <div id="birthplace-status" class="form-hint"></div>
+    </div>
+
     <div class="form-row">
       <div class="form-group">
         <label for="latitude">Latitude *</label>
@@ -103,13 +182,6 @@
       </div>
     </div>
     <div class="form-row">
-      <div class="form-group">
-        <label for="tz">Timezone</label>
-        <input type="text" id="tz" name="tz"
-          value="{{ person.timezone if person else '' }}"
-          placeholder="e.g. Europe/Vienna">
-        <div class="form-hint">IANA timezone name</div>
-      </div>
       <div class="form-group">
         <label for="birth_time_known">Birth Time Known?</label>
         <select id="birth_time_known" name="birth_time_known">