slot/token tooltips: 'expires <relative>' (lowercase, .row-ts timescale) + per-slot token_cost on GateSlot + '+ <Token>' deposited-token list — TDD
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:
18
src/apps/epic/migrations/0017_gateslot_token_cost.py
Normal file
18
src/apps/epic/migrations/0017_gateslot_token_cost.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user