sky.html: DEL btn at wheel center; async SAVE SKY transitions into saved state without reload; pre-save hides wheel-col so form+SAVE SKY stay centered — TDD

DEL btn (.btn-danger, "Forget sky?" data-confirm wired to the global #id_guard_portal) sits absolutely centered inside .sky-wheel-col; OK submits a POST to the new sky_delete view, which clears every sky_* field on the User model & redirects back to /dashboard/sky/.

The sky.html aperture is now uniform across saved/unsaved: form-col is always flex-column align-center justify-center so the fields + SAVE SKY pair sits visually centered. body.sky-saved adds *only* the snap-binary scroll layer (scroll-snap-type:y, modal-body display:contents, cols min-height:100% scroll-snap-align:start, wheel-col aspect-ratio cap released, form-col flex:0 0 auto so the snap basis wins) — the column-stacking is no longer gated.

Async save: SAVE SKY's success branch now calls _activateSavedState(), which adds body.sky-saved, draws the wheel from _lastChartData, pins overlay.scrollTop to the form section's offsetTop, then runs the existing _scrollApertureToTop ease-out so the wheel reveals from above instead of replacing the form with a hard cut. The wheel preview that previously redrew during typing is now gated on _savedSky — pre-first-save typing fetches the chart data (so SAVE SKY enables) but does not render the wheel, mirroring the My Sky applet's "no wheel until saved" UX. The in-room PICK SKY overlay (_sky_overlay.html) still previews live, deliberately untouched.

Pre-save the wheel-col is hidden via `body:not(.sky-saved) .sky-page .sky-wheel-col { display: none }`, so the empty SVG can't shunt the form below the fold (& the DEL btn rides the same selector since it lives inside .sky-wheel-col).

Tests: SkyDeleteTest IT class (5: clears fields, redirects, 405 on GET, login required, preserves unrelated user fields). MySkyDeleteFlowTest FT class (3: DEL btn visibility gated on sky data, NVM dismisses w. data intact, OK clears + reverts body class). MySkyAsyncSaveTest FT (1: fresh user → SAVE SKY → body picks up sky-saved, wheel SVG populates, DEL btn becomes visible — all without a page reload). All 13 sky FTs + sky ITs green; existing MySkyApertureSnapScrollTest & MySkyTimezoneRefreshTest still pass.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-08 13:07:56 -04:00
parent bbd1b22bb0
commit 9ff437012a
6 changed files with 346 additions and 34 deletions

View File

@@ -288,3 +288,59 @@ class SkySaveNoteTest(TestCase):
data = self._post(chart_data=None).json() data = self._post(chart_data=None).json()
self.assertIsNone(data["note"]) self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
class SkyDeleteTest(TestCase):
"""POST /dashboard/sky/delete clears all sky fields on the User model and
redirects back to /dashboard/sky/. The Stargazer Note is preserved (it's
earned, not stateful)."""
def setUp(self):
from datetime import datetime
import zoneinfo
self.user = User.objects.create(email="star@test.io")
self.user.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
self.user.sky_birth_place = "Baltimore, MD, US"
self.user.sky_birth_tz = "America/New_York"
self.user.sky_birth_lat = 39.2904
self.user.sky_birth_lon = -76.6122
self.user.sky_birth_dt = datetime(
1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC")
)
self.user.sky_house_system = "O"
self.user.save()
self.client.force_login(self.user)
self.url = reverse("sky_delete")
def test_post_clears_all_sky_fields(self):
self.client.post(self.url)
self.user.refresh_from_db()
self.assertIsNone(self.user.sky_chart_data)
self.assertIsNone(self.user.sky_birth_dt)
self.assertIsNone(self.user.sky_birth_lat)
self.assertIsNone(self.user.sky_birth_lon)
self.assertEqual(self.user.sky_birth_place, "")
self.assertEqual(self.user.sky_birth_tz, "")
def test_post_redirects_to_sky_view(self):
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("sky"))
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_requires_login(self):
self.client.logout()
response = self.client.post(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_post_preserves_unrelated_user_fields(self):
self.user.username = "stargazer-keepme"
self.user.save()
self.client.post(self.url)
self.user.refresh_from_db()
self.assertEqual(self.user.username, "stargazer-keepme")

View File

@@ -17,6 +17,7 @@ urlpatterns = [
path('sky/', views.sky_view, name='sky'), path('sky/', views.sky_view, name='sky'),
path('sky/preview', views.sky_preview, name='sky_preview'), path('sky/preview', views.sky_preview, name='sky_preview'),
path('sky/save', views.sky_save, name='sky_save'), path('sky/save', views.sky_save, name='sky_save'),
path('sky/delete', views.sky_delete, name='sky_delete'),
path('sky/data', views.sky_data, name='sky_data'), path('sky/data', views.sky_data, name='sky_data'),
path('set-pronouns', views.set_pronouns, name='set_pronouns'), path('set-pronouns', views.set_pronouns, name='set_pronouns'),
] ]

