Compare commits

..

4 Commits

Author SHA1 Message Date
Disco DeDisco
7b2780e642 test_applet_new_post: bump current-url regex /dashboard/post/ → /billboard/post/
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Stray /dashboard/post/ regex in NewVisitorTest.test_multiple_users_can_start_posts_at_different_urls — the path moved in the C1 brief-sprint relocation (d192b15) but two occurrences in this FT slipped past the bulk update. CI run #286 caught it.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:26:00 -04:00
Disco DeDisco
14bab444ff brief sprint C3.b+c+d+e: share-post Line+Brief async, magic-link / invalid-link banners use Brief styling, .alert-* retired — TDD
Closes the C3 brief sprint. Three event sources (note unlock, share invite, login messages) now route through the Brief slide-down, & the legacy .alert-success/.alert-warning rendering in base.html is retired.

C3.b — share-post async Line + Brief:
- billboard.share_post detects Accept: application/json. JSON path appends a Line (text="Shared with X at <isoformat>", isoformat carries microseconds so two rapid shares of the same email don't collide on Line.unique_together(post,text)), spawns a Brief(kind=SHARE_INVITE) for the sharer, and returns {brief: brief.to_banner_dict() | None, line_text, recipient_display}. Sharer-shares-with-themselves stays a silent no-op (response carries brief: null). Legacy form-submit path preserved for non-AJAX (still redirects + flashes the privacy-safe message — kept for older FTs / no-JS fallback).
- billboard.Brief.to_banner_dict() (moved from dashboard.views helper to a model method) shapes the JSON the banner JS consumes.
- post.html: share form intercepted by JS — fetches POST w. Accept:application/json, then appends `data.line_text` as the next row in #id_post_table, calls Brief.showBanner(data.brief), and (when registered) appends a fresh `<span class="post-recipient">` to the new #id_post_recipients box. No page reload — the alert-success flash is gone.
- 10 new ITs (SharePostAsyncTest + SharePostLegacyRedirectTest) cover the JSON path, line append, brief creation w. SHARE_INVITE kind, registered/unregistered recipient behaviour, sharer-self skip, line dedupe via timestamp, and that the legacy form-submit redirect path still works.
- functional_tests.test_sharing line numbering updated: the share now records its own Line so the alice-reply lands at row 3 instead of 2.

C3.c+d — magic-link confirmation + invalid-link error use Brief banner styling:
- base.html's {% if messages %} block stops rendering .alert-success/.alert-warning divs. Instead each message renders as a transient Brief-styled banner: <div class="note-banner note-banner--message note-banner--{{level_tag}}"> with .note-banner__body / __description carrying the message text and a .btn-cancel NVM that removes the banner via inline onclick. No DB Brief row; no FYI; no square. Same Gaussian-glass look as note-unlock + share-invite Briefs.
- _note.scss adds the note-banner--message variant (full-opacity description) + note-banner--error/--warning border-color override (priRd 0.6) so the invalid-link banner reads as red/abandon.

C3.e — .alert-success/.alert-warning retired in markup; the SCSS class blocks aren't referenced anywhere else in templates so they sit dormant (left in place — base form styling keeps .form-control etc. working; no need to ripple into _base.scss).

Banner JS (note.js / Brief module) was untouched in C3.b+c+d — the Brief.showBanner contract from C3.a already handles all three kinds (NOTE_UNLOCK / USER_POST / SHARE_INVITE) by reading kind off the brief; the message-banner path doesn't go through showBanner because there's no Brief row.

Tests: 218 dashboard+billboard+api ITs + 322 lyric+dashboard+billboard ITs + 2 sharing FTs + 9 my_notes FTs + 1 Jasmine FT all green. Existing lyric.test_views login message-text assertions unchanged (they pull from messages framework — not the rendered HTML — so the markup swap doesn't affect them).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:15:43 -04:00
Disco DeDisco
fa53bf561a 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>
2026-05-08 18:00:01 -04:00
Disco DeDisco
7f9ff36d1d brief sprint C2: introduce billboard.Brief notification model + view_post marks-read on GET — TDD
Brief is the slide-down-banner record that connects an event (a Line freshly appended to a Post) to a user who needs to see it. It's the C3 attachment point for note-unlock + share-invite + future event sources; the banner JS (C3) reads the Brief shape to render kind-specific affordances. C2 lays the schema + the FYI-read contract; C3 hooks the senders.

Schema (billboard.Brief):
- owner FK→lyric.User (related_name='briefs') — required; whose attention this is for
- post  FK→billboard.Post (related_name='briefs') — required; where FYI navigates
- line  FK→billboard.Line (related_name='briefs', null=True) — the appended Line that triggered the Brief; nullable for share-invite-style flows where the Line write races behind the Brief
- is_unread BooleanField default=True — flips on view_post GET
- kind CharField (note_unlock | user_post | share_invite, default=user_post) — drives banner-side affordances
- title CharField (blank=True) — banner display title
- created_at DateTimeField (default=timezone.now) — Meta.ordering='-created_at'

view_post (the post-detail GET) now bulk-updates is_unread=False on every Brief where owner == request.user AND post == our_post AND is_unread=True. POST (the compose-a-new-Line path) intentionally does NOT mark read — the user is authoring, not reviewing.

Tests: BriefModelTest (7) covers defaults, kind choices include all three values, line nullability, owner+post requiredness, title field, __str__ shape. ViewPostMarksReadTest (5) covers the GET flips owner's unread Brief to read; doesn't flip other users' Briefs on the same post; doesn't flip Briefs on unrelated posts; idempotent for already-read; POST request does NOT mark read.

Auto migration billboard/0002_brief creates the table. 801-test IT regression green (789 + 12 new).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:35:46 -04:00
22 changed files with 837 additions and 147 deletions

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0 on 2026-05-08 21:34
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Brief',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('is_unread', models.BooleanField(default=True)),
('kind', models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite')], default='user_post', max_length=32)),
('title', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.line')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to=settings.AUTH_USER_MODEL)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post')),
],
options={
'ordering': ['-created_at'],
},
),
]

