diff --git a/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js b/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js index ec90487..f9be5e8 100644 --- a/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js +++ b/src/apps/billboard/static/apps/billboard/my-buds-tooltip.js @@ -28,7 +28,40 @@ _lockedRow.classList.remove('row-locked'); _lockedRow = null; } - if (_portal) _portal.classList.remove('active'); + if (_portal) { + _portal.classList.remove('active'); + // Reset positional props so the next show measures fresh. + _portal.style.top = ''; + _portal.style.bottom = ''; + _portal.style.left = ''; + } + } + + // Clamp the position:fixed portal to the viewport — same 1rem-inset + // shape as game-kit.js / sky-wheel.js / wallet.js. Called AFTER .active + // makes the portal display:block so offsetWidth/Height are real: clamp + // the left edge into [rem, viewport-ttW-rem], then prefer ABOVE the row + // (flip BELOW when the tooltip is too tall to fit above). + function _positionPortal(row) { + if (!_portal) return; + var rect = row.getBoundingClientRect(); + var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + var ttW = _portal.offsetWidth; + var ttH = _portal.offsetHeight; + + var minLeft = rem; + var maxLeft = window.innerWidth - ttW - rem; + var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft)); + _portal.style.left = clampedLeft + 'px'; + + var spaceAbove = rect.top - rem; + if (ttH <= spaceAbove) { + _portal.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; + _portal.style.top = ''; + } else { + _portal.style.top = (rect.bottom + 8) + 'px'; + _portal.style.bottom = ''; + } } function _findSlot(name) { @@ -75,7 +108,10 @@ row.classList.add('row-locked'); _lockedRow = row; _populatePortal(row); - if (_portal) _portal.classList.add('active'); + if (_portal) { + _portal.classList.add('active'); + _positionPortal(row); + } } return; } diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 0456ca5..e4c58c5 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -1251,10 +1251,13 @@ class BudPageAutoAddOnFirstVisitTest(TestCase): class BudPagePendingInviteCascadeTest(TestCase): - """`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired - PENDING SeaInvite exists from this bud (owner) to the viewer (invitee). - Reuses the same template flags `_burger.html` already reads on my_sea - + room — no new template plumbing on bud.html.""" + """`sea_btn_active` + `sea_first_draw_pending` fire iff a *live* SeaInvite + exists from this bud (owner) to the viewer (invitee) — non-terminal + (PENDING or ACCEPTED) AND inside its 24h-from-proffer window OR within 24h + of the viewer's last gate token deposit (user-spec 2026-05-29, via + `SeaInvite.invitee_access_open`). Reuses the same template flags + `_burger.html` already reads on my_sea + room — no new template plumbing + on bud.html.""" def setUp(self): from apps.gameboard.models import SeaInvite @@ -1286,7 +1289,10 @@ class BudPagePendingInviteCascadeTest(TestCase): self.assertTrue(response.context["sea_btn_active"]) self.assertTrue(response.context["sea_first_draw_pending"]) - def test_accepted_invite_does_not_cascade(self): + def test_accepted_invite_within_window_lights_cascade(self): + # New spec: an ACCEPTED invite still inside its 24h window keeps the + # cascade lit (the old design went dark the instant it accepted, so + # the user could never reach the bud's sea from here post-accept). self.SeaInvite.objects.create( owner=self.alice, invitee=self.user, @@ -1296,8 +1302,9 @@ class BudPagePendingInviteCascadeTest(TestCase): response = self.client.get( reverse("billboard:bud_page", args=[self.alice.id]) ) - self.assertIsNone(response.context["pending_invite"]) - self.assertFalse(response.context["sea_btn_active"]) + self.assertIsNotNone(response.context["pending_invite"]) + self.assertTrue(response.context["sea_btn_active"]) + self.assertTrue(response.context["sea_first_draw_pending"]) def test_expired_pending_invite_does_not_cascade(self): inv = self.SeaInvite.objects.create( @@ -1315,6 +1322,41 @@ class BudPagePendingInviteCascadeTest(TestCase): self.assertIsNone(response.context["pending_invite"]) self.assertFalse(response.context["sea_btn_active"]) + def test_stale_accepted_without_deposit_does_not_cascade(self): + inv = self.SeaInvite.objects.create( + owner=self.alice, + invitee=self.user, + invitee_email=self.user.email, + status=self.SeaInvite.ACCEPTED, + ) + self.SeaInvite.objects.filter(pk=inv.pk).update( + created_at=timezone.now() - timezone.timedelta(hours=48), + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNone(response.context["pending_invite"]) + self.assertFalse(response.context["sea_btn_active"]) + + def test_recent_deposit_relights_cascade_past_invite_window(self): + # Invite proffered 3 days ago but a gate token deposit 5h ago re-arms + # the 24h window — the "OR 24h since last token deposit" clause. + inv = self.SeaInvite.objects.create( + owner=self.alice, + invitee=self.user, + invitee_email=self.user.email, + status=self.SeaInvite.ACCEPTED, + token_deposited_at=timezone.now() - timezone.timedelta(hours=5), + ) + self.SeaInvite.objects.filter(pk=inv.pk).update( + created_at=timezone.now() - timezone.timedelta(hours=72), + ) + response = self.client.get( + reverse("billboard:bud_page", args=[self.alice.id]) + ) + self.assertIsNotNone(response.context["pending_invite"]) + self.assertTrue(response.context["sea_btn_active"]) + def test_invite_for_other_invitee_ignored(self): # Pending invite from alice → some other user is irrelevant to ME. other = User.objects.create(email="other@inv.io", username="other") diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index bca67cb..a908c0f 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -603,29 +603,37 @@ def bud_page(request, bud_id): Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses the same template variables `_burger.html` already reads on my_sea + room — server-side conditional renders `glow-handoff` on the burger - + `.active` on the sea sub-btn. The flags fire iff a non-expired - PENDING SeaInvite exists from this bud to the viewer.""" + + `.active` on the sea sub-btn. The flags fire iff a *live* SeaInvite + exists from this bud to the viewer — non-terminal (PENDING or ACCEPTED) + AND inside its 24h-from-proffer window OR within 24h of the viewer's + last gate token deposit (user-spec 2026-05-29, `invitee_access_open`). + Accepting the invite no longer darkens the btn; the cascade now stays + lit across the whole window so the user can reach the bud's sea + (`my_sea_visit` accepts a still-pending invite on GET).""" from django.shortcuts import get_object_or_404 from apps.gameboard.models import SeaInvite bud = get_object_or_404(User, id=bud_id) if bud != request.user and not request.user.buds.filter(id=bud.id).exists(): request.user.buds.add(bud) bn = BudshipNote.objects.filter(user=request.user, bud=bud).first() - pending = ( + live = ( SeaInvite.objects - .filter(owner=bud, invitee=request.user, status=SeaInvite.PENDING) + .filter( + owner=bud, invitee=request.user, + status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED], + ) .order_by("-created_at") .first() ) - if pending is not None and pending.is_expired: - pending = None + if live is not None and not live.invitee_access_open: + live = None return render(request, "apps/billboard/bud.html", { "bud": bud, "shoptalk_text": bn.shoptalk if bn else "", "milestone_dt": bn.edited_at if bn else None, - "pending_invite": pending, - "sea_btn_active": pending is not None, - "sea_first_draw_pending": pending is not None, + "pending_invite": live, + "sea_btn_active": live is not None, + "sea_first_draw_pending": live is not None, "page_class": "page-billbud", }) diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 8007645..f9ad0c9 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -419,3 +419,26 @@ class SeaInvite(models.Model): and self.token_deposited_at is not None and self.left_at is None ) + + @property + def invitee_access_open(self): + """True iff the invitee's bud-page sea sub-btn should be lit AND the + owner's spectator my-sea is reachable — a non-terminal invite within + 24h of being proffered OR within 24h of the invitee's last token + deposit at the owner's visitor gate (user-spec 2026-05-29). + + The deposit clause re-arms the window: as long as the invitee keeps + depositing at the table they keep the door open, independent of the + original 24h invite window. DECLINED / LEFT / EXPIRED are terminal — + the door stays shut. Distinct from `is_expired` (which only models the + PENDING lapse): this is the invitee-facing access window and spans + both PENDING + ACCEPTED.""" + if self.status not in (self.PENDING, self.ACCEPTED): + return False + now = timezone.now() + if now < self.expires_at: + return True + if self.token_deposited_at is not None: + window = timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS) + return now < self.token_deposited_at + window + return False diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index 49fdc8a..9552576 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -43,10 +43,29 @@ class MySeaVisitGuardTest(TestCase): self.client.force_login(stranger) self.assertEqual(self.client.get(self.url).status_code, 403) - def test_pending_invitee_is_forbidden(self): + def test_pending_invitee_is_auto_accepted_on_visit(self): + # Accept-on-GET (user-spec 2026-05-29): following the bud-page sea-btn + # cascade — or the @mailman post-attribution anchor — to the spectator + # table implicitly accepts a still-pending invite, so the page renders + # instead of 403ing the legitimately-invited user. self.invite.status = SeaInvite.PENDING + self.invite.accepted_at = None self.invite.save() self.client.force_login(self.bud) + self.assertEqual(self.client.get(self.url).status_code, 200) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.ACCEPTED) + self.assertIsNotNone(self.invite.accepted_at) + + def test_expired_pending_invitee_is_forbidden(self): + # A lapsed PENDING invite (no deposit, past 24h) is NOT auto-accepted. + self.invite.status = SeaInvite.PENDING + self.invite.accepted_at = None + self.invite.save() + SeaInvite.objects.filter(pk=self.invite.pk).update( + created_at=timezone.now() - timedelta(hours=48), + ) + self.client.force_login(self.bud) self.assertEqual(self.client.get(self.url).status_code, 403) def test_left_invitee_is_forbidden(self): diff --git a/src/apps/gameboard/tests/unit/test_sea_invite.py b/src/apps/gameboard/tests/unit/test_sea_invite.py index 22443c3..a0a6ed1 100644 --- a/src/apps/gameboard/tests/unit/test_sea_invite.py +++ b/src/apps/gameboard/tests/unit/test_sea_invite.py @@ -97,3 +97,68 @@ class SeaInviteStatusMachineTest(SimpleTestCase): left_at=timezone.now(), ) self.assertFalse(inv.is_present) + + # ── invitee_access_open ────────────────────────────────────────────── + # The invitee-facing access window: lights the bud-page sea sub-btn AND + # keeps the owner's spectator my-sea reachable. Open for a non-terminal + # invite within 24h of being proffered OR within 24h of the invitee's + # last gate token deposit (user-spec 2026-05-29). + def test_access_open_for_fresh_pending(self): + inv = self._invite(created_at=timezone.now() - timedelta(hours=1)) + self.assertTrue(inv.invitee_access_open) + + def test_access_open_for_accepted_within_invite_window(self): + # The key fix: an ACCEPTED invite still inside its 24h window keeps + # the sea btn lit (the old design went dark the moment it accepted). + inv = self._invite( + status=SeaInvite.ACCEPTED, + created_at=timezone.now() - timedelta(hours=5), + ) + self.assertTrue(inv.invitee_access_open) + + def test_access_closed_for_stale_pending_without_deposit(self): + inv = self._invite(created_at=timezone.now() - timedelta(hours=25)) + self.assertFalse(inv.invitee_access_open) + + def test_access_closed_for_stale_accepted_without_deposit(self): + inv = self._invite( + status=SeaInvite.ACCEPTED, + created_at=timezone.now() - timedelta(hours=25), + ) + self.assertFalse(inv.invitee_access_open) + + def test_access_open_within_24h_of_last_deposit_past_invite_window(self): + # Invite proffered 3 days ago, but the invitee deposited a token at + # the owner's gate 5h ago — the deposit clause re-arms the window. + inv = self._invite( + status=SeaInvite.ACCEPTED, + created_at=timezone.now() - timedelta(hours=72), + token_deposited_at=timezone.now() - timedelta(hours=5), + ) + self.assertTrue(inv.invitee_access_open) + + def test_access_closed_when_both_windows_lapsed(self): + inv = self._invite( + status=SeaInvite.ACCEPTED, + created_at=timezone.now() - timedelta(hours=72), + token_deposited_at=timezone.now() - timedelta(hours=25), + ) + self.assertFalse(inv.invitee_access_open) + + def test_access_closed_for_declined(self): + inv = self._invite( + status=SeaInvite.DECLINED, + created_at=timezone.now() - timedelta(hours=1), + ) + self.assertFalse(inv.invitee_access_open) + + def test_access_closed_for_left_even_with_recent_deposit(self): + # A BYE is a deliberate exit — the door stays shut even though the + # token deposit is recent. + inv = self._invite( + status=SeaInvite.LEFT, + created_at=timezone.now() - timedelta(hours=1), + token_deposited_at=timezone.now() - timedelta(hours=1), + left_at=timezone.now(), + ) + self.assertFalse(inv.invitee_access_open) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 23bb55c..0a96ef9 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -896,11 +896,26 @@ def my_sea_visit(request, owner_id): their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C = this visitor once present) + the owner's draw read-only via `latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's - hand — `sea_btn_active` forced False.""" + hand — `sea_btn_active` forced False. + + Accept-on-GET (user-spec 2026-05-29): a still-pending, non-expired invite + from `owner` to this user is accepted implicitly on arrival — the bud-page + sea-btn cascade (+ the @mailman post-attribution anchor) both land here, + and the click IS the acceptance. A stranger with no invite still 403s.""" from apps.lyric.models import User + from .models import SeaInvite owner = get_object_or_404(User, id=owner_id) if owner == request.user: return redirect("my_sea") + pending = ( + SeaInvite.objects + .filter(owner=owner, invitee=request.user, status=SeaInvite.PENDING) + .order_by("-created_at").first() + ) + if pending is not None and not pending.is_expired: + pending.status = SeaInvite.ACCEPTED + pending.accepted_at = timezone.now() + pending.save(update_fields=["status", "accepted_at"]) invite = _accepted_visit_invite(owner, request.user) if invite is None: return HttpResponseForbidden() diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index 9aa82fe..be88a22 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -110,6 +110,29 @@ } } +// My Buds rows are `.bud-entry` (`@ the `), NOT `.row-3col`, +// so the row-3col hover/lock highlight above doesn't reach them. Mirror it +// here: hover (mouse) + `.row-locked` (click-lock set by my-buds-tooltip.js) +// fill the row --secUser and flip the --terUser handle to --quiUser, with the +// trailing title brought to the readable --priUser back-of-card colour. The +// (0,3,1) handle rule out-specifies the generic `.applet-list-entry a:hover` +// glow above, so the lit row reads --quiUser even under the cursor. +.applet-list-entry.bud-entry { + border-radius: 0.25rem; + transition: background-color 0.12s ease, color 0.12s ease; + + &:hover, + &.row-locked { + background-color: rgba(var(--secUser), 1); + + .bud-name a { + color: rgba(var(--quiUser), 1); + text-shadow: none; + } + .bud-row-title { color: rgba(var(--priUser), 1); } + } +} + .applet-list-buffer { flex-shrink: 0; height: 0.5rem; diff --git a/src/templates/apps/billboard/bud.html b/src/templates/apps/billboard/bud.html index cdc5a6b..6a0399b 100644 --- a/src/templates/apps/billboard/bud.html +++ b/src/templates/apps/billboard/bud.html @@ -69,14 +69,26 @@ {% if pending_invite %} <script> (function () { + var burger = document.getElementById('id_burger_btn'); var sea = document.getElementById('id_sea_btn'); - if (!sea) return; - // Active sub-btn navigation handler — fires BEFORE burger-btn.js's - // delegated fan handler (target-phase before bubble-up) so the click - // routes to the bud's spectator my-sea page. Burger then closes the - // fan as usual. + if (!burger || !sea) return; + // Glow-handoff machine — the bud-page counterpart of my_sea.html's + // burger → sea_btn cascade, minus the spread-modal stage (the active + // sea sub-btn navigates away instead of opening a modal). The burger + // carries .glow-handoff server-side (sea_first_draw_pending); opening + // it hands the glow off to the sea sub-btn so the nudge rides the + // affordance chain to the click target. + burger.addEventListener('click', function () { + if (!burger.classList.contains('glow-handoff')) return; + burger.classList.remove('glow-handoff'); + sea.classList.add('glow-handoff'); + }); + // Active sea sub-btn click → the bud's spectator my-sea (which accepts + // the invite on GET). Clears the glow but PRESERVES .active. Fires in + // the target phase, BEFORE burger-btn.js's delegated fan-close. sea.addEventListener('click', function () { if (!sea.classList.contains('active')) return; + sea.classList.remove('glow-handoff'); window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/'; }); }());