slot/token tooltips: 'expires <relative>' (lowercase, .row-ts timescale) + per-slot token_cost on GateSlot + '+ <Token>' deposited-token list — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

Three pieces of housekeeping on the token tooltips:

1. Expiry format. relative_ts is now distance-based (abs gap from now), so it formats FUTURE expiries with the same timescale rules it already uses for past .row-ts timestamps (<24h time, <7d weekday, <1y 'dd Mon', else +year) — past behaviour unchanged (abs is a no-op for past). The FREE-token wallet tooltip (Token.tooltip_expiry) and the position-circle .tt-expiry both read 'expires <when>' (lowercase, no majuscule); the position tooltip is server-formatted via the filter (JS just copies the attr — no JS date logic).

2. Per-slot token count moved off the user's CARTE token onto the slot model. New GateSlot.token_cost (PositiveSmallIntegerField default 1) — the per-seat expenditure count. _gate_positions reads slot.token_cost instead of the CARTE Token.slots_claimed high-water mark, which wrongly showed '6' on every CARTE-covered seat. Every slot now reads 1 (a CARTE covers each seat at cost 1, like any token); the field only rises above 1 when the rising-game-cost feature lands.

3. Per-slot deposited-token list. Under the '<n> Token(s) deposited' header the tooltip now lists a '+ <Token name>' bulleted <ul> — one entry today (a slot ejects its token on any re-deposit, so combinations aren't yet possible). Derived from the slot's debited_token_type (e.g. 'carte' -> 'Carte Blanche', 'Free' -> 'Free Token'); a CARTE across all six seats shows '+ Carte Blanche' on each. token_types is a list, future-ready for token combinations + elevated per-slot cost.

Rising-game-cost is NOT built (recon-confirmed), so the per-slot count is always 1 and the 2-token-slot FT is intentionally skipped per user.

Tests: relative_ts future-date unit tests; FreeTokenTooltipTest rewritten for the relative format (real datetime, no MagicMock/strftime); wallet FT + the two CARTE token-count tests updated to per-slot semantics (1 + 'Carte Blanche'); FREE-slot IT asserts the token-list + 'expires '. Full suite 1606 green; 11 position-tooltip FTs + wallet tooltip FT green.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-01 17:39:15 -04:00
parent cbc4f4f323
commit 5229b9f96a
14 changed files with 128 additions and 37 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-06-01 21:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0016_seed_free_in_shop_decks'),
]
operations = [
migrations.AddField(
model_name='gateslot',
name='token_cost',
field=models.PositiveSmallIntegerField(default=1),
),
]

View File

@@ -84,6 +84,13 @@ class GateSlot(models.Model):
filled_at = models.DateTimeField(null=True, blank=True)
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
# Per-slot token EXPENDITURE count — how many tokens this seat cost to
# occupy. 1 today for every slot (a CARTE covers each seat at cost 1, like
# any other token); only rises above 1 once the rising-game-cost feature
# lands (a gamer in their 4th+ simultaneous game pays an elevated per-slot
# cost). Lives HERE (the slot), not derived from the occupant's token —
# the prior CARTE `slots_claimed` high-water mark wrongly showed N per seat.
token_cost = models.PositiveSmallIntegerField(default=1)
# ── Seat-occupancy / renewal clock (sprint 2026-05-31) ────────────────
# A filled seat's token cost is "current" for one renewal span after

View File