View File

@@ -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),
),
]

View File

@@ -2,9 +2,19 @@ import uuid
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
class Post(models.Model): 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) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey( owner = models.ForeignKey(
"lyric.User", "lyric.User",
@@ -20,6 +30,15 @@ class Post(models.Model):
blank=True, 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 @property
def name(self): def name(self):
return self.lines.first().text return self.lines.first().text
@@ -38,3 +57,82 @@ class Line(models.Model):
def __str__(self): def __str__(self):
return self.text return self.text
class Brief(models.Model):
"""A slide-down notification record. Owner = whose attention; post = where
FYI navigates (and where mark-read happens on GET); line = the specific
appended Line that triggered it (so the banner can surface its text).
`kind` discriminates the affordances the banner renders. NOTE_UNLOCK
Briefs get a clickable square that jumps direct to my_notes.html;
SHARE_INVITE Briefs render the invitation copy; USER_POST is the legacy
user-authored compose flow.
Magic-link confirmation + invalid-link banners use the same Gaussian-glass
visual styling but ride no Brief row (transient one-shot).
"""
KIND_NOTE_UNLOCK = "note_unlock"
KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite"
KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlock"),
(KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invite"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="briefs",
on_delete=models.CASCADE,
)
post = models.ForeignKey(
Post,
related_name="briefs",
on_delete=models.CASCADE,
)
# Line is nullable because a share_invite-style Brief can race ahead of its
# async-appended Line write; the post FK alone is enough to navigate.
line = models.ForeignKey(
Line,
related_name="briefs",
on_delete=models.CASCADE,
null=True,
blank=True,
)
is_unread = models.BooleanField(default=True)
kind = models.CharField(
max_length=32,
choices=KIND_CHOICES,
default=KIND_USER_POST,
)
title = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return (
f"Brief({self.kind}, {self.owner.email}, "
f"unread={self.is_unread})"
)
def to_banner_dict(self):
"""Shape this 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 — other kinds get an empty square_url."""
square_url = ""
if self.kind == self.KIND_NOTE_UNLOCK:
square_url = reverse("billboard:my_notes")
return {
"id": str(self.id),
"kind": self.kind,
"title": self.title,
"line_text": self.line.text if self.line else "",
"post_url": self.post.get_absolute_url(),
"square_url": square_url,
"created_at": self.created_at.isoformat(),
}

View File

@@ -0,0 +1,121 @@
"""ITs for the Brief model & view_post's mark-read behavior.
Brief is a notification record — owner + post FK + line FK + is_unread + kind.
It rides on a Post (one-Post-per-category, Lines accumulate). Clicking FYI on
a Brief banner navigates to billboard:view_post for the underlying Post; that
GET is the contract that flips is_unread → False.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import User
class BriefModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="brief@test.io")
self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm")
def test_brief_defaults_unread(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.assertTrue(b.is_unread)
def test_brief_default_kind_is_user_post(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.assertEqual(b.kind, Brief.KIND_USER_POST)
def test_brief_kind_choices_include_note_unlock_and_share_invite(self):
choices = dict(Brief._meta.get_field("kind").choices)
self.assertIn(Brief.KIND_NOTE_UNLOCK, choices)
self.assertIn(Brief.KIND_USER_POST, choices)
self.assertIn(Brief.KIND_SHARE_INVITE, choices)
def test_brief_line_can_be_null(self):
"""A Brief may pre-date its Line (e.g. share-invite spawns the Line
async — the Brief should still be persistable while the Line write
is pending). Doesn't break the post FK."""
b = Brief.objects.create(owner=self.user, post=self.post)
self.assertIsNone(b.line)
def test_brief_owner_post_required(self):
"""Brief without owner OR post is invalid; both are the load-bearing
FKs (owner = whose attention; post = where FYI navigates)."""
from django.db import IntegrityError, transaction
with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(post=self.post, line=self.line)
with transaction.atomic(), self.assertRaises(IntegrityError):
Brief.objects.create(owner=self.user, line=self.line)
def test_brief_carries_title(self):
b = Brief.objects.create(
owner=self.user, post=self.post, line=self.line,
title="Look! — new Note unlocked",
)
self.assertEqual(b.title, "Look! — new Note unlocked")
def test_brief_str_includes_owner_kind_unread(self):
b = Brief.objects.create(owner=self.user, post=self.post, kind=Brief.KIND_NOTE_UNLOCK)
s = str(b)
self.assertIn("brief@test.io", s)
self.assertIn("note_unlock", s)
class ViewPostMarksReadTest(TestCase):
"""GET /billboard/post/<uuid>/ flips every unread Brief on that post for
the requesting user to is_unread=False. NVM (banner dismiss client-side
without nav) leaves Briefs untouched — that path doesn't hit this view."""
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user)
self.post = Post.objects.create(owner=self.user)
self.line = Line.objects.create(post=self.post, text="entry one")
def test_get_view_post_flips_owner_unread_brief_to_read(self):
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.assertTrue(b.is_unread)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
b.refresh_from_db()
self.assertFalse(b.is_unread)
def test_get_does_not_flip_other_users_briefs(self):
other = User.objects.create(email="other@test.io")
# Both users have a Brief on this post; only the requesting user's flips
mine = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
theirs = Brief.objects.create(owner=other, post=self.post, line=self.line)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
mine.refresh_from_db()
theirs.refresh_from_db()
self.assertFalse(mine.is_unread)
self.assertTrue(theirs.is_unread)
def test_get_does_not_flip_briefs_on_other_posts(self):
other_post = Post.objects.create(owner=self.user)
other_line = Line.objects.create(post=other_post, text="other")
unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
unrelated.refresh_from_db()
self.assertTrue(unrelated.is_unread)
def test_get_idempotent_for_already_read_brief(self):
already_read = Brief.objects.create(
owner=self.user, post=self.post, line=self.line, is_unread=False,
)
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
already_read.refresh_from_db()
self.assertFalse(already_read.is_unread)
def test_post_request_does_not_mark_read(self):
"""Posting a new Line to view_post (the legacy compose flow) is not
the FYI-read contract — the user is composing, not reviewing. Mark-
read happens only on a GET render of post.html."""
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
self.client.post(
reverse("billboard:view_post", args=[self.post.id]),
data={"text": "appended via POST"},
)
b.refresh_from_db()
self.assertTrue(b.is_unread)

View File

@@ -0,0 +1,125 @@
"""ITs for share-post async-Brief flow (C3.b).
POST /billboard/post/<uuid>/share-post w. Accept: application/json now:
- Adds the recipient to Post.shared_with (if registered, not the sharer)
- Appends a Line to the Post recording the share event
- Spawns a Brief(kind=SHARE_INVITE) for the sharer that JS slide-downs
- Returns JSON {brief: {…}, line_text: ""}; no redirect, no messages
Legacy form-submit (no Accept: application/json) still redirects + flashes
the privacy-safe success message — kept for non-AJAX fallback / older FTs.
"""
from django.test import TestCase
from django.urls import reverse
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import User
class SharePostAsyncTest(TestCase):
def setUp(self):
self.sharer = User.objects.create(email="sharer@test.io")
self.client.force_login(self.sharer)
self.post = Post.objects.create(owner=self.sharer)
def _share_async(self, recipient_email):
return self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": recipient_email},
HTTP_ACCEPT="application/json",
)
def test_async_share_returns_brief_payload(self):
User.objects.create(email="alice@test.io")
response = self._share_async("alice@test.io")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertIn("brief", body)
self.assertIn("line_text", body)
def test_async_share_appends_line_to_post(self):
User.objects.create(email="alice@test.io")
self.assertEqual(self.post.lines.count(), 0)
self._share_async("alice@test.io")
self.assertEqual(self.post.lines.count(), 1)
line = self.post.lines.first()
self.assertIn("alice@test.io", line.text)
def test_async_share_creates_share_invite_brief_for_sharer(self):
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
brief = Brief.objects.get(owner=self.sharer)
self.assertEqual(brief.kind, Brief.KIND_SHARE_INVITE)
self.assertEqual(brief.post, self.post)
self.assertIsNotNone(brief.line)
self.assertTrue(brief.is_unread)
def test_async_share_adds_registered_recipient_to_shared_with(self):
alice = User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
self.assertIn(alice, self.post.shared_with.all())
def test_async_share_unregistered_recipient_still_appends_line_and_brief(self):
"""Privacy: even if the email isn't registered, the sharer gets the
same confirmation Brief + Line. Otherwise the response shape would
leak whether an address is on the system."""
response = self._share_async("ghost@test.io")
self.assertEqual(response.status_code, 200)
self.assertEqual(self.post.lines.count(), 1)
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 1)
def test_async_share_does_not_add_owner_as_recipient(self):
"""Sharer shares w. their own email — no shared_with add, no Line, no
Brief; response carries brief: null so the JS just no-ops."""
response = self._share_async("sharer@test.io")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["brief"], None)
self.assertEqual(self.post.lines.count(), 0)
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 0)
self.assertNotIn(self.sharer, self.post.shared_with.all())
def test_async_share_brief_payload_carries_share_invite_kind(self):
User.objects.create(email="alice@test.io")
body = self._share_async("alice@test.io").json()
self.assertEqual(body["brief"]["kind"], "share_invite")
self.assertIn("alice@test.io", body["line_text"])
def test_async_share_line_text_dedupes_via_timestamp(self):
"""Two consecutive shares of the same email must not collide on the
Line.unique_together(post, text) constraint — the share line should
carry a timestamp suffix."""
User.objects.create(email="alice@test.io")
self._share_async("alice@test.io")
# Second share — should append a second distinct Line, not 500.
response = self._share_async("alice@test.io")
self.assertEqual(response.status_code, 200)
self.assertEqual(self.post.lines.count(), 2)
class SharePostLegacyRedirectTest(TestCase):
"""Legacy form-submit path (no Accept: application/json) is preserved —
redirects + flashes the privacy-safe message + adds shared_with. Existing
FTs that submit the share form via Selenium still work."""
def setUp(self):
self.sharer = User.objects.create(email="sharer@test.io")
self.client.force_login(self.sharer)
self.post = Post.objects.create(owner=self.sharer)
def test_form_submit_still_redirects(self):
User.objects.create(email="alice@test.io")
response = self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": "alice@test.io"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], reverse("billboard:view_post", args=[self.post.id]))
def test_form_submit_still_adds_shared_with(self):
alice = User.objects.create(email="alice@test.io")
self.client.post(
reverse("billboard:share_post", args=[self.post.id]),
data={"recipient": "alice@test.io"},
)
self.assertIn(alice, self.post.shared_with.all())

