billscroll should now remember user's position across devices

This commit is contained in:
Disco DeDisco
2026-03-24 17:44:34 -04:00
parent a0f8aeb791
commit cde231d43c
9 changed files with 206 additions and 4 deletions

View File

@@ -2,7 +2,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from apps.applets.models import Applet from apps.applets.models import Applet
from apps.drama.models import GameEvent, record from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room from apps.epic.models import Room
from apps.lyric.models import User from apps.lyric.models import User
@@ -133,3 +133,52 @@ class BillscrollViewTest(TestCase):
def test_passes_page_class_billscroll(self): def test_passes_page_class_billscroll(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/") response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["page_class"], "page-billscroll") self.assertEqual(response.context["page_class"], "page-billscroll")
def test_passes_scroll_position_zero_when_none_saved(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 0)
def test_passes_saved_scroll_position_in_context(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 250)
class SaveScrollPositionTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@savescroll.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_post_saves_scroll_position(self):
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 300},
)
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
self.assertEqual(sp.position, 300)
def test_post_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 450},
)
self.assertEqual(
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
)
def test_post_returns_204(self):
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 204)
def test_post_requires_login(self):
self.client.logout()
response = self.client.post(
f"/billboard/room/{self.room.id}/scroll-position/",
{"position": 100},
)
self.assertEqual(response.status_code, 302)

View File

@@ -8,4 +8,5 @@ urlpatterns = [
path("", views.billboard, name="billboard"), path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"), path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"), path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
] ]

View File

@@ -4,7 +4,7 @@ from django.shortcuts import redirect, render
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context from apps.applets.utils import applet_context
from apps.drama.models import GameEvent from apps.drama.models import GameEvent, ScrollPosition
from apps.epic.models import GateSlot, Room, RoomInvite from apps.epic.models import GateSlot, Room, RoomInvite
@@ -61,9 +61,26 @@ def toggle_billboard_applets(request):
def room_scroll(request, room_id): def room_scroll(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
events = room.events.select_related("actor").all() events = room.events.select_related("actor").all()
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
return render(request, "apps/billboard/room_scroll.html", { return render(request, "apps/billboard/room_scroll.html", {
"room": room, "room": room,
"events": events, "events": events,
"viewer": request.user, "viewer": request.user,
"scroll_position": sp.position if sp else 0,
"page_class": "page-billscroll", "page_class": "page-billscroll",
}) })
@login_required(login_url="/")
def save_scroll_position(request, room_id):
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["POST"])
room = Room.objects.get(id=room_id)
position = int(request.POST.get("position", 0))
ScrollPosition.objects.update_or_create(
user=request.user, room=room,
defaults={"position": position},
)
from django.http import HttpResponse
return HttpResponse(status=204)

View File

@@ -83,6 +83,25 @@ class GameEvent(models.Model):
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}" return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}"
class ScrollPosition(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="scroll_positions",
)
room = models.ForeignKey(
"epic.Room", on_delete=models.CASCADE,
related_name="scroll_positions",
)
position = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("user", "room")]
def __str__(self):
return f"{self.user.email} @ {self.room.name}: {self.position}px"
def record(room, verb, actor=None, **data): def record(room, verb, actor=None, **data):
"""Record a game event in the drama log.""" """Record a game event in the drama log."""
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data) return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)

View File