@@ -44,11 +44,20 @@ var PositionTooltip = (function () {
_activeTrig = null;
}
function _fmtExpiry(iso) {
if (!iso) return "";
var d = new Date(iso);
if (isNaN(d.getTime())) return "";
return "seat held to " + d.toLocaleDateString();
// Rebuild the "+ <Token>" bulleted list of the slot's deposited token(s),
// in deposit order, from the pipe-delimited data-tt-token-types attr. The
// "+" bullet is supplied by CSS. Conditional — empty when the slot carries
// no token payload.
function _setTokenList(raw) {
var ul = _portal.querySelector(".tt-token-list");
if (!ul) return;
ul.innerHTML = "";
(raw ? raw.split("|") : []).forEach(function (name) {
if (!name) return;
var li = document.createElement("li");
li.textContent = name;
ul.appendChild(li);
});
}
// Clamp the fixed portal to the viewport, centred under (or over) the
@@ -85,8 +94,10 @@ var PositionTooltip = (function () {
_set(".tt-description", circle.dataset.ttDescription);
_set(".tt-shoptalk", circle.dataset.ttShoptalk);
var tokens = circle.dataset.ttTokens;
_set(".tt-tokens", tokens ? tokens + (tokens === "1" ? " token" : " tokens") : "");
_set(".tt-expiry", _fmtExpiry(circle.dataset.ttExpiry));
_set(".tt-tokens", tokens ? tokens + (tokens === "1" ? " Token deposited" : " Tokens deposited") : "");
_setTokenList(circle.dataset.ttTokenTypes);
// .tt-expiry is server-formatted ("expires <when>") — copy verbatim.
_set(".tt-expiry", circle.dataset.ttExpiry);
var sign = _portal.querySelector(".tt-sign");
if (sign) {

View File

@@ -690,7 +690,10 @@ class PositionTooltipRenderTest(TestCase):
def test_deposit_count_and_expiry_present(self):
slot2 = _circle_tag(self._gate_content(), 2)
self.assertIn('data-tt-tokens="1"', slot2)
self.assertIn("data-tt-expiry=", slot2)
# FREE-funded slot → the deposited-token list reads "Free Token".
self.assertIn('data-tt-token-types="Free Token"', slot2)
# Lowercase "expires <when>" (relative timescale), not an ISO/locale date.
self.assertIn('data-tt-expiry="expires ', slot2)
def test_seat_significator_rank_rides_the_circle(self):
sig = TarotCard.objects.create(
@@ -741,9 +744,18 @@ class PositionTooltipCarteRenderTest(TestCase):
self.client.force_login(self.viewer)
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
def test_tokens_reflects_carte_slots_claimed(self):
def test_each_carte_slot_costs_one_token(self):
# A CARTE covers each seat at cost 1 — the per-slot expenditure count
# is 1 (GateSlot.token_cost), NOT the CARTE slots_claimed high-water
# mark. The deposited-token list reads "Carte Blanche".
content = self.client.get(self.room_url).content.decode()
self.assertIn('data-tt-tokens="6"', _circle_tag(content, 1))
# One CARTE covers ALL six seats — every slot reads cost 1 + "Carte
# Blanche" (token combinations per slot aren't possible: a re-deposit
# ejects the slot's token), so there is no mixed CARTE+FREE spread.
for n in (1, 4, 6):
tag = _circle_tag(content, n)
self.assertIn('data-tt-tokens="1"', tag)
self.assertIn('data-tt-token-types="Carte Blanche"', tag)
def test_own_other_seat_is_me_also_with_switch_href(self):
content = self.client.get(self.room_url).content.decode()

View File

@@ -228,13 +228,9 @@ def _gate_positions(room, user=None, current_slot=None):
bn.bud_id: bn.shoptalk
for bn in BudshipNote.objects.filter(user=user)
}
# One CARTE-token-per-gamer map, hoisted out of the loop — a CARTE gamer
# owns ONE token (carrying slots_claimed) but claims many slots, so the
# per-slot lookup was up to 6 identical queries.
carte_claims = {
t.user_id: t.slots_claimed
for t in Token.objects.filter(token_type=Token.CARTE, current_room=room)
}
# value → display ("carte" → "Carte Blanche", "Free" → "Free Token") for
# the per-slot deposited-token `<ul>`.
token_display = dict(Token.TOKEN_TYPE_CHOICES)
positions = []
for slot in room.gate_slots.select_related("gamer").order_by("slot_number"):
gamer = slot.gamer
@@ -253,11 +249,14 @@ def _gate_positions(room, user=None, current_slot=None):
state_class = "tt-pos-gamer tt-pos-bud"
else:
state_class = "tt-pos-gamer"
# Deposited-token count — CARTE claims many slots on one token.
if gamer is not None and slot.debited_token_type == Token.CARTE:
tokens = carte_claims.get(gamer.id, 1)
else:
tokens = 1
# Ordered display names of the token(s) deposited in THIS slot — one
# today (each slot costs exactly 1 token; a CARTE covers each seat at
# cost 1, so a CARTE seat reads "Carte Blanche"). Becomes a multi-entry
# list when the rising-game-cost feature lands.
token_types = (
[token_display.get(slot.debited_token_type, slot.debited_token_type)]
if gamer is not None and slot.debited_token_type else []
)
sig = seat.significator if seat else None
positions.append({
"slot": slot,
@@ -266,7 +265,9 @@ def _gate_positions(room, user=None, current_slot=None):
"state_class": state_class,
"is_me_also": is_me_also,
"shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
"tokens": tokens,
# Per-slot expenditure count (GateSlot.token_cost) — 1 normally.
"tokens": slot.token_cost,
"token_types": token_types,
"expiry": slot.cost_current_until,
"sign_rank": sig.corner_rank if sig else "",
"sign_suit_icon": sig.suit_icon if sig else "",

View File

@@ -401,7 +401,10 @@ class Token(models.Model):
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry"
if self.expires_at:
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
# "expires <when>" (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)}"
return ""
def tooltip_room_html(self):

View File

@@ -22,6 +22,10 @@ def truncate_email(email):
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 <when>`:
< 24 h → "3:07 a.m."
< 7 d → "Thu"
< 1 y → "07 Mar"
@@ -30,7 +34,7 @@ def relative_ts(dt):
if dt is None:
return ""
local_dt = timezone.localtime(dt)
diff = timezone.now() - dt
diff = abs(timezone.now() - dt)
if diff.total_seconds() < 86400:
return dateformat.format(local_dt, "g:i a")
elif diff.days < 7:

View File

@@ -62,3 +62,13 @@ class RelativeTsFilterTest(SimpleTestCase):
result = relative_ts(dt)
import re
self.assertRegex(result, r'^\d{2} \w{3} \d{4}$')
def test_future_dt_uses_distance_not_sign(self):
# Token expiries are in the FUTURE — relative_ts is distance-based, so
# a date ~30 days out reads "07 Mar" (not collapsing to a time).
dt = timezone.now() + timezone.timedelta(days=30)
self.assertRegex(relative_ts(dt), r'^\d{2} \w{3}$')
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"])

View File

@@ -1,7 +1,10 @@
from datetime import timedelta
from django.test import SimpleTestCase
from unittest.mock import MagicMock
from django.utils import timezone
from apps.lyric.models import Token
from apps.lyric.templatetags.lyric_extras import relative_ts
class CoinTooltipTest(SimpleTestCase):
@@ -26,8 +29,8 @@ class FreeTokenTooltipTest(SimpleTestCase):
def setUp(self):
self.token = Token()
self.token.token_type = Token.FREE
self.token.expires_at = MagicMock()
self.token.expires_at.strftime = lambda fmt: "2026-03-15"
# A real (future) datetime — tooltip_expiry now formats via relative_ts.
self.token.expires_at = timezone.now() + timedelta(days=3)
def test_tooltip_contains_name(self):
self.assertIn("Free Token", self.token.tooltip_text())
@@ -35,11 +38,15 @@ class FreeTokenTooltipTest(SimpleTestCase):
def test_tooltip_contains_entry(self):
self.assertIn("Admit 1 Entry", self.token.tooltip_text())
def test_tooltip_contains_expires(self):
self.assertIn("Expires", self.token.tooltip_text())
def test_tooltip_contains_lowercase_expires(self):
# "expires <when>", no majuscule.
text = self.token.tooltip_text()
self.assertIn("expires", text)
self.assertNotIn("Expires", text)
def test_tooltip_contains_expiry_date(self):
self.assertIn("2026-03-15", self.token.tooltip_text())
def test_tooltip_contains_relative_expiry(self):
# Same timescale as billboard .row-ts (3 days out → weekday).
self.assertIn(relative_ts(self.token.expires_at), self.token.tooltip_text())
class PassTokenTooltipTest(SimpleTestCase):

View File

@@ -115,7 +115,7 @@ class WalletDisplayTest(FunctionalTest):
free_tooltip = self.browser.find_element(By.ID, "id_tooltip_portal").text
self.assertIn("Free Token", free_tooltip)
self.assertIn("Admit 1 Entry", free_tooltip)
self.assertIn("Expires", free_tooltip)
self.assertIn("expires", free_tooltip) # lowercase, relative timescale
def test_wallet_payment_section_renders(self):
# 1. Log in, navigate directly to wallet page

View File

@@ -216,7 +216,10 @@ class CarteSeatSwitchTest(FunctionalTest):
or also.find_element(By.CSS_SELECTOR, "a").get_attribute("href"))
self.assertIn("seat=4", href)
def test_tokens_deposited_reflects_carte_slots_claimed(self):
def test_each_carte_slot_costs_one_token(self):
# A CARTE covers each seat at cost 1 — the per-slot count is 1 (NOT the
# CARTE slots_claimed high-water mark), and the deposited-token list
# reads "Carte Blanche".
self.create_pre_authenticated_session("disco@test.io")
self.room.table_status = Room.ROLE_SELECT
self.room.save()
@@ -224,7 +227,8 @@ class CarteSeatSwitchTest(FunctionalTest):
circle = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot[data-slot='1']")
)
self.assertEqual(circle.get_attribute("data-tt-tokens"), "6")
self.assertEqual(circle.get_attribute("data-tt-tokens"), "1")
self.assertEqual(circle.get_attribute("data-tt-token-types"), "Carte Blanche")
def test_switching_seat_loads_that_seats_role_view(self):
# Clicking the me-also seat-4 circle loads ?seat=4 and the card-stack

View File

@@ -231,7 +231,20 @@ body.page-gameboard {
.tt-tokens,
.tt-expiry { display: block; }
.tt-tokens { font-size: 0.75rem; opacity: 0.65; }
.tt-tokens { font-size: 0.75rem; opacity: 0.7; }
// "+ <Token>" list of the slot's deposited token(s), bulleted with a "+".
.tt-token-list {
list-style: none;
margin: 0.1rem 0 0;
padding: 0;
opacity: 0.7;
li {
font-size: 0.75rem;
&::before { content: "+ "; opacity: 0.7; }
}
}
// Seat significator stack — pinned top-right, modeled on the tray sig
// card (.fan-corner-rank + fa suit icon) and the .tt-price corner pin.

View File

@@ -11,5 +11,6 @@
<span class="tt-description"></span>
<span class="tt-shoptalk"></span>
<span class="tt-tokens"></span>
<ul class="tt-token-list"></ul>
<span class="tt-expiry"></span>
</div>

View File

@@ -9,7 +9,7 @@
<div class="position-strip">
{% 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 %} 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 }}"{% if pos.expiry %} data-tt-expiry="{{ pos.expiry|date:'c' }}"{% 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="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 %}>
<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>
{% if pos.is_me_also %}