View File

@@ -8,8 +8,10 @@ from django.http import HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from django.utils import timezone
from apps.billboard.forms import ExistingPostLineForm, LineForm from apps.billboard.forms import ExistingPostLineForm, LineForm
from apps.billboard.models import Post from apps.billboard.models import Brief, Line, Post
from apps.dashboard.views import _PALETTE_DEFS from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room from apps.epic.models import Room
@@ -253,6 +255,14 @@ def view_post(request, post_id):
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect(our_post) return redirect(our_post)
# GET render is the FYI-read contract — flip every unread Brief on this
# post for the requesting user. POST (compose) is intentionally excluded
# because the user is authoring, not reviewing the new Line.
if request.user.is_authenticated:
Brief.objects.filter(
owner=request.user, post=our_post, is_unread=True,
).update(is_unread=False)
return render(request, "apps/billboard/post.html", {"post": our_post, "form": form}) return render(request, "apps/billboard/post.html", {"post": our_post, "form": form})
@@ -267,13 +277,58 @@ def my_posts(request, user_id):
def share_post(request, post_id): def share_post(request, post_id):
our_post = Post.objects.get(id=post_id) our_post = Post.objects.get(id=post_id)
is_ajax = "application/json" in request.headers.get("Accept", "")
recipient_email = request.POST.get("recipient", "")
recipient = None
try: try:
recipient = User.objects.get(email=request.POST["recipient"]) recipient = User.objects.get(email=recipient_email)
if recipient == request.user:
return redirect(our_post)
our_post.shared_with.add(recipient)
except User.DoesNotExist: except User.DoesNotExist:
pass pass
# Sharer-tries-to-share-with-themselves: silent no-op (existing behavior).
if recipient is not None and recipient == request.user:
if is_ajax:
return JsonResponse({"brief": None, "line_text": ""})
return redirect(our_post)
if recipient is not None:
our_post.shared_with.add(recipient)
# Always append a Line + spawn a Brief for the sharer — privacy: the
# response shape mustn't leak whether the email is on the system. Line
# text carries an isoformat timestamp w/ microseconds so two rapid
# shares of the same email don't collide on the
# Line.unique_together(post, text) constraint.
line_text = (
f"Shared with {recipient_email} at {timezone.now().isoformat()}"
)
line = Line.objects.create(post=our_post, text=line_text)
brief = None
if request.user.is_authenticated:
brief = Brief.objects.create(
owner=request.user,
post=our_post,
line=line,
kind=Brief.KIND_SHARE_INVITE,
title="Invite sent",
)
if is_ajax:
# recipient_display is populated only when the address resolves to a
# registered User — same evidence the server-rendered .post-recipient
# list exposes; doesn't widen the privacy surface beyond what the
# post detail page already shows publicly.
recipient_display = None
if recipient is not None:
recipient_display = recipient.username or recipient.email
return JsonResponse({
"brief": brief.to_banner_dict() if brief is not None else None,
"line_text": line_text,
"recipient_display": recipient_display,
})
messages.success(request, "An invite has been sent if that address is registered.") messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_post) return redirect(our_post)

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'; 'use strict';
function showBanner(note) { function showBanner(brief) {
if (!note) return; if (!brief) return;
const earned = new Date(note.earned_at); const created = new Date(brief.created_at);
const dateStr = earned.toLocaleDateString(undefined, { const dateStr = created.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', year: 'numeric', month: 'short', day: 'numeric',
}); });
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.className = 'note-banner'; 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 = banner.innerHTML =
'<div class="note-banner__body">' + '<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(note.title) + '</p>' + '<p class="note-banner__title">' + _esc(brief.title) + '</p>' +
'<p class="note-banner__description">' + _esc(note.description) + '</p>' + '<p class="note-banner__description">' + _esc(brief.line_text) + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' + '<time class="note-banner__timestamp" datetime="' + _esc(brief.created_at) + '">' +
dateStr + dateStr +
'</time>' + '</time>' +
'</div>' + '</div>' +
'<div class="note-banner__image"></div>' + squareEl +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' + '<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.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove(); banner.remove();
@@ -36,7 +53,7 @@ const Note = (() => {
} }
function handleSaveResponse(data) { function handleSaveResponse(data) {
showBanner(data && data.note); showBanner(data && data.brief);
} }
function _esc(str) { function _esc(str) {
@@ -47,3 +64,6 @@ const Note = (() => {
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse }; 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", 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() data = self._post().json()
self.assertIn("note", data) self.assertIn("brief", data)
recog = data["note"] brief = data["brief"]
self.assertEqual(recog["slug"], "stargazer") self.assertEqual(brief["kind"], "note_unlock")
self.assertIn("title", recog) self.assertEqual(brief["title"], "Stargazer")
self.assertIn("description", recog) self.assertIn("Stargazer", brief["line_text"])
self.assertIn("earned_at", recog) 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): def test_first_save_creates_note_in_db(self):
self._post() self._post()
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1) 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() self._post()
data = self._post().json() data = self._post().json()
self.assertIsNone(data["note"]) self.assertIsNone(data["brief"])
def test_second_save_does_not_create_duplicate_note(self): def test_second_save_does_not_create_duplicate_note(self):
self._post() self._post()
@@ -297,12 +299,12 @@ class SkySaveNoteTest(TestCase):
def test_save_with_empty_chart_data_does_not_grant_note(self): def test_save_with_empty_chart_data_does_not_grant_note(self):
data = self._post(chart_data={}).json() 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) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_note(self): def test_save_with_null_chart_data_does_not_grant_note(self):
data = self._post(chart_data=None).json() 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) self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)

