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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user