From fa53bf561a8705b9bda16baf6326b5f450984b1a Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 8 May 2026 18:00:01 -0400 Subject: [PATCH] =?UTF-8?q?brief=20sprint=20C3.a:=20Note=20unlock=20spawns?= =?UTF-8?q?=20Line=20+=20Brief=20on=20the=20user's=20per-category=20Post;?= =?UTF-8?q?=20banner=20JS=20consumes=20the=20new=20brief=20payload=20(FYI?= =?UTF-8?q?=20=E2=86=92=20post=20detail,=20square=20=E2=86=92=20my-notes)?= =?UTF-8?q?=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 , 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// (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 Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../billboard/migrations/0003_post_kind.py | 18 ++++ src/apps/billboard/models.py | 18 ++++ .../dashboard/static/apps/dashboard/note.js | 42 ++++++--- .../tests/integrated/test_sky_views.py | 24 ++--- src/apps/dashboard/views.py | 34 +++++-- src/apps/drama/models.py | 35 ++++++- .../drama/tests/integrated/test_models.py | 6 +- .../drama/tests/integrated/test_note_brief.py | 85 +++++++++++++++++ src/functional_tests/test_applet_my_notes.py | 17 +++- src/static/tests/NoteSpec.js | 92 ++++++++++--------- src/static_src/tests/NoteSpec.js | 92 ++++++++++--------- .../dashboard/_partials/_applet-my-sky.html | 2 +- src/templates/apps/dashboard/sky.html | 2 +- 13 files changed, 342 insertions(+), 125 deletions(-) create mode 100644 src/apps/billboard/migrations/0003_post_kind.py create mode 100644 src/apps/drama/tests/integrated/test_note_brief.py diff --git a/src/apps/billboard/migrations/0003_post_kind.py b/src/apps/billboard/migrations/0003_post_kind.py new file mode 100644 index 0000000..6db9736 --- /dev/null +++ b/src/apps/billboard/migrations/0003_post_kind.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-08 21:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billboard', '0002_brief'), + ] + + operations = [ + migrations.AddField( + model_name='post', + name='kind', + field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites')], default='user_post', max_length=32), + ), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index 69dc5ff..8743a2a 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -6,6 +6,15 @@ from django.utils import timezone class Post(models.Model): + KIND_NOTE_UNLOCK = "note_unlock" + KIND_USER_POST = "user_post" + KIND_SHARE_INVITE = "share_invite" + KIND_CHOICES = [ + (KIND_NOTE_UNLOCK, "Note unlocks"), + (KIND_USER_POST, "User post"), + (KIND_SHARE_INVITE, "Share invites"), + ] + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey( "lyric.User", @@ -21,6 +30,15 @@ class Post(models.Model): blank=True, ) + # `kind` discriminates per-category Posts — e.g. Note.grant_if_new appends + # to the user's single (owner=user, kind=NOTE_UNLOCK) Post; user-authored + # composes default to KIND_USER_POST. + kind = models.CharField( + max_length=32, + choices=KIND_CHOICES, + default=KIND_USER_POST, + ) + @property def name(self): return self.lines.first().text diff --git a/src/apps/dashboard/static/apps/dashboard/note.js b/src/apps/dashboard/static/apps/dashboard/note.js index 8669bb3..efe838b 100644 --- a/src/apps/dashboard/static/apps/dashboard/note.js +++ b/src/apps/dashboard/static/apps/dashboard/note.js @@ -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 + ? '' + : '
'; + banner.innerHTML = '
' + - '

' + _esc(note.title) + '

' + - '

' + _esc(note.description) + '

' + - '
' + - '
' + + squareEl + '' + - 'FYI'; + 'FYI'; 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; diff --git a/src/apps/dashboard/tests/integrated/test_sky_views.py b/src/apps/dashboard/tests/integrated/test_sky_views.py index f1f4d29..db01561 100644 --- a/src/apps/dashboard/tests/integrated/test_sky_views.py +++ b/src/apps/dashboard/tests/integrated/test_sky_views.py @@ -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) diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index fe95e20..4ad37e3 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -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="/") diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 90f6445..428a23e 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -237,8 +237,41 @@ class Note(models.Model): @classmethod def grant_if_new(cls, user, slug): + """Grants the Note if it doesn't already exist on the user; on a fresh + grant ALSO appends a Line to the user's per-category "Note Unlocks" + Post (creating the Post on first-ever unlock) and spawns a Brief that + FKs the appended Line. Returns ``(note, created, brief)`` — brief is + None on idempotent re-grants. Banner-side affordances (FYI navigation, + my-notes square) ride on the Brief.kind=NOTE_UNLOCK discriminator.""" from django.utils import timezone - return cls.objects.get_or_create( + + from apps.billboard.models import Brief, Line, Post + + note, created = cls.objects.get_or_create( user=user, slug=slug, defaults={"earned_at": timezone.now()}, ) + if not created: + return note, created, None + + post, _ = Post.objects.get_or_create( + owner=user, kind=Post.KIND_NOTE_UNLOCK, + ) + # Per-category header Line (becomes Post.name) — only added once on + # first-ever unlock for this user. + Line.objects.get_or_create(post=post, text="Look! — new Note unlocked") + # Per-event Line — text dedupe is enforced by the unique_together on + # (post, text), so two unlocks of the same slug at the same minute + # would clash; the timestamp suffix carries the second of resolution. + # %-I (lstrip-zero hour) is POSIX-only; %I gives "05:21:00 PM" — fine + # on Windows + Linux, and the leading zero is acceptable in a Line. + line_text = f"{note.display_title}, {note.earned_at:%I:%M:%S %p}" + line = Line.objects.create(post=post, text=line_text) + brief = Brief.objects.create( + owner=user, + post=post, + line=line, + kind=Brief.KIND_NOTE_UNLOCK, + title=note.display_title, + ) + return note, created, brief diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index 9507034..05a9c33 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -308,14 +308,14 @@ class NoteModelTest(TestCase): self.assertIn("earner@test.io", s) def test_grant_if_new_creates_on_first_call(self): - recog, created = Note.grant_if_new(self.user, "stargazer") + recog, created, _brief = Note.grant_if_new(self.user, "stargazer") self.assertTrue(created) self.assertEqual(recog.slug, "stargazer") self.assertIsNotNone(recog.earned_at) def test_grant_if_new_is_idempotent(self): Note.grant_if_new(self.user, "stargazer") - recog, created = Note.grant_if_new(self.user, "stargazer") + recog, created, _brief = Note.grant_if_new(self.user, "stargazer") self.assertFalse(created) self.assertEqual(Note.objects.count(), 1) @@ -324,6 +324,6 @@ class NoteModelTest(TestCase): user=self.user, slug="stargazer", earned_at=timezone.now(), palette="palette-bardo", ) - recog, created = Note.grant_if_new(self.user, "stargazer") + recog, created, _brief = Note.grant_if_new(self.user, "stargazer") self.assertFalse(created) self.assertEqual(recog.palette, "palette-bardo") diff --git a/src/apps/drama/tests/integrated/test_note_brief.py b/src/apps/drama/tests/integrated/test_note_brief.py new file mode 100644 index 0000000..14ca98b --- /dev/null +++ b/src/apps/drama/tests/integrated/test_note_brief.py @@ -0,0 +1,85 @@ +"""ITs for the Brief sprint C3.a — Note.grant_if_new spawns Line + Brief. + +Per the per-category Post model: the user has a single "Note Unlocks" Post; +each unlock appends a Line ("Stargazer, 5:21pm") and spawns a Brief FKing +the appended Line. Briefs of kind=NOTE_UNLOCK live on Posts of kind= +NOTE_UNLOCK. +""" + +from django.test import TestCase + +from apps.billboard.models import Brief, Line, Post +from apps.drama.models import Note +from apps.lyric.models import User + + +class GrantIfNewSpawnsBriefTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="brief-grant@test.io") + + def test_first_grant_creates_post_line_and_brief(self): + note, created, brief = Note.grant_if_new(self.user, "stargazer") + self.assertTrue(created) + self.assertIsNotNone(brief) + # Note Unlocks Post created on user + post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK) + # Brief points at that Post + the appended Line + self.assertEqual(brief.post_id, post.id) + self.assertEqual(brief.kind, Brief.KIND_NOTE_UNLOCK) + self.assertTrue(brief.is_unread) + self.assertEqual(brief.owner, self.user) + # The Brief's line is one of the Post's lines + self.assertIn(brief.line, list(post.lines.all())) + # Brief title matches Note display title + self.assertEqual(brief.title, note.display_title) + + def test_second_grant_same_slug_returns_no_brief(self): + Note.grant_if_new(self.user, "stargazer") + note, created, brief = Note.grant_if_new(self.user, "stargazer") + self.assertFalse(created) + self.assertIsNone(brief) + # Still only one Brief / Line for the user (idempotent) + self.assertEqual( + Brief.objects.filter(owner=self.user, kind=Brief.KIND_NOTE_UNLOCK).count(), 1 + ) + + def test_two_different_grants_share_one_post(self): + """Per-category Post: stargazer + schizo unlocks both append Lines to + the same Note Unlocks Post (one growing thread).""" + Note.grant_if_new(self.user, "stargazer") + Note.grant_if_new(self.user, "schizo") + posts = Post.objects.filter(owner=self.user, kind=Post.KIND_NOTE_UNLOCK) + self.assertEqual(posts.count(), 1, "Only one Note Unlocks Post per user") + post = posts.first() + # 2 Briefs, one per unlock + self.assertEqual(Brief.objects.filter(post=post).count(), 2) + # 3 distinct Lines on the Post: 1 header ("Look! — new Note unlocked") + # + 2 per-event Lines (one per unlock) + line_texts = list(post.lines.values_list("text", flat=True)) + self.assertEqual(len(set(line_texts)), 3) + + def test_brief_line_text_includes_note_title(self): + _, _, brief = Note.grant_if_new(self.user, "stargazer") + self.assertIn("Stargazer", brief.line.text) + + def test_post_kind_is_note_unlock_for_grant(self): + Note.grant_if_new(self.user, "stargazer") + post = Post.objects.get(owner=self.user, kind=Post.KIND_NOTE_UNLOCK) + self.assertEqual(post.kind, Post.KIND_NOTE_UNLOCK) + + +class PostKindFieldTest(TestCase): + """Post gains a `kind` enum so the per-category lookup (e.g. find the + user's Note Unlocks Post) is deterministic; user-authored Posts default + to KIND_USER_POST.""" + + def test_post_default_kind_is_user_post(self): + u = User.objects.create(email="kind@test.io") + p = Post.objects.create(owner=u) + self.assertEqual(p.kind, Post.KIND_USER_POST) + + def test_post_kind_choices_include_three_values(self): + choices = dict(Post._meta.get_field("kind").choices) + self.assertIn(Post.KIND_NOTE_UNLOCK, choices) + self.assertIn(Post.KIND_USER_POST, choices) + self.assertIn(Post.KIND_SHARE_INVITE, choices) diff --git a/src/functional_tests/test_applet_my_notes.py b/src/functional_tests/test_applet_my_notes.py index 7fc31a0..8f82116 100644 --- a/src/functional_tests/test_applet_my_notes.py +++ b/src/functional_tests/test_applet_my_notes.py @@ -150,16 +150,27 @@ class StargazerNoteFromDashboardTest(FunctionalTest): ) banner.find_element(By.CSS_SELECTOR, ".note-banner__description") banner.find_element(By.CSS_SELECTOR, ".note-banner__timestamp") - banner.find_element(By.CSS_SELECTOR, ".note-banner__image") + # Per the Brief sprint, .note-banner__image is now a clickable for + # NOTE_UNLOCK kind — square goes to my_notes.html (the legacy FYI + # behavior preserved on the square). + square = banner.find_element(By.CSS_SELECTOR, ".note-banner__image") + self.assertEqual(square.tag_name, "a") + self.assertEqual(square.get_attribute("href").rstrip("/"), + self.live_server_url + "/billboard/my-notes") banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") # FYI - # FYI navigates to Note page + # FYI now navigates to the underlying Brief's Post detail + # (/billboard/post//) — the GET render is the mark-read contract. fyi.click() self.wait_for( - lambda: self.assertRegex(self.browser.current_url, r"/billboard/my-notes") + lambda: self.assertRegex(self.browser.current_url, r"/billboard/post/[0-9a-f-]+/") ) + # Square (.note-banner__image) preserves the jump-direct-to-my-notes + # behavior. Reload the dashboard and re-fire the banner via a stash. + self.browser.get(self.live_server_url + "/billboard/my-notes/") + # Note page: one Stargazer item item = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item") diff --git a/src/static/tests/NoteSpec.js b/src/static/tests/NoteSpec.js index 8e750b1..d409fb6 100644 --- a/src/static/tests/NoteSpec.js +++ b/src/static/tests/NoteSpec.js @@ -1,30 +1,35 @@ // ── NoteSpec.js ─────────────────────────────────────────────────────────────── // -// Unit specs for note.js — banner injection from sky/save response. +// Unit specs for note.js (the slide-down Brief banner). The banner module is +// exposed as `Brief` (with `Note` kept as an alias during the C3 sprint). // // DOM contract assumed by showBanner(): // Any

in the document — banner is inserted immediately after it. // If absent, banner is prepended to . // // API under test: -// Note.showBanner(note) -// note = { slug, title, description, earned_at } → inject .note-banner -// note = null → no-op +// Brief.showBanner(brief) +// brief = { id, kind, title, line_text, post_url, square_url, created_at } +// → inject .note-banner +// brief = null → no-op // -// Note.handleSaveResponse(data) -// data = { saved: true, note: {...} } → delegates to showBanner -// data = { saved: true, note: null } → no-op +// Brief.handleSaveResponse(data) +// data = { saved: true, brief: {...} } → delegates to showBanner +// data = { saved: true, brief: null } → no-op // // ───────────────────────────────────────────────────────────────────────────── -const SAMPLE_NOTE = { - slug: 'stargazer', - title: 'Stargazer', - description: 'You saved your first personal sky chart.', - earned_at: '2026-04-22T02:00:00+00:00', +const SAMPLE_BRIEF = { + id: '00000000-0000-0000-0000-000000000001', + kind: 'note_unlock', + title: 'Stargazer', + line_text: 'Stargazer, 02:00:00 AM', + post_url: '/billboard/post/abc/', + square_url: '/billboard/my-notes/', + created_at: '2026-04-22T02:00:00+00:00', }; -describe('Note.showBanner', () => { +describe('Brief.showBanner', () => { let fixture; @@ -43,69 +48,72 @@ describe('Note.showBanner', () => { // ── T1 ── null → no banner ──────────────────────────────────────────────── it('T1: showBanner(null) does not inject a banner', () => { - Note.showBanner(null); + Brief.showBanner(null); expect(document.querySelector('.note-banner')).toBeNull(); }); - // ── T2 ── note present → banner in DOM ─────────────────────────────────── + // ── T2 ── brief present → banner in DOM ────────────────────────────────── - it('T2: showBanner(note) injects .note-banner into the document', () => { - Note.showBanner(SAMPLE_NOTE); + it('T2: showBanner(brief) injects .note-banner into the document', () => { + Brief.showBanner(SAMPLE_BRIEF); expect(document.querySelector('.note-banner')).not.toBeNull(); }); // ── T3 ── title ─────────────────────────────────────────────────────────── - it('T3: banner .note-banner__title contains note.title', () => { - Note.showBanner(SAMPLE_NOTE); + it('T3: banner .note-banner__title contains brief.title', () => { + Brief.showBanner(SAMPLE_BRIEF); const el = document.querySelector('.note-banner__title'); expect(el).not.toBeNull(); expect(el.textContent).toContain('Stargazer'); }); - // ── T4 ── description ───────────────────────────────────────────────────── + // ── T4 ── description (line_text) ───────────────────────────────────────── - it('T4: banner .note-banner__description contains note.description', () => { - Note.showBanner(SAMPLE_NOTE); + it('T4: banner .note-banner__description carries brief.line_text', () => { + Brief.showBanner(SAMPLE_BRIEF); const el = document.querySelector('.note-banner__description'); expect(el).not.toBeNull(); - expect(el.textContent).toContain('You saved your first personal sky chart.'); + expect(el.textContent).toContain('Stargazer, 02:00:00 AM'); }); // ── T5 ── timestamp ─────────────────────────────────────────────────────── it('T5: banner has a .note-banner__timestamp element', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); expect(document.querySelector('.note-banner__timestamp')).not.toBeNull(); }); - // ── T6 ── image area ────────────────────────────────────────────────────── + // ── T6 ── image area; for note_unlock kind it's a clickable square ────── - it('T6: banner has a .note-banner__image element', () => { - Note.showBanner(SAMPLE_NOTE); - expect(document.querySelector('.note-banner__image')).not.toBeNull(); + it('T6: banner .note-banner__image is a clickable when brief.square_url is set', () => { + Brief.showBanner(SAMPLE_BRIEF); + const sq = document.querySelector('.note-banner__image'); + expect(sq).not.toBeNull(); + expect(sq.tagName.toLowerCase()).toBe('a'); + expect(sq.getAttribute('href')).toBe('/billboard/my-notes/'); }); // ── T7 ── NVM button ────────────────────────────────────────────────────── it('T7: banner has a .btn.btn-cancel NVM button', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull(); }); - // ── T8 ── FYI link ──────────────────────────────────────────────────────── + // ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ───── - it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => { - Note.showBanner(SAMPLE_NOTE); + it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => { + Brief.showBanner(SAMPLE_BRIEF); const fyi = document.querySelector('.note-banner .btn.btn-info'); expect(fyi).not.toBeNull(); - expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/'); + expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/'); }); // ── T9 ── NVM dismissal ─────────────────────────────────────────────────── it('T9: clicking the NVM button removes the banner from the DOM', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); document.querySelector('.note-banner .btn.btn-cancel').click(); expect(document.querySelector('.note-banner')).toBeNull(); }); @@ -113,30 +121,30 @@ describe('Note.showBanner', () => { // ── T10 ── placement after h2 ───────────────────────────────────────────── it('T10: banner is inserted immediately after the first h2 in the document', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); const h2 = fixture.querySelector('h2'); expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue(); }); }); -describe('Note.handleSaveResponse', () => { +describe('Brief.handleSaveResponse', () => { afterEach(() => { document.querySelectorAll('.note-banner').forEach(b => b.remove()); }); - // ── T11 ── delegates when note present ──────────────────────────────────── + // ── T11 ── delegates when brief present ────────────────────────────────── - it('T11: handleSaveResponse shows banner when data.note is present', () => { - Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE }); + it('T11: handleSaveResponse shows banner when data.brief is present', () => { + Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF }); expect(document.querySelector('.note-banner')).not.toBeNull(); }); - // ── T12 ── no banner when note null ─────────────────────────────────────── + // ── T12 ── no banner when brief null ───────────────────────────────────── - it('T12: handleSaveResponse does not show banner when data.note is null', () => { - Note.handleSaveResponse({ saved: true, note: null }); + it('T12: handleSaveResponse does not show banner when data.brief is null', () => { + Brief.handleSaveResponse({ saved: true, brief: null }); expect(document.querySelector('.note-banner')).toBeNull(); }); diff --git a/src/static_src/tests/NoteSpec.js b/src/static_src/tests/NoteSpec.js index 8e750b1..d409fb6 100644 --- a/src/static_src/tests/NoteSpec.js +++ b/src/static_src/tests/NoteSpec.js @@ -1,30 +1,35 @@ // ── NoteSpec.js ─────────────────────────────────────────────────────────────── // -// Unit specs for note.js — banner injection from sky/save response. +// Unit specs for note.js (the slide-down Brief banner). The banner module is +// exposed as `Brief` (with `Note` kept as an alias during the C3 sprint). // // DOM contract assumed by showBanner(): // Any

in the document — banner is inserted immediately after it. // If absent, banner is prepended to . // // API under test: -// Note.showBanner(note) -// note = { slug, title, description, earned_at } → inject .note-banner -// note = null → no-op +// Brief.showBanner(brief) +// brief = { id, kind, title, line_text, post_url, square_url, created_at } +// → inject .note-banner +// brief = null → no-op // -// Note.handleSaveResponse(data) -// data = { saved: true, note: {...} } → delegates to showBanner -// data = { saved: true, note: null } → no-op +// Brief.handleSaveResponse(data) +// data = { saved: true, brief: {...} } → delegates to showBanner +// data = { saved: true, brief: null } → no-op // // ───────────────────────────────────────────────────────────────────────────── -const SAMPLE_NOTE = { - slug: 'stargazer', - title: 'Stargazer', - description: 'You saved your first personal sky chart.', - earned_at: '2026-04-22T02:00:00+00:00', +const SAMPLE_BRIEF = { + id: '00000000-0000-0000-0000-000000000001', + kind: 'note_unlock', + title: 'Stargazer', + line_text: 'Stargazer, 02:00:00 AM', + post_url: '/billboard/post/abc/', + square_url: '/billboard/my-notes/', + created_at: '2026-04-22T02:00:00+00:00', }; -describe('Note.showBanner', () => { +describe('Brief.showBanner', () => { let fixture; @@ -43,69 +48,72 @@ describe('Note.showBanner', () => { // ── T1 ── null → no banner ──────────────────────────────────────────────── it('T1: showBanner(null) does not inject a banner', () => { - Note.showBanner(null); + Brief.showBanner(null); expect(document.querySelector('.note-banner')).toBeNull(); }); - // ── T2 ── note present → banner in DOM ─────────────────────────────────── + // ── T2 ── brief present → banner in DOM ────────────────────────────────── - it('T2: showBanner(note) injects .note-banner into the document', () => { - Note.showBanner(SAMPLE_NOTE); + it('T2: showBanner(brief) injects .note-banner into the document', () => { + Brief.showBanner(SAMPLE_BRIEF); expect(document.querySelector('.note-banner')).not.toBeNull(); }); // ── T3 ── title ─────────────────────────────────────────────────────────── - it('T3: banner .note-banner__title contains note.title', () => { - Note.showBanner(SAMPLE_NOTE); + it('T3: banner .note-banner__title contains brief.title', () => { + Brief.showBanner(SAMPLE_BRIEF); const el = document.querySelector('.note-banner__title'); expect(el).not.toBeNull(); expect(el.textContent).toContain('Stargazer'); }); - // ── T4 ── description ───────────────────────────────────────────────────── + // ── T4 ── description (line_text) ───────────────────────────────────────── - it('T4: banner .note-banner__description contains note.description', () => { - Note.showBanner(SAMPLE_NOTE); + it('T4: banner .note-banner__description carries brief.line_text', () => { + Brief.showBanner(SAMPLE_BRIEF); const el = document.querySelector('.note-banner__description'); expect(el).not.toBeNull(); - expect(el.textContent).toContain('You saved your first personal sky chart.'); + expect(el.textContent).toContain('Stargazer, 02:00:00 AM'); }); // ── T5 ── timestamp ─────────────────────────────────────────────────────── it('T5: banner has a .note-banner__timestamp element', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); expect(document.querySelector('.note-banner__timestamp')).not.toBeNull(); }); - // ── T6 ── image area ────────────────────────────────────────────────────── + // ── T6 ── image area; for note_unlock kind it's a clickable square ────── - it('T6: banner has a .note-banner__image element', () => { - Note.showBanner(SAMPLE_NOTE); - expect(document.querySelector('.note-banner__image')).not.toBeNull(); + it('T6: banner .note-banner__image is a clickable when brief.square_url is set', () => { + Brief.showBanner(SAMPLE_BRIEF); + const sq = document.querySelector('.note-banner__image'); + expect(sq).not.toBeNull(); + expect(sq.tagName.toLowerCase()).toBe('a'); + expect(sq.getAttribute('href')).toBe('/billboard/my-notes/'); }); // ── T7 ── NVM button ────────────────────────────────────────────────────── it('T7: banner has a .btn.btn-cancel NVM button', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); expect(document.querySelector('.note-banner .btn.btn-cancel')).not.toBeNull(); }); - // ── T8 ── FYI link ──────────────────────────────────────────────────────── + // ── T8 ── FYI link points to brief.post_url (not hardcoded my-notes) ───── - it('T8: banner has a .btn.btn-info FYI link pointing to /billboard/my-notes/', () => { - Note.showBanner(SAMPLE_NOTE); + it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => { + Brief.showBanner(SAMPLE_BRIEF); const fyi = document.querySelector('.note-banner .btn.btn-info'); expect(fyi).not.toBeNull(); - expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/'); + expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/'); }); // ── T9 ── NVM dismissal ─────────────────────────────────────────────────── it('T9: clicking the NVM button removes the banner from the DOM', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); document.querySelector('.note-banner .btn.btn-cancel').click(); expect(document.querySelector('.note-banner')).toBeNull(); }); @@ -113,30 +121,30 @@ describe('Note.showBanner', () => { // ── T10 ── placement after h2 ───────────────────────────────────────────── it('T10: banner is inserted immediately after the first h2 in the document', () => { - Note.showBanner(SAMPLE_NOTE); + Brief.showBanner(SAMPLE_BRIEF); const h2 = fixture.querySelector('h2'); expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue(); }); }); -describe('Note.handleSaveResponse', () => { +describe('Brief.handleSaveResponse', () => { afterEach(() => { document.querySelectorAll('.note-banner').forEach(b => b.remove()); }); - // ── T11 ── delegates when note present ──────────────────────────────────── + // ── T11 ── delegates when brief present ────────────────────────────────── - it('T11: handleSaveResponse shows banner when data.note is present', () => { - Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE }); + it('T11: handleSaveResponse shows banner when data.brief is present', () => { + Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF }); expect(document.querySelector('.note-banner')).not.toBeNull(); }); - // ── T12 ── no banner when note null ─────────────────────────────────────── + // ── T12 ── no banner when brief null ───────────────────────────────────── - it('T12: handleSaveResponse does not show banner when data.note is null', () => { - Note.handleSaveResponse({ saved: true, note: null }); + it('T12: handleSaveResponse does not show banner when data.brief is null', () => { + Brief.handleSaveResponse({ saved: true, brief: null }); expect(document.querySelector('.note-banner')).toBeNull(); }); diff --git a/src/templates/apps/dashboard/_partials/_applet-my-sky.html b/src/templates/apps/dashboard/_partials/_applet-my-sky.html index abcd77f..81a140c 100644 --- a/src/templates/apps/dashboard/_partials/_applet-my-sky.html +++ b/src/templates/apps/dashboard/_partials/_applet-my-sky.html @@ -367,7 +367,7 @@ formWrap.style.display = 'none'; svgEl.style.display = ''; SkyWheel.preload().then(() => SkyWheel.draw(svgEl, _lastChartData)); - Note.handleSaveResponse(data); + Brief.handleSaveResponse(data); }) .catch(err => { setStatus(`Save failed: ${err.message}`, 'error'); diff --git a/src/templates/apps/dashboard/sky.html b/src/templates/apps/dashboard/sky.html index cba4e75..a847380 100644 --- a/src/templates/apps/dashboard/sky.html +++ b/src/templates/apps/dashboard/sky.html @@ -315,7 +315,7 @@ }) .then(data => { setStatus('Sky saved!'); - Note.handleSaveResponse(data); + Brief.handleSaveResponse(data); _activateSavedState(); }) .catch(err => {