View File

@@ -362,18 +362,13 @@ def sky_save(request):
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data', 'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
]) ])
note_payload = None brief_payload = None
if user.sky_chart_data: if user.sky_chart_data:
note, created = Note.grant_if_new(user, "stargazer") note, created, brief = Note.grant_if_new(user, "stargazer")
if created: if created and brief is not None:
note_payload = { brief_payload = brief.to_banner_dict()
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": note.earned_at.isoformat(),
}
return JsonResponse({"saved": True, "note": note_payload}) return JsonResponse({"saved": True, "brief": brief_payload})
@login_required(login_url="/") @login_required(login_url="/")

View File

@@ -237,8 +237,41 @@ class Note(models.Model):
@classmethod @classmethod
def grant_if_new(cls, user, slug): 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 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, user=user, slug=slug,
defaults={"earned_at": timezone.now()}, 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

View File

@@ -308,14 +308,14 @@ class NoteModelTest(TestCase):
self.assertIn("earner@test.io", s) self.assertIn("earner@test.io", s)
def test_grant_if_new_creates_on_first_call(self): 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.assertTrue(created)
self.assertEqual(recog.slug, "stargazer") self.assertEqual(recog.slug, "stargazer")
self.assertIsNotNone(recog.earned_at) self.assertIsNotNone(recog.earned_at)
def test_grant_if_new_is_idempotent(self): def test_grant_if_new_is_idempotent(self):
Note.grant_if_new(self.user, "stargazer") 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.assertFalse(created)
self.assertEqual(Note.objects.count(), 1) self.assertEqual(Note.objects.count(), 1)
@@ -324,6 +324,6 @@ class NoteModelTest(TestCase):
user=self.user, slug="stargazer", user=self.user, slug="stargazer",
earned_at=timezone.now(), palette="palette-bardo", 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.assertFalse(created)
self.assertEqual(recog.palette, "palette-bardo") self.assertEqual(recog.palette, "palette-bardo")

