diff --git a/src/apps/applets/utils.py b/src/apps/applets/utils.py index c154648..c61f096 100644 --- a/src/apps/applets/utils.py +++ b/src/apps/applets/utils.py @@ -1,6 +1,16 @@ from apps.applets.models import Applet, UserApplet +def apply_applet_toggle(user, context, checked_slugs): + """Persist applet visibility choices for a given context.""" + for applet in Applet.objects.filter(context=context): + UserApplet.objects.update_or_create( + user=user, + applet=applet, + defaults={"visible": applet.slug in checked_slugs}, + ) + + def applet_context(user, context): ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()} applets = {a.slug: a for a in Applet.objects.filter(context=context)} diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index 17b2b40..421fdf5 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -2,19 +2,15 @@ from django.contrib.auth.decorators import login_required from django.db.models import Max, Q from django.shortcuts import redirect, render -from apps.applets.models import Applet, UserApplet -from apps.applets.utils import applet_context +from apps.applets.utils import applet_context, apply_applet_toggle from apps.drama.models import GameEvent, ScrollPosition -from apps.epic.models import GateSlot, Room, RoomInvite +from apps.epic.models import Room +from apps.epic.utils import rooms_for_user @login_required(login_url="/") def billboard(request): - my_rooms = Room.objects.filter( - Q(owner=request.user) | - Q(gate_slots__gamer=request.user) | - Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING) - ).distinct().order_by("-created_at") + my_rooms = rooms_for_user(request.user).order_by("-created_at") recent_room = ( Room.objects.filter( @@ -50,12 +46,7 @@ def billboard(request): @login_required(login_url="/") def toggle_billboard_applets(request): checked = request.POST.getlist("applets") - for applet in Applet.objects.filter(context="billboard"): - UserApplet.objects.update_or_create( - user=request.user, - applet=applet, - defaults={"visible": applet.slug in checked}, - ) + apply_applet_toggle(request.user, "billboard", checked) if request.headers.get("HX-Request"): return render(request, "apps/billboard/_partials/_applets.html", { "applets": applet_context(request.user, "billboard"), diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index eb8d3b2..04bc787 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -14,10 +14,10 @@ from django.shortcuts import redirect, render from django.utils import timezone from django.views.decorators.csrf import ensure_csrf_cookie -from apps.applets.models import Applet, UserApplet -from apps.applets.utils import applet_context +from apps.applets.utils import applet_context, apply_applet_toggle from apps.dashboard.forms import ExistingNoteItemForm, ItemForm from apps.dashboard.models import Item, Note +from apps.epic.utils import _compute_distinctions from apps.lyric.models import PaymentMethod, Token, User, Wallet @@ -142,12 +142,7 @@ def set_profile(request): @login_required(login_url="/") def toggle_applets(request): checked = request.POST.getlist("applets") - for applet in Applet.objects.filter(context="dashboard"): - UserApplet.objects.update_or_create( - user=request.user, - applet=applet, - defaults={"visible": applet.slug in checked}, - ) + apply_applet_toggle(request.user, "dashboard", checked) if request.headers.get("HX-Request"): return render(request, "apps/dashboard/_partials/_applets.html", { "applets": applet_context(request.user, "dashboard"), @@ -160,18 +155,18 @@ def toggle_applets(request): @login_required(login_url="/") @ensure_csrf_cookie def wallet(request): + free_tokens = list(request.user.tokens.filter( + token_type=Token.FREE, expires_at__gt=timezone.now() + ).order_by("expires_at")) + tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE)) return render(request, "apps/dashboard/wallet.html", { "wallet": request.user.wallet, "pass_token": request.user.tokens.filter(token_type=Token.PASS).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(), - "free_tokens": list(request.user.tokens.filter( - token_type=Token.FREE, expires_at__gt=timezone.now() - ).order_by("expires_at")), - "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), - "free_count": request.user.tokens.filter( - token_type=Token.FREE, expires_at__gt=timezone.now() - ).count(), - "tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(), + "free_tokens": free_tokens, + "tithe_tokens": tithe_tokens, + "free_count": len(free_tokens), + "tithe_count": len(tithe_tokens), "applets": applet_context(request.user, "wallet"), "page_class": "page-wallet", }) @@ -197,12 +192,7 @@ def kit_bag(request): @login_required(login_url="/") def toggle_wallet_applets(request): checked = request.POST.getlist("applets") - for applet in Applet.objects.filter(context="wallet"): - UserApplet.objects.update_or_create( - user=request.user, - applet=applet, - defaults={"visible": applet.slug in checked}, - ) + apply_applet_toggle(request.user, "wallet", checked) if request.headers.get("HX-Request"): return render(request, "apps/wallet/_partials/_applets.html", { "applets": applet_context(request.user, "wallet"), @@ -300,7 +290,6 @@ def _sky_natus_preview(request): except Exception: return HttpResponse(status=502) - from apps.epic.views import _compute_distinctions data = resp.json() if 'elements' in data and 'Earth' in data['elements']: data['elements']['Stone'] = data['elements'].pop('Earth') diff --git a/src/apps/epic/tests/unit/test_utils.py b/src/apps/epic/tests/unit/test_utils.py new file mode 100644 index 0000000..03cf912 --- /dev/null +++ b/src/apps/epic/tests/unit/test_utils.py @@ -0,0 +1,13 @@ +from django.test import SimpleTestCase + +from apps.epic.utils import _planet_house + + +class PlanetHouseFallbackTest(SimpleTestCase): + def test_returns_1_when_no_cusp_matches(self): + # Pathological cusps list: all 12 cusps identical (zero-width arcs). + # No range has start < end, and the wrap-around condition is also + # never satisfied, so the loop exhausts without returning — hitting + # the fallback `return 1`. + cusps = [0.0] * 12 + self.assertEqual(_planet_house(180.0, cusps), 1) diff --git a/src/apps/epic/utils.py b/src/apps/epic/utils.py new file mode 100644 index 0000000..ae55ba1 --- /dev/null +++ b/src/apps/epic/utils.py @@ -0,0 +1,41 @@ +from django.db.models import Q + +from apps.epic.models import Room, RoomInvite + + +def _planet_house(degree, cusps): + """Return 1-based house number for a planet at ecliptic degree. + + cusps is the 12-element list from PySwiss where cusps[i] is the start of + house i+1. Handles the wrap-around case where a cusp crosses 0°/360°. + """ + degree = degree % 360 + for i in range(12): + start = cusps[i] % 360 + end = cusps[(i + 1) % 12] % 360 + if start < end: + if start <= degree < end: + return i + 1 + else: # wrap-around: e.g. cusp at 350° → next at 10° + if degree >= start or degree < end: + return i + 1 + return 1 + + +def _compute_distinctions(planets, houses): + """Return dict {house_number_str: planet_count} for all 12 houses.""" + cusps = houses['cusps'] + counts = {str(i): 0 for i in range(1, 13)} + for planet_data in planets.values(): + h = _planet_house(planet_data['degree'], cusps) + counts[str(h)] += 1 + return counts + + +def rooms_for_user(user): + """Return a queryset of rooms the user owns, has a gate slot in, or is invited to.""" + return Room.objects.filter( + Q(owner=user) | + Q(gate_slots__gamer=user) | + Q(invites__invitee_email=user.email, invites__status=RoomInvite.PENDING) + ).distinct() diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 7f4d9f8..335ad5f 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -22,6 +22,7 @@ from apps.epic.models import ( active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards, select_token, sig_deck_cards, ) +from apps.epic.utils import _compute_distinctions, _planet_house from apps.lyric.models import Token @@ -923,35 +924,6 @@ def tarot_deal(request, room_id): # ── Natus (natal chart) ─────────────────────────────────────────────────────── -def _planet_house(degree, cusps): - """Return 1-based house number for a planet at ecliptic degree. - - cusps is the 12-element list from PySwiss where cusps[i] is the start of - house i+1. Handles the wrap-around case where a cusp crosses 0°/360°. - """ - degree = degree % 360 - for i in range(12): - start = cusps[i] % 360 - end = cusps[(i + 1) % 12] % 360 - if start < end: - if start <= degree < end: - return i + 1 - else: # wrap-around: e.g. cusp at 350° → next at 10° - if degree >= start or degree < end: - return i + 1 - return 1 - - -def _compute_distinctions(planets, houses): - """Return dict {house_number_str: planet_count} for all 12 houses.""" - cusps = houses['cusps'] - counts = {str(i): 0 for i in range(1, 13)} - for planet_data in planets.values(): - h = _planet_house(planet_data['degree'], cusps) - counts[str(h)] += 1 - return counts - - @login_required def natus_preview(request, room_id): """Proxy GET to PySwiss /api/chart/ and augment with distinction counts. diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 0049efa..dc6e02e 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -1,12 +1,11 @@ from django.contrib.auth.decorators import login_required -from django.db.models import Q from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone -from apps.applets.utils import applet_context -from apps.applets.models import Applet, UserApplet -from apps.epic.models import DeckVariant, Room, RoomInvite +from apps.applets.utils import applet_context, apply_applet_toggle +from apps.epic.models import DeckVariant, Room +from apps.epic.utils import rooms_for_user from apps.lyric.models import Token @@ -37,24 +36,18 @@ def gameboard(request): "free_count": len(free_tokens), "applets": applet_context(request.user, "gameboard"), "page_class": "page-gameboard", - "my_games": Room.objects.filter( - Q(owner=request.user) | - Q(gate_slots__gamer=request.user) | - Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING) - ).distinct(), + "my_games": rooms_for_user(request.user), } ) @login_required(login_url="/") def toggle_game_applets(request): checked = request.POST.getlist("applets") - for applet in Applet.objects.filter(context="gameboard"): - UserApplet.objects.update_or_create( - user=request.user, - applet=applet, - defaults={"visible": applet.slug in checked}, - ) + apply_applet_toggle(request.user, "gameboard", checked) if request.headers.get("HX-Request"): + free_tokens = list(request.user.tokens.filter( + token_type=Token.FREE, expires_at__gt=timezone.now() + ).order_by("expires_at")) return render(request, "apps/gameboard/_partials/_applets.html", { "applets": applet_context(request.user, "gameboard"), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, @@ -63,17 +56,9 @@ def toggle_game_applets(request): "equipped_trinket_id": request.user.equipped_trinket_id, "equipped_deck_id": request.user.equipped_deck_id, "deck_variants": list(request.user.unlocked_decks.all()), - "free_tokens": list(request.user.tokens.filter( - token_type=Token.FREE, expires_at__gt=timezone.now() - ).order_by("expires_at")), - "free_count": request.user.tokens.filter( - token_type=Token.FREE, expires_at__gt=timezone.now() - ).count(), - "my_games": Room.objects.filter( - Q(owner=request.user) | - Q(gate_slots__gamer=request.user) | - Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING) - ).distinct(), + "free_tokens": free_tokens, + "free_count": len(free_tokens), + "my_games": rooms_for_user(request.user), }) return redirect("gameboard") @@ -154,12 +139,7 @@ def game_kit(request): @login_required(login_url="/") def toggle_game_kit_sections(request): checked = request.POST.getlist("applets") - for applet in Applet.objects.filter(context="game-kit"): - UserApplet.objects.update_or_create( - user=request.user, - applet=applet, - defaults={"visible": applet.slug in checked}, - ) + apply_applet_toggle(request.user, "game-kit", checked) if request.headers.get("HX-Request"): return render(request, "apps/gameboard/_partials/_game_kit_sections.html", _game_kit_context(request.user)) diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index 746e62f..8e4c277 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -113,10 +113,7 @@ .tt { z-index: 9999; - .tt-title { font-size: 1rem; } - .tt-description { padding: 0.125rem; font-size: 0.75rem; } - .tt-shoptalk { font-size: 0.75rem; opacity: 0.75; } - .tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); } + @extend %tt-token-fields; // Buttons positioned on left edge of the fixed inline tooltip .tt-equip-btns { diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 6b41a9c..122dd0a 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -124,10 +124,7 @@ body.page-gameboard { padding: 0.75rem 1.5rem; - .tt-title { font-size: 1rem; } - .tt-description { padding: 0.125rem; font-size: 0.75rem; } - .tt-shoptalk { font-size: 0.75rem; opacity: 0.75; } - .tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); } + @extend %tt-token-fields; .tt-equip-btns { position: absolute; diff --git a/src/static_src/scss/_tooltips.scss b/src/static_src/scss/_tooltips.scss index bdf9777..ab9e4c3 100644 --- a/src/static_src/scss/_tooltips.scss +++ b/src/static_src/scss/_tooltips.scss @@ -4,6 +4,14 @@ // and override z-index; inline .tt cards use position:absolute within their // parent token container. +// Shared token tooltip field sizes — used by #id_tooltip_portal and .tt +%tt-token-fields { + .tt-title { font-size: 1rem; } + .tt-description { padding: 0.125rem; font-size: 0.75rem; } + .tt-shoptalk { font-size: 0.75rem; opacity: 0.75; } + .tt-expiry { font-size: 1rem; color: rgba(var(--priRd), 1); } +} + .token-tooltip, .tt { display: none;