From a02f3473d5651b877bf84bc38cedda5a59c14e1f Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 8 Jun 2026 18:20:09 -0400 Subject: [PATCH] =?UTF-8?q?tooltips:=20tense-aware=20expiry=20(expires/exp?= =?UTF-8?q?ired)=20+=20a=20<60min=20'N=20min'=20bucket=20in=20the=20shared?= =?UTF-8?q?=20relative=5Fts=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The token + position-circle expiry tooltips hardcoded 'expires' and never flipped to 'expired' once the time passed — a seat whose `cost_current_until` (the 7d cost clock) lapsed still read 'expires 11:30 p.m.' (staging 2026-06-08). - New `expiry_phrase(dt)` filter (lyric_extras): 'expires ' for a FUTURE datetime, 'expired ' for a PAST one — the verb carries the tense so the underlying `relative_ts` stays direction-agnostic. Wired into `Token.tooltip_expiry` + the position-circle `data-tt-expiry` (position-tooltip.js copies it verbatim, so no JS change). - `relative_ts` gains a <60min → 'N min' bucket (buckets: 60min / 24h / 7d / 12mo / >12mo). Per user-spec it stays SHARED, so scroll.html's provenance feed (+ post.html / my-games row-ts) now reads 'N min' for very recent events too. TDD: relative_ts <60min past+future + the 1h boundary; expiry_phrase none/future/past/wraps-relative_ts; billboard post-line test updated (3h→clock- time, + new just-posted→'N min'). 727 lyric+billboard+gameboard ITs green. Bundled (parallel work): rootvars.scss chroma-hue primaries brightened (--priRd/Or/Gn/Tk/Bl/Id + --terGn). [[project-position-circle-tooltips]] Co-Authored-By: Claude Opus 4.8 (1M context) --- .../billboard/tests/integrated/test_views.py | 20 ++++++++--- src/apps/lyric/models.py | 9 ++--- src/apps/lyric/templatetags/lyric_extras.py | 31 ++++++++++++---- .../lyric/tests/unit/test_templatetags.py | 36 +++++++++++++++++++ src/static_src/scss/rootvars.scss | 16 ++++----- .../gameboard/_partials/_table_positions.html | 2 +- 6 files changed, 90 insertions(+), 24 deletions(-) diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index adc205d..dc4a5fe 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -620,9 +620,9 @@ class SaveScrollPositionTest(TestCase): class PostLineRelativeTimestampTest(TestCase): """post.html mirrors scroll.html's bucketed `relative_ts` time rendering: - same-day Lines show a time; older ones collapse to weekday / month-day / - month-day-year. Bypasses `auto_now_add` with a queryset .update() so the - test can backdate Lines.""" + <60min Lines show minutes; same-day a time; older ones collapse to weekday + / month-day / month-day-year. Bypasses `auto_now_add` with a queryset + .update() so the test can backdate Lines.""" def setUp(self): self.owner = User.objects.create(email="owner@post-ts.io", username="owner") @@ -638,13 +638,25 @@ class PostLineRelativeTimestampTest(TestCase): ) def test_recent_line_renders_clock_time(self): - self.Line.objects.create(post=self.post, text="now", author=self.owner) + # 3 h ago — within the day but past the new <60min bucket → clock time. + line = self.Line.objects.create(post=self.post, text="recent", author=self.owner) + self._backdate(line, hours=3) response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) self.assertRegex( response.content.decode(), r'class="post-line-time"[^>]*>\s*\d+:\d{2}\s*[ap]\.m\.\s*<', ) + def test_under_hour_line_renders_minutes(self): + # New shared <60min bucket: a just-posted Line reads "N min", not a clock + # time (user-spec 2026-06-08 — same formatter the expiry tooltips use). + self.Line.objects.create(post=self.post, text="now", author=self.owner) + response = self.client.get(reverse("billboard:view_post", args=[self.post.id])) + self.assertRegex( + response.content.decode(), + r'class="post-line-time"[^>]*>\s*\d+ min\s*<', + ) + def test_two_day_old_line_renders_weekday(self): line = self.Line.objects.create(post=self.post, text="old", author=self.owner) self._backdate(line, days=2) diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 929854f..c2b2cd1 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -409,10 +409,11 @@ class Token(models.Model): return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return "no expiry" if self.expires_at: - # "expires " (lowercase, relative timescale) — same rules as - # the billboard .row-ts + the position-circle .tt-expiry. - from apps.lyric.templatetags.lyric_extras import relative_ts - return f"expires {relative_ts(self.expires_at)}" + # Tense-aware (expires / expired) + bucketed — shares `expiry_phrase` + # with the position-circle .tt-expiry, over the provenance feed's + # `relative_ts` timescale. + from apps.lyric.templatetags.lyric_extras import expiry_phrase + return expiry_phrase(self.expires_at) return "" def tooltip_room_html(self): diff --git a/src/apps/lyric/templatetags/lyric_extras.py b/src/apps/lyric/templatetags/lyric_extras.py index 0e0cb1c..a3dd6f8 100644 --- a/src/apps/lyric/templatetags/lyric_extras.py +++ b/src/apps/lyric/templatetags/lyric_extras.py @@ -23,19 +23,23 @@ def relative_ts(dt): """Return a compact relative timestamp string for a datetime value. Distance-based (uses the ABSOLUTE gap from now), so it reads the same for - a past provenance timestamp OR a future expiry — the token tooltips reuse - it for `expires `: + a past provenance timestamp OR a future expiry — `expiry_phrase` layers the + `expires` / `expired` verb on top: - < 24 h → "3:07 a.m." - < 7 d → "Thu" - < 1 y → "07 Mar" - ≥ 1 y → "07 Mar 2025" + < 60 min → "42 min" + < 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 = abs(timezone.now() - dt) - if diff.total_seconds() < 86400: + secs = diff.total_seconds() + if secs < 3600: + return f"{max(1, int(secs // 60))} min" + elif secs < 86400: return dateformat.format(local_dt, "g:i a") elif diff.days < 7: return dateformat.format(local_dt, "D") @@ -45,6 +49,19 @@ def relative_ts(dt): return dateformat.format(local_dt, "d M Y") +@register.filter +def expiry_phrase(dt): + """Tense-aware expiry label: "expires " for a FUTURE datetime, + "expired " for a PAST one. is the shared bucketed `relative_ts` + (60 min / 24 h / 7 d / 12 mo / >12 mo). The verb carries the tense so + `relative_ts` stays direction-agnostic + reusable by the provenance feed. + Empty string for None.""" + if dt is None: + return "" + verb = "expired" if dt < timezone.now() else "expires" + return f"{verb} {relative_ts(dt)}" + + @register.filter def display_name(user): # `getattr` guards: AnonymousUser has no `.email` attribute and is not diff --git a/src/apps/lyric/tests/unit/test_templatetags.py b/src/apps/lyric/tests/unit/test_templatetags.py index 9f63b05..ae82bbc 100644 --- a/src/apps/lyric/tests/unit/test_templatetags.py +++ b/src/apps/lyric/tests/unit/test_templatetags.py @@ -72,3 +72,39 @@ class RelativeTsFilterTest(SimpleTestCase): def test_future_dt_within_week_is_weekday(self): dt = timezone.now() + timezone.timedelta(days=3) self.assertIn(relative_ts(dt), ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) + + def test_returns_minutes_for_under_hour_past_dt(self): + # New <60min bucket: a bare minute count (verb supplies tense elsewhere). + dt = timezone.now() - timezone.timedelta(minutes=42, seconds=30) + self.assertRegex(relative_ts(dt), r'^\d+ min$') + self.assertIn(relative_ts(dt), ["42 min", "43 min"]) + + def test_returns_minutes_for_under_hour_future_dt(self): + dt = timezone.now() + timezone.timedelta(minutes=42, seconds=30) + self.assertRegex(relative_ts(dt), r'^\d+ min$') + + def test_at_one_hour_falls_to_clock_time(self): + # 60min boundary is exclusive — exactly 1h reads as a clock time, not min. + dt = timezone.now() - timezone.timedelta(hours=1, minutes=1) + self.assertRegex(relative_ts(dt), r'\d+:\d+') + + +class ExpiryPhraseFilterTest(SimpleTestCase): + def test_none_is_empty(self): + from apps.lyric.templatetags.lyric_extras import expiry_phrase + self.assertEqual(expiry_phrase(None), "") + + def test_future_says_expires(self): + from apps.lyric.templatetags.lyric_extras import expiry_phrase + dt = timezone.now() + timezone.timedelta(hours=3) + self.assertTrue(expiry_phrase(dt).startswith("expires ")) + + def test_past_says_expired(self): + from apps.lyric.templatetags.lyric_extras import expiry_phrase + dt = timezone.now() - timezone.timedelta(days=2) + self.assertTrue(expiry_phrase(dt).startswith("expired ")) + + def test_wraps_the_shared_bucketed_relative_ts(self): + from apps.lyric.templatetags.lyric_extras import expiry_phrase, relative_ts + dt = timezone.now() - timezone.timedelta(minutes=42) + self.assertEqual(expiry_phrase(dt), f"expired {relative_ts(dt)}") diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index fdeaff6..13f48b3 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -119,16 +119,16 @@ /* Chroma Hues */ // red (A-Fire) - --priRd: 233, 53, 37; + --priRd: 253, 73, 57; --secRd: 193, 43, 28; --terRd: 155, 31, 15; --quaRd: 119, 20, 4; --quiRd: 85, 11, 0; --sixRd: 54, 4, 0; // orange (B-Fire) - --priOr: 225, 133, 40; + --priOr: 245, 183, 90; --secOr: 187, 111, 30; - --terOr: 150, 88, 17; + --terOr: 130, 88, 17; --quaOr: 115, 67, 6; --quiOr: 82, 47, 0; --sixOr: 51, 29, 0; @@ -147,14 +147,14 @@ --quiLm: 47, 64, 15; --sixLm: 25, 40, 6; // green (A-Space) - --priGn: 0, 160, 75; + --priGn: 0, 240, 125; --secGn: 0, 135, 62; - --terGn: 0, 109, 48; + --terGn: 0, 99, 41; --quaGn: 0, 85, 35; --quiGn: 0, 62, 23; --sixGn: 0, 40, 12; // teal (B-Space) - --priTk: 0, 184, 162; + --priTk: 0, 204, 182; --secTk: 0, 154, 136; --terTk: 0, 125, 110; --quaTk: 0, 97, 85; @@ -168,14 +168,14 @@ --quiCy: 0, 67, 78; --sixCy: 0, 43, 52; // blue (B-Air) - --priBl: 20, 141, 205; + --priBl: 40, 181, 245; --secBl: 18, 119, 173; --terBl: 8, 95, 140; --quaBl: 3, 73, 109; --quiBl: 0, 52, 79; --sixBl: 0, 33, 51; // indigo (A-Water) - --priId: 79, 102, 212; + --priId: 111, 133, 244; --secId: 66, 88, 184; --terId: 53, 74, 156; --quaId: 44, 60, 131; diff --git a/src/templates/apps/gameboard/_partials/_table_positions.html b/src/templates/apps/gameboard/_partials/_table_positions.html index 7395d52..f5447cf 100644 --- a/src/templates/apps/gameboard/_partials/_table_positions.html +++ b/src/templates/apps/gameboard/_partials/_table_positions.html @@ -13,7 +13,7 @@
{% for pos in gate_positions %}
+ data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}" data-tt-title="{{ pos.slot.gamer|at_handle }}" data-tt-description="the {{ pos.slot.gamer.active_title_display }}" data-tt-shoptalk="{{ pos.shoptalk }}" data-tt-tokens="{{ pos.tokens }}" data-tt-token-types="{{ pos.token_types|join:'|' }}"{% if pos.expiry %} data-tt-expiry="{{ pos.expiry|expiry_phrase }}"{% endif %}{% if pos.sign_rank %} data-tt-sign-rank="{{ pos.sign_rank }}" data-tt-sign-suit="{{ pos.sign_suit_icon }}"{% endif %}{% endif %}> {{ pos.slot.slot_number }} {% if pos.slot.gamer %}{{ pos.slot.gamer|at_handle }}{% else %}empty{% endif %} {% if pos.is_me_also %}