View File

@@ -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)

View File

@@ -150,16 +150,27 @@ class StargazerNoteFromDashboardTest(FunctionalTest):
) )
banner.find_element(By.CSS_SELECTOR, ".note-banner__description") 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__timestamp")
banner.find_element(By.CSS_SELECTOR, ".note-banner__image") # Per the Brief sprint, .note-banner__image is now a clickable <a> 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 banner.find_element(By.CSS_SELECTOR, ".btn.btn-cancel") # NVM
fyi = banner.find_element(By.CSS_SELECTOR, ".btn.btn-info") # FYI 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/<uuid>/) — the GET render is the mark-read contract.
fyi.click() fyi.click()
self.wait_for( 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 # Note page: one Stargazer item
item = self.wait_for( item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item") lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-list .note-item")

View File

@@ -38,7 +38,7 @@ class NewVisitorTest(FunctionalTest):
edith_post_url = self.browser.current_url edith_post_url = self.browser.current_url
self.assertRegex( self.assertRegex(
edith_post_url, edith_post_url,
r'/dashboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$', r'/billboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
) )
self.browser.delete_all_cookies() self.browser.delete_all_cookies()
@@ -54,7 +54,7 @@ class NewVisitorTest(FunctionalTest):
francis_post_url = self.browser.current_url francis_post_url = self.browser.current_url
self.assertRegex( self.assertRegex(
francis_post_url, francis_post_url,
r'/dashboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$', r'/billboard/post/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
) )
self.assertNotEqual(francis_post_url, edith_post_url) self.assertNotEqual(francis_post_url, edith_post_url)

View File

@@ -59,7 +59,9 @@ class SharingTest(FunctionalTest):
self.browser = disco_browser self.browser = disco_browser
self.browser.refresh() self.browser.refresh()
post_page.wait_for_row_in_post_table("At your command, Disco King", 2) # Line numbering: 1) "Send help" 2) "Shared with alice@test.io …"
# (auto-appended by share_post in C3.b) 3) Alice's reply.
post_page.wait_for_row_in_post_table("At your command, Disco King", 3)
class PostAccessTest(FunctionalTest): class PostAccessTest(FunctionalTest):
def test_stranger_cannot_access_owned_post(self): def test_stranger_cannot_access_owned_post(self):

View File

@@ -1,30 +1,35 @@
// ── NoteSpec.js ─────────────────────────────────────────────────────────────── // ── 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(): // DOM contract assumed by showBanner():
// Any <h2> in the document — banner is inserted immediately after it. // Any <h2> in the document — banner is inserted immediately after it.
// If absent, banner is prepended to <body>. // If absent, banner is prepended to <body>.
// //
// API under test: // API under test:
// Note.showBanner(note) // Brief.showBanner(brief)
// note = { slug, title, description, earned_at } → inject .note-banner // brief = { id, kind, title, line_text, post_url, square_url, created_at }
// note = nullno-op // inject .note-banner
// brief = null → no-op
// //
// Note.handleSaveResponse(data) // Brief.handleSaveResponse(data)
// data = { saved: true, note: {...} } → delegates to showBanner // data = { saved: true, brief: {...} } → delegates to showBanner
// data = { saved: true, note: null } → no-op // data = { saved: true, brief: null } → no-op
// //
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const SAMPLE_NOTE = { const SAMPLE_BRIEF = {
slug: 'stargazer', id: '00000000-0000-0000-0000-000000000001',
title: 'Stargazer', kind: 'note_unlock',
description: 'You saved your first personal sky chart.', title: 'Stargazer',
earned_at: '2026-04-22T02:00:00+00:00', 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; let fixture;
@@ -43,69 +48,72 @@ describe('Note.showBanner', () => {
// ── T1 ── null → no banner ──────────────────────────────────────────────── // ── T1 ── null → no banner ────────────────────────────────────────────────
it('T1: showBanner(null) does not inject a banner', () => { it('T1: showBanner(null) does not inject a banner', () => {
Note.showBanner(null); Brief.showBanner(null);
expect(document.querySelector('.note-banner')).toBeNull(); 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', () => { it('T2: showBanner(brief) injects .note-banner into the document', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
expect(document.querySelector('.note-banner')).not.toBeNull(); expect(document.querySelector('.note-banner')).not.toBeNull();
}); });
// ── T3 ── title ─────────────────────────────────────────────────────────── // ── T3 ── title ───────────────────────────────────────────────────────────
it('T3: banner .note-banner__title contains note.title', () => { it('T3: banner .note-banner__title contains brief.title', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
const el = document.querySelector('.note-banner__title'); const el = document.querySelector('.note-banner__title');
expect(el).not.toBeNull(); expect(el).not.toBeNull();
expect(el.textContent).toContain('Stargazer'); expect(el.textContent).toContain('Stargazer');
}); });
// ── T4 ── description ───────────────────────────────────────────────────── // ── T4 ── description (line_text) ─────────────────────────────────────────
it('T4: banner .note-banner__description contains note.description', () => { it('T4: banner .note-banner__description carries brief.line_text', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
const el = document.querySelector('.note-banner__description'); const el = document.querySelector('.note-banner__description');
expect(el).not.toBeNull(); 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 ─────────────────────────────────────────────────────── // ── T5 ── timestamp ───────────────────────────────────────────────────────
it('T5: banner has a .note-banner__timestamp element', () => { 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(); 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', () => { it('T6: banner .note-banner__image is a clickable <a> when brief.square_url is set', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
expect(document.querySelector('.note-banner__image')).not.toBeNull(); 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 ────────────────────────────────────────────────────── // ── T7 ── NVM button ──────────────────────────────────────────────────────
it('T7: banner has a .btn.btn-cancel 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(); 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/', () => { it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
const fyi = document.querySelector('.note-banner .btn.btn-info'); const fyi = document.querySelector('.note-banner .btn.btn-info');
expect(fyi).not.toBeNull(); expect(fyi).not.toBeNull();
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/'); expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/');
}); });
// ── T9 ── NVM dismissal ─────────────────────────────────────────────────── // ── T9 ── NVM dismissal ───────────────────────────────────────────────────
it('T9: clicking the NVM button removes the banner from the DOM', () => { 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(); document.querySelector('.note-banner .btn.btn-cancel').click();
expect(document.querySelector('.note-banner')).toBeNull(); expect(document.querySelector('.note-banner')).toBeNull();
}); });
@@ -113,30 +121,30 @@ describe('Note.showBanner', () => {
// ── T10 ── placement after h2 ───────────────────────────────────────────── // ── T10 ── placement after h2 ─────────────────────────────────────────────
it('T10: banner is inserted immediately after the first h2 in the document', () => { 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'); const h2 = fixture.querySelector('h2');
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue(); expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
}); });
}); });
describe('Note.handleSaveResponse', () => { describe('Brief.handleSaveResponse', () => {
afterEach(() => { afterEach(() => {
document.querySelectorAll('.note-banner').forEach(b => b.remove()); 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', () => { it('T11: handleSaveResponse shows banner when data.brief is present', () => {
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE }); Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF });
expect(document.querySelector('.note-banner')).not.toBeNull(); 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', () => { it('T12: handleSaveResponse does not show banner when data.brief is null', () => {
Note.handleSaveResponse({ saved: true, note: null }); Brief.handleSaveResponse({ saved: true, brief: null });
expect(document.querySelector('.note-banner')).toBeNull(); expect(document.querySelector('.note-banner')).toBeNull();
}); });

