diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 8d12776..89a56c9 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -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 ` | <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): diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 8d3ed14..3c3a11a 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -14,14 +14,18 @@ from apps.billboard.models import Brief, Line, Post from apps.dashboard.views import _PALETTE_DEFS from apps.drama.models import GameEvent, Note, ScrollPosition from apps.epic.models import Room -from apps.epic.utils import rooms_for_user +from apps.epic.utils import annotate_latest_event, rooms_for_user from apps.lyric.models import User, get_or_create_adman _PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS} def _recent_posts(user, limit=3): - return ( + """Most-recently-active Posts the user owns or is shared on. Attaches + `latest_line` to each Post (Line or None) so the My Posts applet row + can render its 3-col `<title> | <latest line text> | <ts>` shape + without an extra template-side query.""" + posts = list( Post .objects .filter(Q(owner=user) | Q(shared_with=user)) @@ -29,27 +33,37 @@ def _recent_posts(user, limit=3): .order_by('-last_line') .distinct()[:limit] ) + for p in posts: + p.latest_line = p.lines.order_by("-id").first() + return posts def _recent_buds(user, limit=3): """Most-recently-added buds, newest first. M2M has no explicit through - model, so we sort the auto-through table by its monotonic `id`.""" + model, so we sort the auto-through table by its monotonic `id`. The + `select_related('to_user__active_title')` chain warms up the active + title FK for the My Buds applet row's middle column.""" through = User.buds.through # type: ignore[attr-defined] rows = ( through.objects .filter(from_user=user) - .select_related("to_user") + .select_related("to_user__active_title") .order_by("-id")[:limit] ) return [r.to_user for r in rows] def _recent_notes(user, limit=3): - return user.notes.order_by("-earned_at")[:limit] + """Most-recently-earned Notes. Attaches `description` from _NOTE_META + so the My Notes applet row can render `<title> | <description> | <ts>`.""" + notes = list(user.notes.order_by("-earned_at")[:limit]) + for n in notes: + n.description = _NOTE_META.get(n.slug, {}).get("description", "") + return notes def _billboard_context(user): - my_rooms = rooms_for_user(user).order_by("-created_at") + my_rooms = annotate_latest_event(rooms_for_user(user).order_by("-created_at")) recent_room = ( Room.objects.filter( diff --git a/src/apps/epic/utils.py b/src/apps/epic/utils.py index f2255d4..aa92979 100644 --- a/src/apps/epic/utils.py +++ b/src/apps/epic/utils.py @@ -98,3 +98,14 @@ def rooms_for_user(user): Q(gate_slots__gamer=user) | Q(invites__invitee_email=user.email, invites__status=RoomInvite.PENDING) ).distinct() + + +def annotate_latest_event(rooms): + """Attach `latest_event` (GameEvent or None) to each Room in the iterable. + Materialises the queryset to a list. Shared by the My Scrolls (billboard) + + My Games (gameboard) applet rows so they render the same 3-col + `<title> | <latest event prose> | <ts>` shape from one helper.""" + rooms = list(rooms) + for r in rooms: + r.latest_event = r.events.order_by("-timestamp").first() + return rooms diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index d2a6b1a..12fef24 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -45,6 +45,25 @@ class GameboardViewTest(TestCase): game_items = self.parsed.cssselect("#id_applet_my_games .game-item") self.assertEqual(len(game_items), 0) + def test_my_games_row_shows_latest_event_prose_and_ts(self): + from apps.drama.models import GameEvent, record + 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("/gameboard/") + body = response.content.decode() + self.assertIn("StampedRoom", body) + # Row carries a ts cell from the recorded event + self.assertRegex( + body, + r'#id_applet_my_games|class="[^"]*row-ts'.replace("#", ""), + ) + # A .row-body cell carries some event prose + self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts') + def test_game_kit_has_coin_on_a_string(self): [_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string") diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 31a0218..5e7e718 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -18,7 +18,7 @@ def _annotate_deck_in_use(decks, user): deck.in_use_room_name = active.get(deck.pk) return decks from apps.epic.models import DeckVariant, Room, TableSeat -from apps.epic.utils import rooms_for_user +from apps.epic.utils import annotate_latest_event, rooms_for_user from apps.lyric.models import Token @@ -49,7 +49,7 @@ def gameboard(request): "free_count": len(free_tokens), "applets": applet_context(request.user, "gameboard"), "page_class": "page-gameboard", - "my_games": rooms_for_user(request.user), + "my_games": annotate_latest_event(rooms_for_user(request.user)), } ) @@ -71,7 +71,7 @@ def toggle_game_applets(request): "deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user), "free_tokens": free_tokens, "free_count": len(free_tokens), - "my_games": rooms_for_user(request.user), + "my_games": annotate_latest_event(rooms_for_user(request.user)), }) return redirect("gameboard") diff --git a/src/apps/lyric/templatetags/lyric_extras.py b/src/apps/lyric/templatetags/lyric_extras.py index d37e0f2..2f6f836 100644 --- a/src/apps/lyric/templatetags/lyric_extras.py +++ b/src/apps/lyric/templatetags/lyric_extras.py @@ -54,6 +54,20 @@ def display_name(user): return truncate_email(user.email) +@register.filter +def truncate_title(text, length=35): + """`<length>` chars verbatim, or `<length-3>` chars + literal `...` + past that — mirrors `billboard.views._truncate_post_title` so applet + list rows + new-Post titling share one truncation rule. Falls back + to empty string on None for guard-free template use.""" + if text is None: + return "" + text = str(text) + if len(text) <= length: + return text + return text[: length - 3] + "..." + + @register.filter def at_handle(user): """`@username` when the user has set one; falls back to the truncated diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index f2d3058..dea9a0f 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -37,6 +37,39 @@ margin: 0; } +// 3-col applet row — `<title> | <body> | <ts>`. Mirrors post.html's +// `.post-line` grid (`minmax(4rem,auto) 1fr minmax(3rem,auto)`) so the +// rightward ts column lines up across post.html, scroll.html, and every +// applet list. Title gets the project-wide 35char/32+... truncation via +// the `truncate_title` template filter; body is dimmed to 0.6. +.applet-list-entry.row-3col { + display: grid; + grid-template-columns: minmax(4rem, auto) 1fr minmax(3rem, auto); + align-items: baseline; + gap: 0.5rem; + + .row-title { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .row-body { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.6; + } + + .row-ts { + font-size: 0.75rem; + opacity: 0.5; + text-align: right; + white-space: nowrap; + } +} + .applet-list-entry { padding: 0.4rem 0; diff --git a/src/templates/apps/billboard/_partials/_my_buds_applet_item.html b/src/templates/apps/billboard/_partials/_my_buds_applet_item.html index 35718ee..c1183fa 100644 --- a/src/templates/apps/billboard/_partials/_my_buds_applet_item.html +++ b/src/templates/apps/billboard/_partials/_my_buds_applet_item.html @@ -1,7 +1,14 @@ {% load lyric_extras %} -{# Recent-bud row for the My Buds in-grid applet. `item` is a User. #} -{# Mirrors the dedicated billbuds page item: just a name chip, no link #} -{# (per-bud surface doesn't exist yet — coming in the 3-col sprint). #} -<li class="applet-list-entry bud-entry" data-bud-id="{{ item.id }}"> - <span class="bud-name">{{ item|display_name }}</span> +{# My Buds applet row — bud handle | the <active title> | since <ts>. #} +{# `item` is a User w. `active_title` select_related'd. Mirrors the #} +{# dedicated billbuds page item shape (.bud-entry / .bud-name) so the #} +{# duplicate-flash add-flow CSS keeps working in this surface too. #} +<li class="applet-list-entry bud-entry row-3col" data-bud-id="{{ item.id }}"> + {# No per-bud landing page yet — the handle is a plain span until #} + {# the 3-col bud surface ships (next sprint). #} + <span class="row-title bud-name">{{ item|at_handle }}</span> + {% if item.active_title %} + <span class="row-body">the {{ item.active_title_display }}</span> + <time class="row-ts" datetime="{{ item.active_title.earned_at|date:'c' }}">since {{ item.active_title.earned_at|relative_ts }}</time> + {% endif %} </li> diff --git a/src/templates/apps/billboard/_partials/_my_notes_item.html b/src/templates/apps/billboard/_partials/_my_notes_item.html index e005e68..592cf92 100644 --- a/src/templates/apps/billboard/_partials/_my_notes_item.html +++ b/src/templates/apps/billboard/_partials/_my_notes_item.html @@ -1,4 +1,11 @@ -{# Recent-note row for the My Notes in-grid applet. `item` is a Note. #} -<li class="applet-list-entry"> - <a href="{% url 'billboard:my_notes' %}">{{ item.display_name }}</a> +{% load lyric_extras %} +{# My Notes applet row — note title | description | earned_at. #} +{# `item` is a Note w. `description` attached by `_recent_notes` (lookup #} +{# into apps.billboard.views._NOTE_META). #} +<li class="applet-list-entry row-3col"> + <a href="{% url 'billboard:my_notes' %}" class="row-title">{{ item.display_name|truncate_title }}</a> + {% if item.description %} + <span class="row-body">{{ item.description|striptags }}</span> + {% endif %} + <time class="row-ts" datetime="{{ item.earned_at|date:'c' }}">{{ item.earned_at|relative_ts }}</time> </li> diff --git a/src/templates/apps/billboard/_partials/_my_posts_applet_item.html b/src/templates/apps/billboard/_partials/_my_posts_applet_item.html index 89326ef..e8d89a9 100644 --- a/src/templates/apps/billboard/_partials/_my_posts_applet_item.html +++ b/src/templates/apps/billboard/_partials/_my_posts_applet_item.html @@ -1,4 +1,10 @@ -{# Recent-posts row for the My Posts in-grid applet. `item` is a Post. #} -<li class="applet-list-entry"> - <a href="{{ item.get_absolute_url }}">{{ item.title }}</a> +{% load lyric_extras %} +{# My Posts applet row — title | latest line text | latest line ts. #} +{# `item` is a Post w. `latest_line` attached by `_recent_posts`. #} +<li class="applet-list-entry row-3col"> + <a href="{{ item.get_absolute_url }}" class="row-title">{{ item.title|truncate_title }}</a> + {% if item.latest_line %} + <span class="row-body">{{ item.latest_line.text|striptags }}</span> + <time class="row-ts" datetime="{{ item.latest_line.created_at|date:'c' }}">{{ item.latest_line.created_at|relative_ts }}</time> + {% endif %} </li> diff --git a/src/templates/apps/billboard/_partials/_my_scrolls_item.html b/src/templates/apps/billboard/_partials/_my_scrolls_item.html index c870ee5..538e324 100644 --- a/src/templates/apps/billboard/_partials/_my_scrolls_item.html +++ b/src/templates/apps/billboard/_partials/_my_scrolls_item.html @@ -1,4 +1,10 @@ -{# Recent-scroll row for the My Scrolls in-grid applet. `item` is a Room. #} -<li class="applet-list-entry"> - <a href="{% url 'billboard:scroll' item.id %}">{{ item.name }}</a> +{% load lyric_extras %} +{# My Scrolls applet row — room name | latest event prose | event ts. #} +{# `item` is a Room w. `latest_event` attached by `annotate_latest_event`. #} +<li class="applet-list-entry row-3col"> + <a href="{% url 'billboard:scroll' item.id %}" class="row-title">{{ item.name|truncate_title }}</a> + {% if item.latest_event %} + <span class="row-body">{{ item.latest_event.to_prose|striptags }}</span> + <time class="row-ts" datetime="{{ item.latest_event.timestamp|date:'c' }}">{{ item.latest_event.timestamp|relative_ts }}</time> + {% endif %} </li> diff --git a/src/templates/apps/gameboard/_partials/_my_games_item.html b/src/templates/apps/gameboard/_partials/_my_games_item.html index d8db5a4..4838fff 100644 --- a/src/templates/apps/gameboard/_partials/_my_games_item.html +++ b/src/templates/apps/gameboard/_partials/_my_games_item.html @@ -1,4 +1,10 @@ -{# Recent-game row for the My Games in-grid applet. `item` is a Room. #} -<li class="applet-list-entry"> - <a href="{% url 'epic:gatekeeper' item.id %}">{{ item.name }}</a> +{% load lyric_extras %} +{# My Games applet row — room name | latest event prose | event ts. #} +{# `item` is a Room w. `latest_event` attached by `annotate_latest_event`. #} +<li class="applet-list-entry row-3col"> + <a href="{% url 'epic:gatekeeper' item.id %}" class="row-title">{{ item.name|truncate_title }}</a> + {% if item.latest_event %} + <span class="row-body">{{ item.latest_event.to_prose|striptags }}</span> + <time class="row-ts" datetime="{{ item.latest_event.timestamp|date:'c' }}">{{ item.latest_event.timestamp|relative_ts }}</time> + {% endif %} </li>