MY SKY: dashboard applet + full-page natal chart — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- User model: sky_birth_dt/lat/lon/place/house_system/chart_data fields (lyric migration 0018)
- Applet seed: my-sky (6×6, dashboard context) via applets migration 0009
- dashboard/sky/ — full monoapplet page: natus form + D3 wheel, LS key scoped to dashboard:sky; saves to User model
- dashboard/sky/preview/ — PySwiss proxy (same logic as epic:natus_preview, no seat check)
- dashboard/sky/save/ — persists 6 sky fields to User via update_fields
- _applet-my-sky.html: tile with h2 link to /dashboard/sky/
- 2 FTs (applet→link→form, localStorage persistence) + 12 ITs — all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-16 03:03:19 -04:00
parent 127f4a092d
commit abf8be8861
9 changed files with 817 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def seed_my_sky_applet(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.get_or_create(
slug='my-sky',
defaults={
'name': 'My Sky',
'grid_cols': 6,
'grid_rows': 6,
'context': 'dashboard',
},
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0008_game_kit_applets'),
]
operations = [
migrations.RunPython(seed_my_sky_applet, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,152 @@
"""Integration tests for the My Sky dashboard views.
sky_view — GET /dashboard/sky/ → renders sky template
sky_preview — GET /dashboard/sky/preview/ → proxies to PySwiss (no DB write)
sky_save — POST /dashboard/sky/save/ → saves natal data to User model
"""
import json
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from apps.lyric.models import User
class SkyViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
def test_sky_view_renders_template(self):
response = self.client.get(reverse("sky"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/sky.html")
def test_sky_view_requires_login(self):
self.client.logout()
response = self.client.get(reverse("sky"))
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_sky_view_passes_preview_and_save_urls(self):
response = self.client.get(reverse("sky"))
self.assertContains(response, reverse("sky_preview"))
self.assertContains(response, reverse("sky_save"))
class SkyPreviewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
self.url = reverse("sky_preview")
def test_requires_login(self):
self.client.logout()
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_missing_params_returns_400(self):
response = self.client.get(self.url, {"date": "1990-06-15"})
self.assertEqual(response.status_code, 400)
def test_invalid_lat_returns_400(self):
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
self.assertEqual(response.status_code, 400)
@patch("apps.dashboard.views.http_requests")
def test_proxies_to_pyswiss_and_returns_chart(self, mock_requests):
chart_payload = {
"planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}},
"houses": {"cusps": [0]*12},
"elements": {"Fire": 1, "Earth": 0, "Air": 0, "Water": 0},
"house_system": "O",
}
tz_response = MagicMock()
tz_response.json.return_value = {"timezone": "Europe/London"}
tz_response.raise_for_status = MagicMock()
chart_response = MagicMock()
chart_response.json.return_value = chart_payload
chart_response.raise_for_status = MagicMock()
mock_requests.get.side_effect = [tz_response, chart_response]
response = self.client.get(self.url, {
"date": "1990-06-15", "time": "09:30",
"lat": "51.5074", "lon": "-0.1278",
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("planets", data)
# Earth→Stone rename applied
self.assertIn("Stone", data["elements"])
self.assertNotIn("Earth", data["elements"])
self.assertIn("timezone", data)
self.assertIn("distinctions", data)
class SkySaveTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
self.url = reverse("sky_save")
def _post(self, payload):
return self.client.post(
self.url,
data=json.dumps(payload),
content_type="application/json",
)
def test_requires_login(self):
self.client.logout()
response = self._post({})
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_get_not_allowed(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_saves_sky_fields_to_user(self):
chart = {"planets": {}, "houses": {}, "elements": {}}
payload = {
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5074,
"birth_lon": -0.1278,
"birth_place": "London, UK",
"house_system": "O",
"chart_data": chart,
}
response = self._post(payload)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(str(self.user.sky_birth_dt), "1990-06-15 08:30:00+00:00")
self.assertAlmostEqual(float(self.user.sky_birth_lat), 51.5074, places=3)
self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3)
self.assertEqual(self.user.sky_birth_place, "London, UK")
self.assertEqual(self.user.sky_house_system, "O")
self.assertEqual(self.user.sky_chart_data, chart)
def test_invalid_json_returns_400(self):
response = self.client.post(
self.url, data="not json", content_type="application/json"
)
self.assertEqual(response.status_code, 400)
def test_response_contains_saved_flag(self):
payload = {
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5,
"birth_lon": -0.1,
"birth_place": "",
"house_system": "O",
"chart_data": {},
}
data = self._post(payload).json()
self.assertTrue(data["saved"])

View File

@@ -14,4 +14,7 @@ urlpatterns = [
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
path('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'),
path('sky/preview/', views.sky_preview, name='sky_preview'),
path('sky/save/', views.sky_save, name='sky_save'),
]

View File

@@ -1,4 +1,9 @@
import json
import stripe
import zoneinfo
from datetime import datetime
import requests as http_requests
from django.conf import settings
from django.contrib import messages
@@ -236,3 +241,117 @@ def save_payment_method(request):
brand=pm.card.brand,
)
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
# ── My Sky (personal natal chart) ────────────────────────────────────────────
def _sky_natus_preview(request):
"""Shared preview logic — proxies to PySwiss, no DB writes."""
date_str = request.GET.get('date')
time_str = request.GET.get('time', '12:00')
tz_str = request.GET.get('tz', '').strip()
lat_str = request.GET.get('lat')
lon_str = request.GET.get('lon')
if not date_str or lat_str is None or lon_str is None:
return HttpResponse(status=400)
try:
lat = float(lat_str)
lon = float(lon_str)
except ValueError:
return HttpResponse(status=400)
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return HttpResponse(status=400)
if not tz_str:
try:
tz_resp = http_requests.get(
settings.PYSWISS_URL + '/api/tz/',
params={'lat': lat_str, 'lon': lon_str},
timeout=5,
)
tz_resp.raise_for_status()
tz_str = tz_resp.json().get('timezone') or 'UTC'
except Exception:
tz_str = 'UTC'
try:
tz = zoneinfo.ZoneInfo(tz_str)
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
return HttpResponse(status=400)
try:
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
local_dt = local_dt.replace(tzinfo=tz)
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
except ValueError:
return HttpResponse(status=400)
try:
resp = http_requests.get(
settings.PYSWISS_URL + '/api/chart/',
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
timeout=5,
)
resp.raise_for_status()
except Exception:
return HttpResponse(status=502)
from apps.epic.views import _compute_distinctions
data = resp.json()
if 'elements' in data and 'Earth' in data['elements']:
data['elements']['Stone'] = data['elements'].pop('Earth')
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
data['timezone'] = tz_str
return JsonResponse(data)
@login_required(login_url="/")
def sky_view(request):
return render(request, "apps/dashboard/sky.html", {
"preview_url": request.build_absolute_uri("/dashboard/sky/preview/"),
"save_url": request.build_absolute_uri("/dashboard/sky/save/"),
"saved_sky": request.user.sky_chart_data,
"saved_birth_dt": request.user.sky_birth_dt,
"saved_birth_place": request.user.sky_birth_place,
})
@login_required(login_url="/")
def sky_preview(request):
return _sky_natus_preview(request)
@login_required(login_url="/")
def sky_save(request):
if request.method != 'POST':
return HttpResponse(status=405)
try:
body = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponse(status=400)
user = request.user
birth_dt_str = body.get('birth_dt', '')
if birth_dt_str:
try:
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00'))
user.sky_birth_dt = naive if naive.tzinfo else naive.replace(
tzinfo=zoneinfo.ZoneInfo('UTC')
)
except ValueError:
user.sky_birth_dt = None
user.sky_birth_lat = body.get('birth_lat')
user.sky_birth_lon = body.get('birth_lon')
user.sky_birth_place = body.get('birth_place', '')
user.sky_house_system = body.get('house_system', 'O')
user.sky_chart_data = body.get('chart_data')
user.save(update_fields=[
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
'sky_birth_place', 'sky_house_system', 'sky_chart_data',
])
return JsonResponse({"saved": True})

View File

@@ -0,0 +1,40 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0017_ap_keypair_fields'),
]
operations = [
migrations.AddField(
model_name='user',
name='sky_birth_dt',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='sky_birth_lat',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=9, null=True),
),
migrations.AddField(
model_name='user',
name='sky_birth_lon',
field=models.DecimalField(blank=True, decimal_places=4, max_digits=9, null=True),
),
migrations.AddField(
model_name='user',
name='sky_birth_place',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='user',
name='sky_house_system',
field=models.CharField(blank=True, default='O', max_length=1),
),
migrations.AddField(
model_name='user',
name='sky_chart_data',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -47,6 +47,14 @@ class User(AbstractBaseUser):
ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="")
# Personal natal chart (My Sky) — independent of any game room/character
sky_birth_dt = models.DateTimeField(null=True, blank=True)
sky_birth_lat = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
sky_birth_lon = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
sky_birth_place = models.CharField(max_length=255, blank=True)
sky_house_system = models.CharField(max_length=1, blank=True, default="O")
sky_chart_data = models.JSONField(null=True, blank=True)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)

View File

@@ -0,0 +1,118 @@
"""Functional tests for the My Sky dashboard feature.
My Sky is a dashboard applet linking to /dashboard/sky/ — a full-page
natus (natal chart) interface where the user can save their personal sky
to their account (stored on the User model, independent of any game room).
"""
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.lyric.models import User
from .base import FunctionalTest
class MySkyAppletTest(FunctionalTest):
"""My Sky applet appears on the dashboard and links to the sky page."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
# ── T1 ───────────────────────────────────────────────────────────────────
def test_my_sky_applet_links_to_sky_page_with_form(self):
"""Applet is visible on dashboard; link leads to /dashboard/sky/ with
all natus form fields present."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.live_server_url)
# 1. Applet is on the dashboard
applet = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_sky")
)
# 2. Heading contains a link whose text is "My Sky"
link = applet.find_element(By.CSS_SELECTOR, "h2 a")
self.assertIn("MY SKY", link.text.upper())
# 3. Clicking the link navigates to /dashboard/sky/
link.click()
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/dashboard/sky/$")
)
# 4. All natus form fields are present
self.browser.find_element(By.ID, "id_nf_date")
self.browser.find_element(By.ID, "id_nf_time")
self.browser.find_element(By.ID, "id_nf_place")
self.browser.find_element(By.ID, "id_nf_lat")
self.browser.find_element(By.ID, "id_nf_lon")
self.browser.find_element(By.ID, "id_nf_tz")
self.browser.find_element(By.ID, "id_natus_confirm")
class MySkyLocalStorageTest(FunctionalTest):
"""My Sky form fields persist to localStorage across visits."""
def setUp(self):
super().setUp()
Applet.objects.get_or_create(
slug="my-sky",
defaults={"name": "My Sky", "grid_cols": 6, "grid_rows": 6, "context": "dashboard"},
)
self.gamer = User.objects.create(email="stargazer@test.io")
self.sky_url = self.live_server_url + "/dashboard/sky/"
def _fill_form(self):
"""Set date, lat, lon directly — bypasses Nominatim network call."""
self.browser.execute_script(
"document.getElementById('id_nf_date').value = '1990-06-15';"
"document.getElementById('id_nf_lat').value = '51.5074';"
"document.getElementById('id_nf_lon').value = '-0.1278';"
"document.getElementById('id_nf_place').value = 'London, UK';"
"document.getElementById('id_nf_tz').value = 'Europe/London';"
)
# Fire input events so the localStorage save listener triggers
self.browser.execute_script("""
['id_nf_date','id_nf_lat','id_nf_lon','id_nf_place','id_nf_tz'].forEach(id => {
document.getElementById(id)
.dispatchEvent(new Event('input', {bubbles: true}));
});
""")
def _field_values(self):
return self.browser.execute_script("""
return {
date: document.getElementById('id_nf_date').value,
lat: document.getElementById('id_nf_lat').value,
lon: document.getElementById('id_nf_lon').value,
place: document.getElementById('id_nf_place').value,
tz: document.getElementById('id_nf_tz').value,
};
""")
# ── T2 ───────────────────────────────────────────────────────────────────
def test_sky_form_fields_repopulated_after_page_refresh(self):
"""Form values survive a full page refresh via localStorage."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_nf_date"))
self._fill_form()
self.browser.refresh()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_nf_date"))
values = self._field_values()
self.assertEqual(values["date"], "1990-06-15")
self.assertEqual(values["lat"], "51.5074")
self.assertEqual(values["lon"], "-0.1278")
self.assertEqual(values["place"], "London, UK")
self.assertEqual(values["tz"], "Europe/London")

View File

@@ -0,0 +1,6 @@
<section
id="id_applet_my_sky"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<h2><a href="{% url 'sky' %}">My Sky</a></h2>
</section>

View File

@@ -0,0 +1,347 @@
{% extends "core/base.html" %}
{% load static %}
{% block title_text %}My Sky{% endblock title_text %}
{% block header_text %}<span>Dash</span>sky{% endblock header_text %}
{% block content %}
<div class="sky-page"
id="id_natus_overlay"
data-preview-url="{% url 'sky_preview' %}"
data-save-url="{% url 'sky_save' %}">
<div class="natus-modal-body sky-body">
{# ── Form column ─────────────────────────────────────────────────── #}
<div class="natus-form-col">
<div class="natus-form-main">
<form id="id_natus_form" autocomplete="off">
<div class="natus-field">
<label for="id_nf_date">Birth date</label>
<input id="id_nf_date" name="date" type="date" required>
</div>
<div class="natus-field">
<label for="id_nf_time">Birth time</label>
<input id="id_nf_time" name="time" type="time" value="12:00">
<small>Local time at birth place. Use 12:00 if unknown.</small>
</div>
<div class="natus-field natus-place-field">
<label for="id_nf_place">Birth place</label>
<div class="natus-place-wrap">
<input id="id_nf_place" name="place" type="text"
placeholder="Start typing a city…"
autocomplete="off">
<button type="button" id="id_nf_geolocate"
class="btn btn-secondary btn-sm"
title="Use device location">
<i class="fa-solid fa-location-crosshairs"></i>
</button>
</div>
<div id="id_nf_suggestions" class="natus-suggestions" hidden></div>
</div>
<div class="natus-field natus-coords">
<div>
<label>Latitude</label>
<input id="id_nf_lat" name="lat" type="text"
placeholder="—" readonly tabindex="-1">
</div>
<div>
<label>Longitude</label>
<input id="id_nf_lon" name="lon" type="text"
placeholder="—" readonly tabindex="-1">
</div>
</div>
<div class="natus-field">
<label for="id_nf_tz">Timezone</label>
<input id="id_nf_tz" name="tz" type="text"
placeholder="auto-detected from location">
<small id="id_nf_tz_hint"></small>
</div>
</form>
<div id="id_natus_status" class="natus-status"></div>
</div>{# /.natus-form-main #}
<button type="button" id="id_natus_confirm" class="btn btn-primary" disabled>
Save Sky
</button>
</div>
{# ── Wheel column ────────────────────────────────────────────────── #}
<div class="natus-wheel-col">
<svg id="id_natus_svg" class="natus-svg"></svg>
</div>
</div>{# /.natus-modal-body #}
</div>{# /.sky-page #}
{# Planet hover tooltip — position:fixed escapes any overflow:hidden ancestor #}
<div id="id_natus_tooltip" class="tt" style="display:none;"></div>
<script src="{% static 'apps/gameboard/d3.min.js' %}"></script>
<script src="{% static 'apps/gameboard/natus-wheel.js' %}"></script>
<script>
(function () {
'use strict';
const overlay = document.getElementById('id_natus_overlay');
const form = document.getElementById('id_natus_form');
const svgEl = document.getElementById('id_natus_svg');
const statusEl = document.getElementById('id_natus_status');
const confirmBtn = document.getElementById('id_natus_confirm');
const geoBtn = document.getElementById('id_nf_geolocate');
const placeInput = document.getElementById('id_nf_place');
const latInput = document.getElementById('id_nf_lat');
const lonInput = document.getElementById('id_nf_lon');
const tzInput = document.getElementById('id_nf_tz');
const tzHint = document.getElementById('id_nf_tz_hint');
const suggestions = document.getElementById('id_nf_suggestions');
const PREVIEW_URL = overlay.dataset.previewUrl;
const SAVE_URL = overlay.dataset.saveUrl;
const NOMINATIM = 'https://nominatim.openstreetmap.org/search';
const USER_AGENT = 'EarthmanRPG/1.0 (https://earthmanrpg.me)';
// localStorage key — fixed for the user's personal sky (not room-scoped)
const LS_KEY = 'natus-form:dashboard:sky';
let _lastChartData = null;
let _placeDebounce = null;
let _chartDebounce = null;
const PLACE_DELAY = 400;
const CHART_DELAY = 300;
NatusWheel.preload();
// ── localStorage persistence ────────────────────────────────────────────
function _saveForm() {
const data = {
date: document.getElementById('id_nf_date').value,
time: document.getElementById('id_nf_time').value,
place: placeInput.value,
lat: latInput.value,
lon: lonInput.value,
tz: tzInput.value,
};
try { localStorage.setItem(LS_KEY, JSON.stringify(data)); } catch (_) {}
}
function _restoreForm() {
let data;
try { data = JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch (_) {}
if (!data) return;
if (data.date) document.getElementById('id_nf_date').value = data.date;
if (data.time) document.getElementById('id_nf_time').value = data.time;
if (data.place) placeInput.value = data.place;
if (data.lat) latInput.value = data.lat;
if (data.lon) lonInput.value = data.lon;
if (data.tz) { tzInput.value = data.tz; tzHint.textContent = 'Auto-detected from coordinates.'; }
// If we have enough data from localStorage, kick off a wheel draw
if (_formReady()) schedulePreview();
}
// ── Status helper ───────────────────────────────────────────────────────
function setStatus(msg, type) {
statusEl.textContent = msg;
statusEl.className = 'natus-status' + (type ? ` natus-status--${type}` : '');
}
// ── Nominatim place search ──────────────────────────────────────────────
placeInput.addEventListener('input', () => {
clearTimeout(_placeDebounce);
const q = placeInput.value.trim();
if (q.length < 3) { hideSuggestions(); return; }
_placeDebounce = setTimeout(() => fetchPlaces(q), PLACE_DELAY);
});
placeInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideSuggestions();
});
document.addEventListener('click', (e) => {
if (!placeInput.contains(e.target) && !suggestions.contains(e.target)) {
hideSuggestions();
}
});
function fetchPlaces(query) {
fetch(`${NOMINATIM}?format=json&q=${encodeURIComponent(query)}&limit=6`, {
headers: { 'User-Agent': USER_AGENT },
})
.then(r => r.json())
.then(results => {
if (!results.length) { hideSuggestions(); return; }
renderSuggestions(results);
})
.catch(() => hideSuggestions());
}
function renderSuggestions(results) {
suggestions.innerHTML = '';
results.forEach(place => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'natus-suggestion-item';
item.textContent = place.display_name;
item.addEventListener('click', () => selectPlace(place));
suggestions.appendChild(item);
});
suggestions.hidden = false;
}
function hideSuggestions() {
suggestions.hidden = true;
suggestions.innerHTML = '';
}
function selectPlace(place) {
placeInput.value = place.display_name;
latInput.value = parseFloat(place.lat).toFixed(4);
lonInput.value = parseFloat(place.lon).toFixed(4);
hideSuggestions();
_saveForm();
schedulePreview();
}
// ── Geolocation ─────────────────────────────────────────────────────────
geoBtn.addEventListener('click', () => {
if (!navigator.geolocation) {
setStatus('Geolocation not supported by this browser.', 'error');
return;
}
setStatus('Requesting device location…');
navigator.geolocation.getCurrentPosition(
(pos) => {
latInput.value = pos.coords.latitude.toFixed(4);
lonInput.value = pos.coords.longitude.toFixed(4);
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latInput.value}&lon=${lonInput.value}`, {
headers: { 'User-Agent': USER_AGENT },
})
.then(r => r.json())
.then(data => { placeInput.value = _cityName(data.address) || data.display_name || ''; })
.catch(() => {})
.finally(() => _saveForm());
setStatus('');
schedulePreview();
},
() => setStatus('Location access denied.', 'error'),
);
});
function _cityName(addr) {
if (!addr) return '';
const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || '';
const region = addr.state || addr.county || addr.state_district || '';
const country = addr.country || '';
return [city, region, country].filter(Boolean).join(', ');
}
// ── Debounced chart preview ─────────────────────────────────────────────
form.addEventListener('input', (e) => {
if (e.target === placeInput) return;
_saveForm();
clearTimeout(_chartDebounce);
_chartDebounce = setTimeout(schedulePreview, CHART_DELAY);
});
function _formReady() {
return document.getElementById('id_nf_date').value &&
latInput.value && lonInput.value;
}
function schedulePreview() {
if (!_formReady()) return;
const date = document.getElementById('id_nf_date').value;
const time = document.getElementById('id_nf_time').value || '12:00';
const lat = latInput.value;
const lon = lonInput.value;
const tz = tzInput.value.trim();
const params = new URLSearchParams({ date, time, lat, lon });
if (tz) params.set('tz', tz);
setStatus('Calculating…');
confirmBtn.disabled = true;
fetch(`${PREVIEW_URL}?${params}`, { credentials: 'same-origin' })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
_lastChartData = data;
if (!tzInput.value && data.timezone) {
tzInput.value = data.timezone;
tzHint.textContent = 'Auto-detected from coordinates.';
}
setStatus('');
confirmBtn.disabled = false;
if (svgEl.querySelector('*')) {
NatusWheel.redraw(data);
} else {
NatusWheel.draw(svgEl, data);
}
})
.catch(err => {
setStatus(`Could not fetch chart: ${err.message}`, 'error');
confirmBtn.disabled = true;
});
}
// ── Save ────────────────────────────────────────────────────────────────
confirmBtn.addEventListener('click', () => {
if (!_lastChartData) return;
confirmBtn.disabled = true;
setStatus('Saving…');
const payload = {
birth_dt: `${document.getElementById('id_nf_date').value}T${document.getElementById('id_nf_time').value || '12:00'}:00`,
birth_lat: parseFloat(latInput.value),
birth_lon: parseFloat(lonInput.value),
birth_place: placeInput.value,
house_system: _lastChartData.house_system || 'O',
chart_data: _lastChartData,
};
fetch(SAVE_URL, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _getCsrf() },
body: JSON.stringify(payload),
})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(() => setStatus('Sky saved!'))
.catch(err => {
setStatus(`Save failed: ${err.message}`, 'error');
confirmBtn.disabled = false;
});
});
// ── CSRF ─────────────────────────────────────────────────────────────────
function _getCsrf() {
const m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
// ── Restore persisted form on load ───────────────────────────────────────
_restoreForm();
})();
</script>
{% endblock %}