View File

@@ -9,8 +9,9 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
@@ -439,6 +440,25 @@ def sky_save(request):
return JsonResponse({"saved": True, "note": note_payload}) return JsonResponse({"saved": True, "note": note_payload})
@login_required(login_url="/")
def sky_delete(request):
if request.method != 'POST':
return HttpResponse(status=405)
user = request.user
user.sky_birth_dt = None
user.sky_birth_lat = None
user.sky_birth_lon = None
user.sky_birth_place = ''
user.sky_birth_tz = ''
user.sky_house_system = User._meta.get_field('sky_house_system').default
user.sky_chart_data = None
user.save(update_fields=[
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
])
return HttpResponseRedirect(reverse('sky'))
@login_required(login_url="/") @login_required(login_url="/")
def sky_data(request): def sky_data(request):
user = request.user user = request.user

View File

@@ -467,6 +467,186 @@ class MySkyApertureSnapScrollTest(FunctionalTest):
)) ))
class MySkyAsyncSaveTest(FunctionalTest):
"""A fresh user (no saved sky) clicks SAVE SKY (mocked) — without a page
reload, the page transitions into the saved state: body picks up
.sky-saved, the wheel SVG renders, and the DEL btn becomes visible."""
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="newcomer@test.io")
self.sky_url = self.live_server_url + "/dashboard/sky/"
def test_save_sky_async_activates_saved_state_without_reload(self):
self.create_pre_authenticated_session("newcomer@test.io")
self.browser.get(self.sky_url)
# Pre-save state: no wheel children, body has no sky-saved class
body = self.browser.find_element(By.TAG_NAME, "body")
self.assertNotIn("sky-saved", body.get_attribute("class"))
# Mock /sky/preview + /sky/save
self.browser.execute_script("""
const FIXTURE = """ + _json.dumps(_CHART_FIXTURE) + """;
window._origFetch = window.fetch;
window.fetch = function(url, opts) {
if (typeof url === 'string' && url.includes('/sky/preview')) {
return Promise.resolve({ok:true, json:()=>Promise.resolve(FIXTURE)});
}
if (typeof url === 'string' && url.includes('/sky/save')) {
return Promise.resolve({ok:true, json:()=>Promise.resolve({saved:true})});
}
return window._origFetch(url, opts);
};
""")
# Fill required form fields & dispatch input → preview enables save btn
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_tz').value = 'Europe/London';
document.getElementById('id_nf_date').dispatchEvent(
new Event('input', {bubbles:true})
);
""")
confirm_btn = self.browser.find_element(By.ID, "id_sky_confirm")
self.wait_for(lambda: self.assertIsNone(
confirm_btn.get_attribute("disabled")
))
confirm_btn.click()
# 1. body picks up sky-saved without a reload
self.wait_for(lambda: self.assertIn(
"sky-saved",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
))
# 2. Wheel SVG is now drawn (has child elements)
self.wait_for(lambda: self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, "#id_sky_svg > *"),
"Wheel SVG should have children after async save",
))
# 3. DEL btn visible
del_btn = self.browser.find_element(By.CSS_SELECTOR, ".sky-wheel-col .btn-danger")
self.assertTrue(del_btn.is_displayed())
class MySkyDeleteFlowTest(FunctionalTest):
"""A .btn-danger DEL button sits at the wheel center when sky data exists.
Clicking it summons the global #id_guard_portal (OK / NVM); OK clears all
sky fields on the User model and reverts the page to the form-only view."""
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.gamer.sky_chart_data = _CHART_FIXTURE
self.gamer.sky_birth_place = "Baltimore, MD, US"
self.gamer.sky_birth_tz = "America/New_York"
self.gamer.sky_birth_lat = 39.2904
self.gamer.sky_birth_lon = -76.6122
self.gamer.sky_birth_dt = datetime(
1990, 6, 15, 12, 0, tzinfo=zoneinfo.ZoneInfo("UTC")
)
self.gamer.save()
self.sky_url = self.live_server_url + "/dashboard/sky/"
# ── T1 ───────────────────────────────────────────────────────────────────
def test_del_btn_only_visible_when_sky_data_exists(self):
"""DEL btn is hidden for users w. no saved sky; visible once data is saved.
The btn lives in the DOM unconditionally (so async save can reveal it) —
visibility is gated by body.sky-saved via CSS."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
del_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sky-wheel-col .btn-danger"
))
self.assertTrue(del_btn.is_displayed())
# Sanity: a fresh user (no chart_data) → DEL btn hidden (display:none)
from apps.lyric.models import User as _U
_U.objects.create(email="newcomer@test.io")
self.create_pre_authenticated_session("newcomer@test.io")
self.browser.get(self.sky_url)
self.wait_for(lambda: self.browser.find_element(By.ID, "id_nf_date"))
btns = self.browser.find_elements(By.CSS_SELECTOR, ".sky-wheel-col .btn-danger")
self.assertTrue(
not btns or not btns[0].is_displayed(),
"DEL btn should not be visible before any sky data is saved",
)
# ── T2 ───────────────────────────────────────────────────────────────────
def test_clicking_del_then_nvm_keeps_sky_data(self):
"""DEL → guard portal → NVM dismisses; user's sky fields are untouched."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
del_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sky-wheel-col .btn-danger"
))
del_btn.click()
portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal"))
self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class")))
portal.find_element(By.CSS_SELECTOR, ".guard-no").click()
self.wait_for(lambda: self.assertNotIn(
"active", portal.get_attribute("class")
))
self.gamer.refresh_from_db()
self.assertIsNotNone(self.gamer.sky_chart_data)
self.assertEqual(self.gamer.sky_birth_place, "Baltimore, MD, US")
# ── T3 ───────────────────────────────────────────────────────────────────
def test_clicking_del_then_ok_clears_sky_and_reverts_to_form_only(self):
"""DEL → guard portal → OK clears the user's sky fields, redirects, &
the next sky-page render has no body.sky-saved class."""
self.create_pre_authenticated_session("stargazer@test.io")
self.browser.get(self.sky_url)
# Confirm initial saved state
body = self.browser.find_element(By.TAG_NAME, "body")
self.assertIn("sky-saved", body.get_attribute("class"))
del_btn = self.wait_for(lambda: self.browser.find_element(
By.CSS_SELECTOR, ".sky-wheel-col .btn-danger"
))
del_btn.click()
portal = self.wait_for(lambda: self.browser.find_element(By.ID, "id_guard_portal"))
self.wait_for(lambda: self.assertIn("active", portal.get_attribute("class")))
portal.find_element(By.CSS_SELECTOR, ".guard-yes").click()
# Page navigates back to /dashboard/sky/; body class no longer carries sky-saved
self.wait_for(lambda: self.assertNotIn(
"sky-saved",
self.browser.find_element(By.TAG_NAME, "body").get_attribute("class"),
))
# DB-side: every sky field is cleared
self.gamer.refresh_from_db()
self.assertIsNone(self.gamer.sky_chart_data)
self.assertIsNone(self.gamer.sky_birth_dt)
self.assertEqual(self.gamer.sky_birth_place, "")
self.assertEqual(self.gamer.sky_birth_tz, "")
class MySkyWheelConjunctionTest(FunctionalTest): class MySkyWheelConjunctionTest(FunctionalTest):
"""Tick lines, z-raise, and dual tooltip for conjunct planets.""" """Tick lines, z-raise, and dual tooltip for conjunct planets."""

View File

@@ -931,6 +931,18 @@ body.page-sky {
overflow-y: auto; overflow-y: auto;
} }
// DEL btn pinned at the wheel center — only rendered when chart_data exists.
// Anchored to .sky-wheel-col (which has position:relative) so the btn sits
// over the SVG's geometric center regardless of the snap layout's outer sizing.
#id_sky_delete_form {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 5;
margin: 0;
}
// Stack wheel above form; allow body to grow past viewport (page scrolls, not body) // Stack wheel above form; allow body to grow past viewport (page scrolls, not body)
.sky-page .sky-modal-body { .sky-page .sky-modal-body {
flex-direction: column; flex-direction: column;
@@ -948,21 +960,45 @@ body.page-sky {
align-self: center; align-self: center;
} }
// Form col runs horizontally below the wheel (same compact pattern as narrow-portrait modal) // Form col is a vertical stack — fields on top, SAVE SKY beneath, both
// centered horizontally. Pre-save the wheel-col is hidden so this column
// fills the aperture & sits visually centered. Post-save the snap layout
// (body.sky-saved) keeps the same internal stacking but pins each col to
// the aperture height.
.sky-page .sky-form-col { .sky-page .sky-form-col {
flex: 0 0 auto; flex: 1 0 auto;
flex-direction: row; flex-direction: column;
align-items: flex-end; align-items: center;
justify-content: center;
gap: 1rem;
min-height: 100%;
border-right: none; border-right: none;
border-top: 0.1rem solid rgba(var(--terUser), 0.12); border-top: 0.1rem solid rgba(var(--terUser), 0.12);
} }
.sky-page .sky-form-main { .sky-page .sky-form-main {
flex: 1; flex: 0 0 auto;
width: 100%;
max-width: 22rem;
min-width: 0; min-width: 0;
max-height: none;
overflow-y: visible; overflow-y: visible;
} }
// The (max-width:600px) block (written for the in-room PICK SKY modal where
// form-col is flex-row) sets align-self:flex-end on the btn — that's "right"
// once we flip to flex-column. Reset.
.sky-page .sky-form-col > #id_sky_confirm {
align-self: auto;
}
// Pre-save the wheel section is hidden — no preview wheel shunts the form
// downward & the user clearly sees SAVE SKY. The DEL btn rides along so
// async SAVE SKY can reveal it without a template re-render.
body:not(.sky-saved) .sky-page .sky-wheel-col {
display: none;
}
// ── Snap-binary aperture (post-save) ────────────────────────────────────────── // ── Snap-binary aperture (post-save) ──────────────────────────────────────────
// Once a sky is saved, the .sky-page aperture flips into scroll-snap mode: // Once a sky is saved, the .sky-page aperture flips into scroll-snap mode:
// the wheel section + form section each fill the aperture, so scrolling toggles // the wheel section + form section each fill the aperture, so scrolling toggles
@@ -1004,30 +1040,11 @@ body.sky-saved {
width: 100%; width: 100%;
} }
// Stack form-main on top, SAVE SKY beneath — both centered horizontally, // form-col loses its grow/min-height fill so the snap basis (min-height:100%
// and the pair vertically centered inside the aperture (parity w. the // above) wins instead — without this, two `flex: 1 0 auto` sections compete
// wheel). .sky-page .sky-form-col defaults to flex-row align-end which // for the aperture height and the snap stops landing at the col boundaries.
// would otherwise pin form-main left and the btn bottom-right.
.sky-page .sky-form-col { .sky-page .sky-form-col {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.sky-page .sky-form-main {
flex: 0 0 auto; flex: 0 0 auto;
width: 100%;
max-width: 22rem;
max-height: none; // reset the (max-width:600px) cap
overflow-y: visible; // no inner scroll — aperture handles it
}
// The (max-width:600px) block sets align-self:flex-end on the btn — that's
// "bottom" under the default flex-row form-col, but becomes "right" once we
// flip the col to flex-direction:column. Reset to inherit align-items:center.
.sky-page .sky-form-col > #id_sky_confirm {
align-self: auto;
} }
} }

View File

@@ -79,9 +79,15 @@
</button> </button>
</div> </div>
{# ── Wheel column ────────────────────────────────────────────────── #} {# Wheel column always renders so async SAVE SKY can populate it without a #}
{# refresh — visibility (incl. the DEL btn) is gated by body.sky-saved. #}
<div class="sky-wheel-col"> <div class="sky-wheel-col">
<svg id="id_sky_svg" class="sky-svg"></svg> <svg id="id_sky_svg" class="sky-svg"></svg>
<form id="id_sky_delete_form" method="POST" action="{% url 'sky_delete' %}">
{% csrf_token %}
<button type="submit" class="btn btn-danger"
data-confirm="Forget sky?">DEL</button>
</form>
</div> </div>
</div>{# /.sky-modal-body #} </div>{# /.sky-modal-body #}
@@ -265,11 +271,18 @@
} }
setStatus(''); setStatus('');
confirmBtn.disabled = false; confirmBtn.disabled = false;
// Only redraw the wheel when a saved sky already exists on the page —
// pre-first-save we suppress the live wheel preview so it doesn't
// shunt the form (and SAVE SKY) below the fold. Mirrors the My Sky
// applet's "no wheel until saved" UX. The in-room PICK SKY overlay
// intentionally still previews live.
if (_savedSky) {
if (svgEl.querySelector('*')) { if (svgEl.querySelector('*')) {
SkyWheel.redraw(data); SkyWheel.redraw(data);
} else { } else {
SkyWheel.draw(svgEl, data); SkyWheel.draw(svgEl, data);
} }
}
}) })
.catch(err => { .catch(err => {
setStatus(`Could not fetch chart: ${err.message}`, 'error'); setStatus(`Could not fetch chart: ${err.message}`, 'error');
@@ -307,7 +320,7 @@
.then(data => { .then(data => {
setStatus('Sky saved!'); setStatus('Sky saved!');
Note.handleSaveResponse(data); Note.handleSaveResponse(data);
_scrollApertureToTop(); _activateSavedState();
}) })
.catch(err => { .catch(err => {
setStatus(`Save failed: ${err.message}`, 'error'); setStatus(`Save failed: ${err.message}`, 'error');
@@ -315,6 +328,31 @@
}); });
}); });
// ── Async save activation ──────────────────────────────────────────────
// After SAVE SKY succeeds we transition into the saved state without a page
// refresh: add body.sky-saved (reveals the wheel-col + DEL btn, switches
// the aperture into snap-binary mode), draw the wheel from _lastChartData,
// and pin the aperture to the form section so _scrollApertureToTop()'s
// ease-out reveals the wheel sliding in from above.
function _activateSavedState() {
if (!_lastChartData) return;
const wasAlreadySaved = document.body.classList.contains('sky-saved');
document.body.classList.add('sky-saved');
if (svgEl.querySelector('*')) {
SkyWheel.redraw(_lastChartData);
} else {
SkyWheel.draw(svgEl, _lastChartData);
}
if (!wasAlreadySaved) {
// First-time save: pin scroll to the form section so the wheel reveal
// animates in instead of replacing the form with a hard cut.
const formCol = document.querySelector('.sky-page .sky-form-col');
if (formCol) overlay.scrollTop = formCol.offsetTop;
}
_scrollApertureToTop();
}
// ── Snap-back scroll on save ──────────────────────────────────────────── // ── Snap-back scroll on save ────────────────────────────────────────────
// .sky-page is scroll-snap-y-mandatory (post-save). After SAVE SKY the user // .sky-page is scroll-snap-y-mandatory (post-save). After SAVE SKY the user
// should land back on the wheel section even if they clicked from the form // should land back on the wheel section even if they clicked from the form