View File

@@ -40,6 +40,18 @@
.note-banner__fyi { .note-banner__fyi {
flex-shrink: 0; flex-shrink: 0;
} }
// Transient message-banner variants (magic-link confirmation, errors).
// No DB Brief row — just inherits the Gaussian-glass shell from the
// .note-banner base & shifts the border colour for level=error/warning.
&.note-banner--message .note-banner__description {
opacity: 1; // message body is the whole content; full opacity
}
&.note-banner--error,
&.note-banner--warning {
border-color: rgba(var(--priRd), 0.6);
}
} }
// ── Notes page ───────────────────────────────────────────────────────────── // ── Notes page ─────────────────────────────────────────────────────────────

View File

@@ -1,30 +1,35 @@
// ── NoteSpec.js ─────────────────────────────────────────────────────────────── // ── 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(): // DOM contract assumed by showBanner():
// Any <h2> in the document — banner is inserted immediately after it. // Any <h2> in the document — banner is inserted immediately after it.
// If absent, banner is prepended to <body>. // If absent, banner is prepended to <body>.
// //
// API under test: // API under test:
// Note.showBanner(note) // Brief.showBanner(brief)
// note = { slug, title, description, earned_at } → inject .note-banner // brief = { id, kind, title, line_text, post_url, square_url, created_at }
// note = nullno-op // inject .note-banner
// brief = null → no-op
// //
// Note.handleSaveResponse(data) // Brief.handleSaveResponse(data)
// data = { saved: true, note: {...} } → delegates to showBanner // data = { saved: true, brief: {...} } → delegates to showBanner
// data = { saved: true, note: null } → no-op // data = { saved: true, brief: null } → no-op
// //
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
const SAMPLE_NOTE = { const SAMPLE_BRIEF = {
slug: 'stargazer', id: '00000000-0000-0000-0000-000000000001',
title: 'Stargazer', kind: 'note_unlock',
description: 'You saved your first personal sky chart.', title: 'Stargazer',
earned_at: '2026-04-22T02:00:00+00:00', 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; let fixture;
@@ -43,69 +48,72 @@ describe('Note.showBanner', () => {
// ── T1 ── null → no banner ──────────────────────────────────────────────── // ── T1 ── null → no banner ────────────────────────────────────────────────
it('T1: showBanner(null) does not inject a banner', () => { it('T1: showBanner(null) does not inject a banner', () => {
Note.showBanner(null); Brief.showBanner(null);
expect(document.querySelector('.note-banner')).toBeNull(); 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', () => { it('T2: showBanner(brief) injects .note-banner into the document', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
expect(document.querySelector('.note-banner')).not.toBeNull(); expect(document.querySelector('.note-banner')).not.toBeNull();
}); });
// ── T3 ── title ─────────────────────────────────────────────────────────── // ── T3 ── title ───────────────────────────────────────────────────────────
it('T3: banner .note-banner__title contains note.title', () => { it('T3: banner .note-banner__title contains brief.title', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
const el = document.querySelector('.note-banner__title'); const el = document.querySelector('.note-banner__title');
expect(el).not.toBeNull(); expect(el).not.toBeNull();
expect(el.textContent).toContain('Stargazer'); expect(el.textContent).toContain('Stargazer');
}); });
// ── T4 ── description ───────────────────────────────────────────────────── // ── T4 ── description (line_text) ─────────────────────────────────────────
it('T4: banner .note-banner__description contains note.description', () => { it('T4: banner .note-banner__description carries brief.line_text', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
const el = document.querySelector('.note-banner__description'); const el = document.querySelector('.note-banner__description');
expect(el).not.toBeNull(); 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 ─────────────────────────────────────────────────────── // ── T5 ── timestamp ───────────────────────────────────────────────────────
it('T5: banner has a .note-banner__timestamp element', () => { 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(); 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', () => { it('T6: banner .note-banner__image is a clickable <a> when brief.square_url is set', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
expect(document.querySelector('.note-banner__image')).not.toBeNull(); 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 ────────────────────────────────────────────────────── // ── T7 ── NVM button ──────────────────────────────────────────────────────
it('T7: banner has a .btn.btn-cancel 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(); 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/', () => { it('T8: banner FYI .btn.btn-info link points to brief.post_url', () => {
Note.showBanner(SAMPLE_NOTE); Brief.showBanner(SAMPLE_BRIEF);
const fyi = document.querySelector('.note-banner .btn.btn-info'); const fyi = document.querySelector('.note-banner .btn.btn-info');
expect(fyi).not.toBeNull(); expect(fyi).not.toBeNull();
expect(fyi.getAttribute('href')).toBe('/billboard/my-notes/'); expect(fyi.getAttribute('href')).toBe('/billboard/post/abc/');
}); });
// ── T9 ── NVM dismissal ─────────────────────────────────────────────────── // ── T9 ── NVM dismissal ───────────────────────────────────────────────────
it('T9: clicking the NVM button removes the banner from the DOM', () => { 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(); document.querySelector('.note-banner .btn.btn-cancel').click();
expect(document.querySelector('.note-banner')).toBeNull(); expect(document.querySelector('.note-banner')).toBeNull();
}); });
@@ -113,30 +121,30 @@ describe('Note.showBanner', () => {
// ── T10 ── placement after h2 ───────────────────────────────────────────── // ── T10 ── placement after h2 ─────────────────────────────────────────────
it('T10: banner is inserted immediately after the first h2 in the document', () => { 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'); const h2 = fixture.querySelector('h2');
expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue(); expect(h2.nextElementSibling.classList.contains('note-banner')).toBeTrue();
}); });
}); });
describe('Note.handleSaveResponse', () => { describe('Brief.handleSaveResponse', () => {
afterEach(() => { afterEach(() => {
document.querySelectorAll('.note-banner').forEach(b => b.remove()); 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', () => { it('T11: handleSaveResponse shows banner when data.brief is present', () => {
Note.handleSaveResponse({ saved: true, note: SAMPLE_NOTE }); Brief.handleSaveResponse({ saved: true, brief: SAMPLE_BRIEF });
expect(document.querySelector('.note-banner')).not.toBeNull(); 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', () => { it('T12: handleSaveResponse does not show banner when data.brief is null', () => {
Note.handleSaveResponse({ saved: true, note: null }); Brief.handleSaveResponse({ saved: true, brief: null });
expect(document.querySelector('.note-banner')).toBeNull(); expect(document.querySelector('.note-banner')).toBeNull();
}); });

View File

@@ -25,7 +25,7 @@
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-6"> <div class="col-lg-6">
<form method="POST" action="{% url "billboard:share_post" post.id %}"> <form id="id_share_form" method="POST" action="{% url "billboard:share_post" post.id %}">
{% csrf_token %} {% csrf_token %}
<input <input
id="id_recipient" id="id_recipient"
@@ -44,9 +44,11 @@
<button type="submit" class="btn btn-primary">Share</button> <button type="submit" class="btn btn-primary">Share</button>
</form> </form>
<small>Post shared with: <small>Post shared with:
{% for user in post.shared_with.all %} <span id="id_post_recipients">
<span class="post-recipient">{{ user|display_name }}</span> {% for user in post.shared_with.all %}
{% endfor %} <span class="post-recipient">{{ user|display_name }}</span>
{% endfor %}
</span>
</small> </small>
</div> </div>
</div> </div>
@@ -54,4 +56,64 @@
{% block scripts %} {% block scripts %}
{% include "apps/dashboard/_partials/_scripts.html" %} {% include "apps/dashboard/_partials/_scripts.html" %}
<script>
// Async share: intercepts the share form, POSTs w. Accept:application/json,
// then slide-downs the SHARE_INVITE Brief banner under the navbar h2 + appends
// the freshly-recorded Line into #id_post_table. No page reload — the legacy
// alert-success flash is gone.
(function () {
'use strict';
var form = document.getElementById('id_share_form');
var input = document.getElementById('id_recipient');
var table = document.getElementById('id_post_table');
var recipientsBox = document.getElementById('id_post_recipients');
if (!form || !input || !table) return;
function _csrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _appendLine(text) {
var n = table.querySelectorAll('tr').length + 1;
var tr = document.createElement('tr');
var td = document.createElement('td');
td.textContent = n + '. ' + text;
tr.appendChild(td);
table.appendChild(tr);
}
form.addEventListener('submit', function (e) {
e.preventDefault();
var fd = new FormData(form);
fetch(form.action, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Accept': 'application/json',
'X-CSRFToken': _csrf(),
},
body: fd,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (data) {
if (data.line_text) _appendLine(data.line_text);
if (window.Brief && data.brief) Brief.showBanner(data.brief);
if (data.recipient_display && recipientsBox) {
var span = document.createElement('span');
span.className = 'post-recipient';
span.textContent = data.recipient_display;
recipientsBox.appendChild(document.createTextNode(' '));
recipientsBox.appendChild(span);
}
input.value = '';
})
.catch(function () {
// No-op for now — the privacy-safe response shape means
// even an unregistered recipient is a 200 w. brief data;
// a true error path (5xx) silently swallows.
});
});
}());
</script>
{% endblock scripts %} {% endblock scripts %}

View File

@@ -367,7 +367,7 @@
formWrap.style.display = 'none'; formWrap.style.display = 'none';
svgEl.style.display = ''; svgEl.style.display = '';
SkyWheel.preload().then(() => SkyWheel.draw(svgEl, _lastChartData)); SkyWheel.preload().then(() => SkyWheel.draw(svgEl, _lastChartData));
Note.handleSaveResponse(data); Brief.handleSaveResponse(data);
}) })
.catch(err => { .catch(err => {
setStatus(`Save failed: ${err.message}`, 'error'); setStatus(`Save failed: ${err.message}`, 'error');

View File

@@ -315,7 +315,7 @@
}) })
.then(data => { .then(data => {
setStatus('Sky saved!'); setStatus('Sky saved!');
Note.handleSaveResponse(data); Brief.handleSaveResponse(data);
_activateSavedState(); _activateSavedState();
}) })
.catch(err => { .catch(err => {

View File

@@ -30,17 +30,18 @@
</div> </div>
{% if messages %} {% if messages %}
<div class="row"> {% for message in messages %}
<div class="col-md-12"> {# Transient Brief-styled banner — no DB row, no FYI/square. #}
{% for message in messages %} {# Slides in under the navbar h2 w. the same Gaussian-glass #}
{% if message.level_tag == 'success' %} {# look as the Brief notification banner; NVM dismisses. #}
<div class="alert alert-success">{{ message }}</div> <div class="note-banner note-banner--message note-banner--{{ message.level_tag }}">
{% else %} <div class="note-banner__body">
<div class="alert alert-warning">{{ message }}</div> <p class="note-banner__description">{{ message }}</p>
{% endif %} </div>
{% endfor %} <button type="button" class="btn btn-cancel note-banner__nvm"
onclick="this.parentElement.remove()">NVM</button>
</div> </div>
</div> {% endfor %}
{% endif %} {% endif %}
{% block content %} {% block content %}