applet rows: 3-col grid <title> | <body> | <ts> mirroring post.html's .post-line shape — _my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item all gain a .applet-list-entry.row-3col w. <a class="row-title"> (clickable, 35c/32+... server-side truncated via new lyric_extras.truncate_title filter) + <span class="row-body"> (most-recent activity excerpt, dimmed 0.6 opacity, CSS-text-overflow: ellipsis clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + <time class="row-ts"> (relative_ts formatted, same minmax(3rem,auto) rightward column allocation post.html's .post-line-time uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid minmax(4rem,auto) 1fr minmax(3rem,auto) lifted from .post-line's template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — _recent_posts annotates each Post w. latest_line (Line FK ordered by -id, None for empty Note-unlock posts); _recent_buds select_related('to_user__active_title') warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); _recent_notes attaches description from _NOTE_META per slug; annotate_latest_event(rooms) helper added to apps.epic.utils (next to rooms_for_user) — attaches room.latest_event per Room w. one .events.order_by('-timestamp').first() per item, used by _billboard_context for my_rooms (My Scrolls applet) AND by apps.gameboard.views.gameboard + toggle_game_applets for my_games (My Games applet), keeping the My Scrolls + My Games shapes symmetric; _billboard_context.my_rooms = annotate_latest_event(...) swaps rooms_for_user(...).order_by("-created_at") materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new truncate_title filter is the existing _truncate_post_title view helper hoisted into the template namespace (literal ... past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on <span class="row-body"> purely to CSS text-overflow: ellipsis per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-12 23:06:55 -04:00
parent eccb84f92b
commit c08dd145c3
12 changed files with 227 additions and 26 deletions

View File

@@ -152,6 +152,84 @@ class BillboardViewTest(TestCase):
response = self.client.get("/billboard/")
self.assertEqual(list(response.context["recent_notes"]), [])
# ── 3-column applet row contract ──────────────────────────────────
# Each applet's list item renders `<title> | <body> | <ts>` —
# title + body wrapped in `.row-title` / `.row-body`, timestamp
# in a `<time class="row-ts">` element. The body shows the
# most-recent activity (post line / room event / note description /
# bud's active title). Right column carries `relative_ts` text.
def test_my_posts_applet_row_shows_latest_line_and_ts(self):
from apps.billboard.models import Line, Post
post = Post.objects.create(
owner=self.user, kind=Post.KIND_USER_POST, title="StampedPost",
)
Line.objects.create(post=post, text="first line text", author=self.user)
Line.objects.create(post=post, text="latest line text", author=self.user)
response = self.client.get("/billboard/")
body = response.content.decode()
# Title preserved
self.assertIn("StampedPost", body)
# Latest line surfaces in the row body, not the older line
self.assertIn("latest line text", body)
# A <time> ts element renders on the row
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_buds_applet_row_shows_active_title_and_since_ts(self):
bud = User.objects.create(email="alice@test.io", username="alice")
note = Note.objects.create(
user=bud, slug="stargazer", earned_at=timezone.now(),
)
bud.active_title = note
bud.save()
self.user.buds.add(bud)
response = self.client.get("/billboard/")
body = response.content.decode()
# Bud handle shows; the active title surfaces as the row body;
# the ts column carries a "since " prefix unique to buds
self.assertIn("alice", body)
self.assertIn("the Stargazer", body)
self.assertRegex(body, r'class="[^"]*row-ts[^"]*"[^>]*>\s*since\s+')
def test_my_buds_applet_row_no_active_title_no_body_or_ts(self):
bud = User.objects.create(email="alice@test.io", username="alice")
# No active_title donned
self.user.buds.add(bud)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("alice", body)
# Without an active title we don't render a "since" line
self.assertNotRegex(body, r'class="[^"]*row-ts[^"]*"[^>]*>\s*since\s+')
def test_my_notes_applet_row_shows_description_and_earned_at(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("Stargazer", body)
# Description prefix from _NOTE_META — full string is 40 chars and
# truncates to 32+"..." via the `truncate_title` filter, so assert
# against the head only ("You saved your first personal" is 30 chars,
# comfortably inside the truncation window).
self.assertIn("You saved your first personal", body)
# ts column present
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
def test_my_scrolls_applet_row_shows_latest_event_and_ts(self):
room = Room.objects.create(name="StampedRoom", owner=self.user)
record(
room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin-on-a-String", renewal_days=7,
)
response = self.client.get("/billboard/")
body = response.content.decode()
self.assertIn("StampedRoom", body)
# The slot-fill prose mentions the token; we just check the room's
# scroll row carries a <time> + some body content (event prose)
self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')
class SaveScrollPositionViewTest(TestCase):
def setUp(self):