diff --git a/src/apps/applets/migrations/0009_my_sky_applet.py b/src/apps/applets/migrations/0009_my_sky_applet.py
new file mode 100644
index 0000000..07ccd1f
--- /dev/null
+++ b/src/apps/applets/migrations/0009_my_sky_applet.py
@@ -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)
+ ]
diff --git a/src/apps/dashboard/tests/integrated/test_sky_views.py b/src/apps/dashboard/tests/integrated/test_sky_views.py
new file mode 100644
index 0000000..d907a86
--- /dev/null
+++ b/src/apps/dashboard/tests/integrated/test_sky_views.py
@@ -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"])
diff --git a/src/apps/dashboard/urls.py b/src/apps/dashboard/urls.py
index a9d43b8..d58351d 100644
--- a/src/apps/dashboard/urls.py
+++ b/src/apps/dashboard/urls.py
@@ -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'),
]
diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py
index b449f9e..08ea0e0 100644
--- a/src/apps/dashboard/views.py
+++ b/src/apps/dashboard/views.py
@@ -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})
diff --git a/src/apps/lyric/migrations/0018_user_sky_fields.py b/src/apps/lyric/migrations/0018_user_sky_fields.py
new file mode 100644
index 0000000..7468a17
--- /dev/null
+++ b/src/apps/lyric/migrations/0018_user_sky_fields.py
@@ -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),
+ ),
+ ]
diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py
index fe97d7b..5e2b752 100644
--- a/src/apps/lyric/models.py
+++ b/src/apps/lyric/models.py
@@ -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)
diff --git a/src/functional_tests/test_my_sky.py b/src/functional_tests/test_my_sky.py
new file mode 100644
index 0000000..00cced9
--- /dev/null
+++ b/src/functional_tests/test_my_sky.py
@@ -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")
diff --git a/src/templates/apps/dashboard/_partials/_applet-my-sky.html b/src/templates/apps/dashboard/_partials/_applet-my-sky.html
new file mode 100644
index 0000000..4263480
--- /dev/null
+++ b/src/templates/apps/dashboard/_partials/_applet-my-sky.html
@@ -0,0 +1,6 @@
+My Sky
+