From 8538f76b1324f493a2b27d2072e70c9efb90782b Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Thu, 2 Apr 2026 14:51:08 -0400 Subject: [PATCH] new core.middleware sets cookie for scroll timestamp view to local browser time, w. new corresponding tests in core.tests.UTs.test_middleware; apps.lyric.templatetags.lyric_extras determines timestamp format based on duration elapsed since timestamp; apps.bill.tests.ITs.test_views renamed, now also asserts scroll renders event body and time in columns --- ...{test_billboard_views.py => test_views.py} | 5 ++ src/apps/lyric/templatetags/lyric_extras.py | 24 +++++++ src/core/middleware.py | 29 ++++++++ src/core/settings.py | 1 + src/core/tests/__init__.py | 0 src/core/tests/unit/__init__.py | 0 src/core/tests/unit/test_middleware.py | 41 +++++++++++ src/functional_tests/test_billboard.py | 68 +++++++++++++++++++ src/static_src/scss/_billboard.scss | 18 +++++ src/templates/core/_partials/_scroll.html | 10 +-- src/templates/core/base.html | 1 + 11 files changed, 192 insertions(+), 5 deletions(-) rename src/apps/billboard/tests/integrated/{test_billboard_views.py => test_views.py} (96%) create mode 100644 src/core/middleware.py create mode 100644 src/core/tests/__init__.py create mode 100644 src/core/tests/unit/__init__.py create mode 100644 src/core/tests/unit/test_middleware.py diff --git a/src/apps/billboard/tests/integrated/test_billboard_views.py b/src/apps/billboard/tests/integrated/test_views.py similarity index 96% rename from src/apps/billboard/tests/integrated/test_billboard_views.py rename to src/apps/billboard/tests/integrated/test_views.py index b0bc738..d16e363 100644 --- a/src/apps/billboard/tests/integrated/test_billboard_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -143,6 +143,11 @@ class BillscrollViewTest(TestCase): response = self.client.get(f"/billboard/room/{self.room.id}/scroll/") self.assertEqual(response.context["scroll_position"], 250) + def test_scroll_renders_event_body_and_time_columns(self): + response = self.client.get(f"/billboard/room/{self.room.id}/scroll/") + self.assertContains(response, 'class="drama-event-body"') + self.assertContains(response, 'class="drama-event-time"') + class SaveScrollPositionTest(TestCase): def setUp(self): diff --git a/src/apps/lyric/templatetags/lyric_extras.py b/src/apps/lyric/templatetags/lyric_extras.py index cf9df4e..fe8c5bd 100644 --- a/src/apps/lyric/templatetags/lyric_extras.py +++ b/src/apps/lyric/templatetags/lyric_extras.py @@ -1,4 +1,5 @@ from django import template +from django.utils import dateformat, timezone register = template.Library() @@ -17,6 +18,29 @@ def truncate_email(email): return local + "@" + domain_name + "." + domain_tld +@register.filter +def relative_ts(dt): + """Return a compact relative timestamp string for a datetime value. + + < 24 h → "3:07 a.m." + < 7 d → "Thu" + < 1 y → "07 Mar" + ≥ 1 y → "07 Mar 2025" + """ + if dt is None: + return "" + local_dt = timezone.localtime(dt) + diff = timezone.now() - dt + if diff.total_seconds() < 86400: + return dateformat.format(local_dt, "g:i a") + elif diff.days < 7: + return dateformat.format(local_dt, "D") + elif diff.days < 365: + return dateformat.format(local_dt, "d M") + else: + return dateformat.format(local_dt, "d M Y") + + @register.filter def display_name(user): if user is None: diff --git a/src/core/middleware.py b/src/core/middleware.py new file mode 100644 index 0000000..1097757 --- /dev/null +++ b/src/core/middleware.py @@ -0,0 +1,29 @@ +import zoneinfo + +from django.utils import timezone + + +class TimezoneMiddleware: + """Activate the user's local timezone from the ``user_tz`` cookie. + + The cookie is set client-side via ``Intl.DateTimeFormat().resolvedOptions().timeZone`` + on every page load, so it reflects the browser's OS timezone rather than + the server's configured TIME_ZONE. Invalid or absent cookies fall back to + Django's default (UTC). + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + tz_name = request.COOKIES.get("user_tz") + if tz_name: + try: + timezone.activate(zoneinfo.ZoneInfo(tz_name)) + except (zoneinfo.ZoneInfoNotFoundError, KeyError): + timezone.deactivate() + else: + timezone.deactivate() + response = self.get_response(request) + timezone.deactivate() + return response diff --git a/src/core/settings.py b/src/core/settings.py index da7776e..47e174b 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -79,6 +79,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'core.middleware.TimezoneMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', diff --git a/src/core/tests/__init__.py b/src/core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/tests/unit/__init__.py b/src/core/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/tests/unit/test_middleware.py b/src/core/tests/unit/test_middleware.py new file mode 100644 index 0000000..ba124ec --- /dev/null +++ b/src/core/tests/unit/test_middleware.py @@ -0,0 +1,41 @@ +from django.http import HttpResponse +from django.test import RequestFactory, SimpleTestCase +from django.utils import timezone + +from core.middleware import TimezoneMiddleware + + +class TimezoneMiddlewareTest(SimpleTestCase): + + def setUp(self): + self.factory = RequestFactory() + self.middleware = TimezoneMiddleware(lambda r: HttpResponse()) + + def test_activates_valid_timezone_from_cookie(self): + captured = {} + + def get_response(request): + captured["tz"] = str(timezone.get_current_timezone()) + return HttpResponse() + + middleware = TimezoneMiddleware(get_response) + request = self.factory.get("/") + request.COOKIES["user_tz"] = "America/New_York" + middleware(request) + self.assertEqual(captured["tz"], "America/New_York") + + def test_deactivates_after_response(self): + # Timezone activation must not leak into subsequent requests + request = self.factory.get("/") + request.COOKIES["user_tz"] = "America/New_York" + self.middleware(request) + self.assertEqual(str(timezone.get_current_timezone()), "UTC") + + def test_invalid_timezone_cookie_does_not_raise(self): + request = self.factory.get("/") + request.COOKIES["user_tz"] = "Not/ATimezone" + self.middleware(request) # must not raise + + def test_missing_cookie_does_not_raise(self): + request = self.factory.get("/") + self.middleware(request) # must not raise diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index 60faab2..4fb3366 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -1,5 +1,8 @@ +import datetime +import re import time +from django.utils import timezone from selenium.webdriver.common.by import By from .base import FunctionalTest @@ -284,3 +287,68 @@ class BillscrollAppletsTest(FunctionalTest): lambda: self.browser.find_element(By.ID, "id_drama_scroll") ) self.assertIn("Coin-on-a-String", scroll.text) + + +class BillscrollEntryLayoutTest(FunctionalTest): + """ + FT: each drama entry renders as a 90/10 row — event body at 90%, + relative timestamp at 10%; timestamp text format varies with age. + """ + + def setUp(self): + super().setUp() + self.founder = User.objects.create(email="founder@layout.io") + self.room = Room.objects.create(name="Layout Chamber", owner=self.founder) + # A fresh (< 24 h) event — timestamp is auto_now_add so always recent + record( + self.room, GameEvent.SLOT_FILLED, actor=self.founder, + slot_number=1, token_type="coin", + token_display="Fresh Coin", renewal_days=7, + ) + # An old (> 1 year) event — backdate via queryset update to bypass auto_now_add + old = record( + self.room, GameEvent.SLOT_FILLED, actor=self.founder, + slot_number=2, token_type="coin", + token_display="Ancient Coin", renewal_days=7, + ) + GameEvent.objects.filter(pk=old.pk).update( + timestamp=timezone.now() - datetime.timedelta(days=400) + ) + + def _go_to_scroll(self): + self.create_pre_authenticated_session("founder@layout.io") + self.browser.get( + self.live_server_url + f"/billboard/room/{self.room.id}/scroll/" + ) + return self.wait_for( + lambda: self.browser.find_elements(By.CSS_SELECTOR, ".drama-event") + ) + + # ------------------------------------------------------------------ # + # Test 1 — each entry has a body column and a time column # + # ------------------------------------------------------------------ # + + def test_each_drama_entry_has_body_and_time_columns(self): + events = self._go_to_scroll() + self.assertEqual(len(events), 2) + for event_el in events: + event_el.find_element(By.CSS_SELECTOR, ".drama-event-body") + event_el.find_element(By.CSS_SELECTOR, ".drama-event-time") + + # ------------------------------------------------------------------ # + # Test 2 — recent entry timestamp shows HH:MM a.m./p.m. # + # ------------------------------------------------------------------ # + + def test_recent_event_shows_time_format(self): + events = self._go_to_scroll() + recent_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time") + self.assertRegex(recent_ts.text, r"\d+:\d+\s+[ap]\.m\.") + + # ------------------------------------------------------------------ # + # Test 3 — entry > 1 year old shows DD Mon YYYY # + # ------------------------------------------------------------------ # + + def test_old_event_shows_date_with_year(self): + events = self._go_to_scroll() + old_ts = events[1].find_element(By.CSS_SELECTOR, ".drama-event-time") + self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}") diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index f3e406d..ca84a6f 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -131,6 +131,24 @@ body.page-billscroll { } } +// ── Drama event entries: 90 / 10 column split ───────────────────────────── + +.drama-event { + display: flex; + align-items: baseline; + + .drama-event-body { + flex: 0 0 80%; + } + + .drama-event-time { + flex: 0 0 20%; + font-size: 0.75rem; + opacity: 0.5; + text-align: right; + } +} + // ── My Scrolls list ──────────────────────────────────────────────────────── #id_applet_billboard_my_scrolls { diff --git a/src/templates/core/_partials/_scroll.html b/src/templates/core/_partials/_scroll.html index 593ce33..eb89c48 100644 --- a/src/templates/core/_partials/_scroll.html +++ b/src/templates/core/_partials/_scroll.html @@ -2,13 +2,13 @@
{% for event in events %}
- + {{ event.actor|display_name }} - {{ event.to_prose }}
- + {{ event.to_prose }}
+
{% empty %}

No events yet.

diff --git a/src/templates/core/base.html b/src/templates/core/base.html index 26679dd..85d2bd8 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -146,6 +146,7 @@