position circles: rich .tt-pos-* hover tooltips on the gate-view + table circles — @handle/title/seat-sig/shoptalk/#tokens/expiry portal + CARTE me-also ?seat switch href — TDD

Workstream A of the position-circle tooltips sprint (green; B/C ride @skip-ped).

The numbered gate-position circles (1-6) gain rich hover tooltips mirroring the My Buds bud tooltip on every surface — and now render on room_gate.html (the GATE VIEW), which showed no circles before (the headline gap).

- _gate_positions(room, user, current_slot): per-circle .tt-pos-* state class (empty / gamer / gamer+bud / me-current / me-also) + data-tt-* payload (@handle via at_handle NOT email, title, seat significator rank/suit, bud shoptalk, deposited #tokens [CARTE slots_claimed else 1], seat-clock cost_current_until expiry). _viewer_current_slot resolves the viewer's acting seat (?seat override or canonical) to split me-current vs me-also.
- room_gate view merges _gate_context so _table_positions renders there; room_view threads ?seat into _role_select_context.
- _table_positions.html: .tt-pos-* appended AFTER role-assigned (keeps the 'gate-slot filled role-assigned' substring + class-before-data-slot regex intact for RoleSelectRenderingTest), data-tt-* attrs, me-also ?seat switch anchor.
- #id_position_tooltip_portal (page-root, position:fixed) + position-tooltip.js (hover/clamp/union-hide modeled on tray-tooltip.js); .tt-sign rank+suit stack; .tt-pos-* circle accents; room-gate pointer-events re-enable.

Tests: 7 PositionTooltipTest + 2 CarteSeatSwitchTest (tokens, me-also href) FTs green; 8 fast render-level ITs (PositionTooltip{,Carte}RenderTest); full suite 1598 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 12:13:09 -04:00
parent 19471662ff
commit 30246cc94a
10 changed files with 509 additions and 17 deletions

View File

@@ -178,19 +178,94 @@ _ROLE_SCRAWL_NAMES = {
}
def _gate_positions(room):
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
def _viewer_current_slot(room, user, seat_param=None):
"""The slot the viewer is currently "acting as": a ?seat=N override when
they own that slot, else their lowest-numbered owned slot (the canonical
seat). None for anon / non-seated viewers. Splits the viewer's own
position circles into me-current (this slot) vs me-also (their other
CARTE-claimed slots)."""
if not getattr(user, "is_authenticated", False):
return None
owned = sorted(
room.gate_slots.filter(gamer=user).values_list("slot_number", flat=True)
)
if not owned:
return None
if seat_param:
try:
n = int(seat_param)
except (TypeError, ValueError):
n = None
if n in owned:
return n
return owned[0]
def _gate_positions(room, user=None, current_slot=None):
"""Return list of per-circle dicts for _table_positions.html.
Carries the legacy keys (slot, role_label, role_assigned) PLUS the rich
tooltip payload (sprint 2026-06-02): a `.tt-pos-*` state class classifying
the occupant relative to `user`, the deposited-token count, the seat-clock
expiry, the seat significator rank/suit, and bud shoptalk. `@handle` +
title are read off `pos.slot.gamer` in the template (at_handle /
active_title_display). `current_slot` is the viewer's acting seat
(`_viewer_current_slot`) — it splits the viewer's own circles me-current
vs me-also."""
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
# of which role each gamer chose — so use count, not role matching.
assigned_count = room.table_seats.exclude(role__isnull=True).count()
return [
{
seats_by_slot = {s.slot_number: s for s in room.table_seats.all()}
authed = getattr(user, "is_authenticated", False)
bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set()
shoptalk_map = {}
if authed:
from apps.billboard.models import BudshipNote
shoptalk_map = {
bn.bud_id: bn.shoptalk
for bn in BudshipNote.objects.filter(user=user)
}
positions = []
for slot in room.gate_slots.order_by("slot_number"):
gamer = slot.gamer
seat = seats_by_slot.get(slot.slot_number)
is_self = authed and gamer is not None and gamer.id == user.id
is_bud = (
authed and gamer is not None and not is_self
and gamer.id in bud_ids
)
is_me_also = is_self and slot.slot_number != current_slot
if gamer is None:
state_class = "tt-pos-empty"
elif is_self:
state_class = "tt-pos-me-also" if is_me_also else "tt-pos-me-current"
elif is_bud:
state_class = "tt-pos-gamer tt-pos-bud"
else:
state_class = "tt-pos-gamer"
# Deposited-token count — CARTE claims many slots on one token.
tokens = 1
if gamer is not None and slot.debited_token_type == Token.CARTE:
carte = gamer.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
tokens = carte.slots_claimed if carte else 1
sig = seat.significator if seat else None
positions.append({
"slot": slot,
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
"role_assigned": slot.slot_number <= assigned_count,
}
for slot in room.gate_slots.order_by("slot_number")
]
"state_class": state_class,
"is_self": is_self,
"is_bud": is_bud,
"is_me_also": is_me_also,
"shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
"tokens": tokens,
"expiry": slot.cost_current_until,
"sign_rank": sig.corner_rank if sig else "",
"sign_suit_icon": sig.suit_icon if sig else "",
})
return positions
def _expire_reserved_slots(room):
@@ -250,7 +325,7 @@ def _expire_lapsed_seats(room):
room.save(update_fields=["gate_status"])
def _gate_context(room, user):
def _gate_context(room, user, seat_param=None):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
@@ -306,12 +381,15 @@ def _gate_context(room, user):
"carte_slots_claimed": carte_slots_claimed,
"carte_nvm_slot_number": carte_nvm_slot_number,
"carte_next_slot_number": carte_next_slot_number,
"gate_positions": _gate_positions(room),
"gate_positions": _gate_positions(
room, user, _viewer_current_slot(room, user, seat_param)
),
"starter_roles": [],
}
def _role_select_context(room, user):
def _role_select_context(room, user, seat_param=None):
current_slot = _viewer_current_slot(room, user, seat_param)
user_seat = None
active_seat = None
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
@@ -391,7 +469,7 @@ def _role_select_context(room, user):
.values_list("slot_number", flat=True)
) if user.is_authenticated else [],
"active_slot": active_slot,
"gate_positions": _gate_positions(room),
"gate_positions": _gate_positions(room, user, current_slot),
"slots": room.gate_slots.order_by("slot_number"),
}
# Viewer's seat token-cost state — drives the center-hex GATE VIEW
@@ -511,7 +589,7 @@ def gatekeeper(request, room_id):
def room_view(request, room_id):
room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
ctx = _role_select_context(room, request.user)
ctx = _role_select_context(room, request.user, request.GET.get("seat"))
ctx["room"] = room
# `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's
# `page-my-sea`) so the table page reaches the renewal gate-view instead
@@ -541,12 +619,19 @@ def room_gate(request, room_id):
user_slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED
).first()
return render(request, "apps/gameboard/room_gate.html", {
# Merge the gatekeeper gate-context so the position circles
# (_table_positions.html) render here too — it supplies gate_positions
# (now rich w. the .tt-pos-* tooltip payload) + every carte_* key the
# partial's slot conditionals read. The renewal modal's own keys override
# below.
ctx = _gate_context(room, request.user, request.GET.get("seat"))
ctx.update({
"room": room,
"cost_current": user_slot.cost_current if user_slot else True,
"deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(),
"page_class": "page-gameboard page-room page-room-gate",
})
return render(request, "apps/gameboard/room_gate.html", ctx)
@login_required