@@ -1,6 +1,7 @@
from django.test import TestCase from django.test import TestCase
from django.db import IntegrityError
from apps.drama.models import GameEvent, record from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room from apps.epic.models import Room
from apps.lyric.models import User from apps.lyric.models import User
@@ -42,3 +43,31 @@ class GameEventModelTest(TestCase):
def test_str_without_actor_shows_system(self): def test_str_without_actor_shows_system(self):
event = record(self.room, GameEvent.ROLES_REVEALED) event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertIn("system", str(event)) self.assertIn("system", str(event))
class ScrollPositionModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_can_save_scroll_position(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
self.assertEqual(ScrollPosition.objects.count(), 1)
self.assertEqual(sp.position, 150)
def test_default_position_is_zero(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
self.assertEqual(sp.position, 0)
def test_unique_per_user_and_room(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
with self.assertRaises(IntegrityError):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
def test_upsert_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
ScrollPosition.objects.update_or_create(
user=self.user, room=self.room,
defaults={"position": 200},
)
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)

View File

@@ -1,3 +1,5 @@
import time
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
@@ -123,6 +125,63 @@ class BillboardScrollTest(FunctionalTest):
self.assertIn("other", theirs_events[0].text) self.assertIn("other", theirs_events[0].text)
class BillscrollPositionTest(FunctionalTest):
"""
FT: the user's scroll position in a billscroll is saved to the server
and restored when they return to the same scroll from any device/session.
"""
def setUp(self):
super().setUp()
self.founder = User.objects.create(email="founder@scrollpos.io")
self.room = Room.objects.create(name="Persistent Chamber", owner=self.founder)
# Enough events to make #id_drama_scroll scrollable
for i in range(20):
record(
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
slot_number=(i % 6) + 1, token_type="coin",
token_display=f"Coin-{i}", renewal_days=7,
)
def test_scroll_position_persists_across_sessions(self):
# 1. Log in and navigate to the room's billscroll
self.create_pre_authenticated_session("founder@scrollpos.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
scroll_el = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
# 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase),
# set position, and dispatch scroll event to trigger the debounced save
target = 100
self.browser.execute_script("""
var el = arguments[0];
el.style.overflow = 'auto';
el.style.height = '150px';
el.scrollTop = arguments[1];
el.dispatchEvent(new Event('scroll'));
""", scroll_el, target)
# 3. Wait for debounce (800ms) + fetch to complete
time.sleep(3)
# 4. Navigate away and back in a fresh session
self.browser.get(self.live_server_url + "/billboard/")
self.create_pre_authenticated_session("founder@scrollpos.io")
self.browser.get(
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
)
# 5. The saved position is reflected in the page's data attribute
scroll_el = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
)
restored = int(scroll_el.get_attribute("data-scroll-position"))
self.assertEqual(restored, target)
class BillboardAppletsTest(FunctionalTest): class BillboardAppletsTest(FunctionalTest):
""" """
FT: billboard page renders three applets in the grid — My Scrolls, FT: billboard page renders three applets in the grid — My Scrolls,

View File

@@ -2,3 +2,30 @@
<h2>{{ room.name }}</h2> <h2>{{ room.name }}</h2>
{% include "core/_partials/_scroll.html" %} {% include "core/_partials/_scroll.html" %}
</section> </section>
<script>
(function() {
var scroll = document.getElementById('id_drama_scroll');
if (!scroll) return;
// Restore saved position
scroll.scrollTop = {{ scroll_position }};
// Debounced save on scroll
var saveTimer;
scroll.addEventListener('scroll', function() {
clearTimeout(saveTimer);
saveTimer = setTimeout(function() {
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
var token = csrfToken ? csrfToken.value : '';
fetch("{% url 'billboard:save_scroll_position' room.id %}", {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': token,
},
body: 'position=' + Math.round(scroll.scrollTop),
});
}, 800);
});
})();
</script>

View File

@@ -4,6 +4,7 @@
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %} {% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
{% block content %} {% block content %}
{% csrf_token %}
<div class="billscroll-page"> <div class="billscroll-page">
{% include "apps/billboard/_partials/_applet-billboard-scroll.html" %} {% include "apps/billboard/_partials/_applet-billboard-scroll.html" %}
</div> </div>

View File

@@ -1,5 +1,5 @@
{% load lyric_extras %} {% load lyric_extras %}
<section id="id_drama_scroll" class="drama-scroll"> <section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
{% for event in events %} {% for event in events %}
<div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}"> <div class="drama-event {% if event.actor == viewer %}mine{% else %}theirs{% endif %}">
<span class="event-body"> <span class="event-body">