brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD

Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line.

Server:
- billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK).
- drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title).
- dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes').

Banner JS (apps/dashboard/note.js):
- Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working.
- sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse.

Tests:
- new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present.
- existing drama test_models.GrantIfNew tests updated to unpack the third tuple element.
- dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/).
- NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/).
- functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/.

billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green.

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 18:00:01 -04:00
parent 7f9ff36d1d
commit fa53bf561a
13 changed files with 342 additions and 125 deletions

View File

@@ -1,27 +1,44 @@
const Note = (() => {
// Slide-down Brief banner — appears under the navbar h2 with a Gaussian-glass
// background. Banner data comes from the Brief.to_banner_dict shape on the
// server: {id, kind, title, line_text, post_url, square_url, created_at}.
//
// FYI button → brief.post_url (the post detail; that GET marks the Brief read).
// .note-banner__image (the "square") → brief.square_url (kind-specific
// shortcut; note-unlock briefs jump direct to /billboard/my-notes/).
// NVM dismisses without marking read.
//
// `Note` is preserved as an alias for backwards-compat with any leftover
// caller while the C3 sprint lands; new code should use `Brief.*`.
const Brief = (() => {
'use strict';
function showBanner(note) {
if (!note) return;
function showBanner(brief) {
if (!brief) return;
const earned = new Date(note.earned_at);
const dateStr = earned.toLocaleDateString(undefined, {
const created = new Date(brief.created_at);
const dateStr = created.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
const banner = document.createElement('div');
banner.className = 'note-banner';
const squareEl = brief.square_url
? '<a href="' + _esc(brief.square_url) + '" class="note-banner__image"></a>'
: '<div class="note-banner__image"></div>';
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(note.title) + '</p>' +
'<p class="note-banner__description">' + _esc(note.description) + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' +
'<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
'<p class="note-banner__description">' + _esc(brief.line_text) + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
dateStr +
'</time>' +
'</div>' +
'<div class="note-banner__image"></div>' +
squareEl +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<a href="/billboard/my-notes/" class="btn btn-info note-banner__fyi">FYI</a>';
'<a href="' + _esc(brief.post_url) + '" class="btn btn-info note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove();
@@ -36,7 +53,7 @@ const Note = (() => {
}
function handleSaveResponse(data) {
showBanner(data && data.note);
showBanner(data && data.brief);
}
function _esc(str) {
@@ -47,3 +64,6 @@ const Note = (() => {
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
})();
// Backwards-compat shim — to be removed once the codebase uniformly uses Brief.
const Note = Brief;

View File

@@ -272,23 +272,25 @@ class SkySaveNoteTest(TestCase):
content_type="application/json",
)
def test_first_save_with_chart_data_returns_stargazer_note(self):
def test_first_save_with_chart_data_returns_stargazer_brief(self):
data = self._post().json()
self.assertIn("note", data)
recog = data["note"]
self.assertEqual(recog["slug"], "stargazer")
self.assertIn("title", recog)
self.assertIn("description", recog)
self.assertIn("earned_at", recog)
self.assertIn("brief", data)
brief = data["brief"]
self.assertEqual(brief["kind"], "note_unlock")
self.assertEqual(brief["title"], "Stargazer")
self.assertIn("Stargazer", brief["line_text"])
self.assertIn("/billboard/post/", brief["post_url"])
self.assertEqual(brief["square_url"], "/billboard/my-notes/")
self.assertIn("created_at", brief)
def test_first_save_creates_note_in_db(self):
self._post()
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_second_save_returns_null_note(self):
def test_second_save_returns_null_brief(self):
self._post()
data = self._post().json()
self.assertIsNone(data["note"])
self.assertIsNone(data["brief"])
def test_second_save_does_not_create_duplicate_note(self):
self._post()
@@ -297,12 +299,12 @@ class SkySaveNoteTest(TestCase):
def test_save_with_empty_chart_data_does_not_grant_note(self):
data = self._post(chart_data={}).json()
self.assertIsNone(data["note"])
self.assertIsNone(data["brief"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_note(self):
data = self._post(chart_data=None).json()
self.assertIsNone(data["note"])
self.assertIsNone(data["brief"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)

View File

@@ -362,18 +362,32 @@ def sky_save(request):
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
])
note_payload = None
brief_payload = None
if user.sky_chart_data:
note, created = Note.grant_if_new(user, "stargazer")
if created:
note_payload = {
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": note.earned_at.isoformat(),
}
note, created, brief = Note.grant_if_new(user, "stargazer")
if created and brief is not None:
brief_payload = _brief_to_banner_dict(brief)
return JsonResponse({"saved": True, "note": note_payload})
return JsonResponse({"saved": True, "brief": brief_payload})
def _brief_to_banner_dict(brief):
"""Shape a Brief for the slide-down banner JS. NOTE_UNLOCK kind carries
a `square_url` pointing at /billboard/my-notes/ so the thumbnail-square
inside the banner jumps direct to the user's Note collection."""
square_url = ""
if brief.kind == "note_unlock":
from django.urls import reverse
square_url = reverse("billboard:my_notes")
return {
"id": str(brief.id),
"kind": brief.kind,
"title": brief.title,
"line_text": brief.line.text if brief.line else "",
"post_url": brief.post.get_absolute_url(),
"square_url": square_url,
"created_at": brief.created_at.isoformat(),
}
@login_required(login_url="/")