tooltips: tense-aware expiry (expires/expired) + a <60min 'N min' bucket in the shared relative_ts — TDD
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 <when>' for a FUTURE datetime, 'expired <when>' 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) <noreply@anthropic.com>
This commit is contained in:
@@ -620,9 +620,9 @@ class SaveScrollPositionTest(TestCase):
|
|||||||
|
|
||||||
class PostLineRelativeTimestampTest(TestCase):
|
class PostLineRelativeTimestampTest(TestCase):
|
||||||
"""post.html mirrors scroll.html's bucketed `relative_ts` time rendering:
|
"""post.html mirrors scroll.html's bucketed `relative_ts` time rendering:
|
||||||
same-day Lines show a time; older ones collapse to weekday / month-day /
|
<60min Lines show minutes; same-day a time; older ones collapse to weekday
|
||||||
month-day-year. Bypasses `auto_now_add` with a queryset .update() so the
|
/ month-day / month-day-year. Bypasses `auto_now_add` with a queryset
|
||||||
test can backdate Lines."""
|
.update() so the test can backdate Lines."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.owner = User.objects.create(email="owner@post-ts.io", username="owner")
|
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):
|
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]))
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
self.assertRegex(
|
self.assertRegex(
|
||||||
response.content.decode(),
|
response.content.decode(),
|
||||||
r'class="post-line-time"[^>]*>\s*\d+:\d{2}\s*[ap]\.m\.\s*<',
|
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):
|
def test_two_day_old_line_renders_weekday(self):
|
||||||
line = self.Line.objects.create(post=self.post, text="old", author=self.owner)
|
line = self.Line.objects.create(post=self.post, text="old", author=self.owner)
|
||||||
self._backdate(line, days=2)
|
self._backdate(line, days=2)
|
||||||
|
|||||||
@@ -409,10 +409,11 @@ class Token(models.Model):
|
|||||||
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
|
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
|
||||||
return "no expiry"
|
return "no expiry"
|
||||||
if self.expires_at:
|
if self.expires_at:
|
||||||
# "expires <when>" (lowercase, relative timescale) — same rules as
|
# Tense-aware (expires / expired) + bucketed — shares `expiry_phrase`
|
||||||
# the billboard .row-ts + the position-circle .tt-expiry.
|
# with the position-circle .tt-expiry, over the provenance feed's
|
||||||
from apps.lyric.templatetags.lyric_extras import relative_ts
|
# `relative_ts` timescale.
|
||||||
return f"expires {relative_ts(self.expires_at)}"
|
from apps.lyric.templatetags.lyric_extras import expiry_phrase
|
||||||
|
return expiry_phrase(self.expires_at)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def tooltip_room_html(self):
|
def tooltip_room_html(self):
|
||||||
|
|||||||
@@ -23,19 +23,23 @@ def relative_ts(dt):
|
|||||||
"""Return a compact relative timestamp string for a datetime value.
|
"""Return a compact relative timestamp string for a datetime value.
|
||||||
|
|
||||||
Distance-based (uses the ABSOLUTE gap from now), so it reads the same for
|
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
|
a past provenance timestamp OR a future expiry — `expiry_phrase` layers the
|
||||||
it for `expires <when>`:
|
`expires` / `expired` verb on top:
|
||||||
|
|
||||||
< 24 h → "3:07 a.m."
|
< 60 min → "42 min"
|
||||||
< 7 d → "Thu"
|
< 24 h → "3:07 a.m."
|
||||||
< 1 y → "07 Mar"
|
< 7 d → "Thu"
|
||||||
≥ 1 y → "07 Mar 2025"
|
< 1 y → "07 Mar"
|
||||||
|
≥ 1 y → "07 Mar 2025"
|
||||||
"""
|
"""
|
||||||
if dt is None:
|
if dt is None:
|
||||||
return ""
|
return ""
|
||||||
local_dt = timezone.localtime(dt)
|
local_dt = timezone.localtime(dt)
|
||||||
diff = abs(timezone.now() - 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")
|
return dateformat.format(local_dt, "g:i a")
|
||||||
elif diff.days < 7:
|
elif diff.days < 7:
|
||||||
return dateformat.format(local_dt, "D")
|
return dateformat.format(local_dt, "D")
|
||||||
@@ -45,6 +49,19 @@ def relative_ts(dt):
|
|||||||
return dateformat.format(local_dt, "d M Y")
|
return dateformat.format(local_dt, "d M Y")
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def expiry_phrase(dt):
|
||||||
|
"""Tense-aware expiry label: "expires <when>" for a FUTURE datetime,
|
||||||
|
"expired <when>" for a PAST one. <when> 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
|
@register.filter
|
||||||
def display_name(user):
|
def display_name(user):
|
||||||
# `getattr` guards: AnonymousUser has no `.email` attribute and is not
|
# `getattr` guards: AnonymousUser has no `.email` attribute and is not
|
||||||
|
|||||||
@@ -72,3 +72,39 @@ class RelativeTsFilterTest(SimpleTestCase):
|
|||||||
def test_future_dt_within_week_is_weekday(self):
|
def test_future_dt_within_week_is_weekday(self):
|
||||||
dt = timezone.now() + timezone.timedelta(days=3)
|
dt = timezone.now() + timezone.timedelta(days=3)
|
||||||
self.assertIn(relative_ts(dt), ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"])
|
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)}")
|
||||||
|
|||||||
@@ -119,16 +119,16 @@
|
|||||||
|
|
||||||
/* Chroma Hues */
|
/* Chroma Hues */
|
||||||
// red (A-Fire)
|
// red (A-Fire)
|
||||||
--priRd: 233, 53, 37;
|
--priRd: 253, 73, 57;
|
||||||
--secRd: 193, 43, 28;
|
--secRd: 193, 43, 28;
|
||||||
--terRd: 155, 31, 15;
|
--terRd: 155, 31, 15;
|
||||||
--quaRd: 119, 20, 4;
|
--quaRd: 119, 20, 4;
|
||||||
--quiRd: 85, 11, 0;
|
--quiRd: 85, 11, 0;
|
||||||
--sixRd: 54, 4, 0;
|
--sixRd: 54, 4, 0;
|
||||||
// orange (B-Fire)
|
// orange (B-Fire)
|
||||||
--priOr: 225, 133, 40;
|
--priOr: 245, 183, 90;
|
||||||
--secOr: 187, 111, 30;
|
--secOr: 187, 111, 30;
|
||||||
--terOr: 150, 88, 17;
|
--terOr: 130, 88, 17;
|
||||||
--quaOr: 115, 67, 6;
|
--quaOr: 115, 67, 6;
|
||||||
--quiOr: 82, 47, 0;
|
--quiOr: 82, 47, 0;
|
||||||
--sixOr: 51, 29, 0;
|
--sixOr: 51, 29, 0;
|
||||||
@@ -147,14 +147,14 @@
|
|||||||
--quiLm: 47, 64, 15;
|
--quiLm: 47, 64, 15;
|
||||||
--sixLm: 25, 40, 6;
|
--sixLm: 25, 40, 6;
|
||||||
// green (A-Space)
|
// green (A-Space)
|
||||||
--priGn: 0, 160, 75;
|
--priGn: 0, 240, 125;
|
||||||
--secGn: 0, 135, 62;
|
--secGn: 0, 135, 62;
|
||||||
--terGn: 0, 109, 48;
|
--terGn: 0, 99, 41;
|
||||||
--quaGn: 0, 85, 35;
|
--quaGn: 0, 85, 35;
|
||||||
--quiGn: 0, 62, 23;
|
--quiGn: 0, 62, 23;
|
||||||
--sixGn: 0, 40, 12;
|
--sixGn: 0, 40, 12;
|
||||||
// teal (B-Space)
|
// teal (B-Space)
|
||||||
--priTk: 0, 184, 162;
|
--priTk: 0, 204, 182;
|
||||||
--secTk: 0, 154, 136;
|
--secTk: 0, 154, 136;
|
||||||
--terTk: 0, 125, 110;
|
--terTk: 0, 125, 110;
|
||||||
--quaTk: 0, 97, 85;
|
--quaTk: 0, 97, 85;
|
||||||
@@ -168,14 +168,14 @@
|
|||||||
--quiCy: 0, 67, 78;
|
--quiCy: 0, 67, 78;
|
||||||
--sixCy: 0, 43, 52;
|
--sixCy: 0, 43, 52;
|
||||||
// blue (B-Air)
|
// blue (B-Air)
|
||||||
--priBl: 20, 141, 205;
|
--priBl: 40, 181, 245;
|
||||||
--secBl: 18, 119, 173;
|
--secBl: 18, 119, 173;
|
||||||
--terBl: 8, 95, 140;
|
--terBl: 8, 95, 140;
|
||||||
--quaBl: 3, 73, 109;
|
--quaBl: 3, 73, 109;
|
||||||
--quiBl: 0, 52, 79;
|
--quiBl: 0, 52, 79;
|
||||||
--sixBl: 0, 33, 51;
|
--sixBl: 0, 33, 51;
|
||||||
// indigo (A-Water)
|
// indigo (A-Water)
|
||||||
--priId: 79, 102, 212;
|
--priId: 111, 133, 244;
|
||||||
--secId: 66, 88, 184;
|
--secId: 66, 88, 184;
|
||||||
--terId: 53, 74, 156;
|
--terId: 53, 74, 156;
|
||||||
--quaId: 44, 60, 131;
|
--quaId: 44, 60, 131;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="position-strip">
|
<div class="position-strip">
|
||||||
{% for pos in gate_positions %}
|
{% for pos in gate_positions %}
|
||||||
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned and not persist_circles %} role-assigned{% endif %}{% if pos.state_class %} {{ pos.state_class }}{% endif %}"
|
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned and not persist_circles %} role-assigned{% endif %}{% if pos.state_class %} {{ pos.state_class }}{% endif %}"
|
||||||
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="expires {{ pos.expiry|relative_ts }}"{% endif %}{% if pos.sign_rank %} data-tt-sign-rank="{{ pos.sign_rank }}" data-tt-sign-suit="{{ pos.sign_suit_icon }}"{% endif %}{% endif %}>
|
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 %}>
|
||||||
<span class="slot-number">{{ pos.slot.slot_number }}</span>
|
<span class="slot-number">{{ pos.slot.slot_number }}</span>
|
||||||
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer|at_handle }}{% else %}empty{% endif %}</span>
|
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer|at_handle }}{% else %}empty{% endif %}</span>
|
||||||
{% if pos.is_me_also %}
|
{% if pos.is_me_also %}
|
||||||
|
|||||||
Reference in New Issue
Block a user