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:
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user