diff --git a/games/views/session.py b/games/views/session.py
index d3359e2..ea01fa2 100644
--- a/games/views/session.py
+++ b/games/views/session.py
@@ -5,10 +5,9 @@ from django.db.models import Q
from django.http import HttpRequest, HttpResponse
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, redirect
-from django.template.defaultfilters import date as date_filter
from django.urls import reverse
from django.utils import timezone
-from django.utils.safestring import SafeText, mark_safe
+from django.utils.safestring import mark_safe
from common.components import (
A,
@@ -29,8 +28,8 @@ from common.components import (
TableRow,
paginated_table_content,
)
-from common.components.primitives import Span, Td, Tr
-from common.layout import render_page
+from common.layout import NavbarPlaytime, render_page
+from games.views.general import model_counts
from common.time import (
dateformat,
local_strftime,
@@ -309,84 +308,16 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
)
-def _session_row_fragment(session: Session) -> SafeText:
- """A single session
(the old list_sessions.html#session-row partial),
- returned by the inline end/clone-session HTMX endpoints."""
- name_link = A(
- href=reverse("games:view_game", args=[session.game.id]),
- attributes=[
- (
- "class",
- "underline decoration-slate-500 sm:decoration-2 inline-block "
- "truncate max-w-20char group-hover:absolute group-hover:max-w-none "
- "group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 "
- "group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 "
- "group-hover:rounded-xs group-hover:outline-dashed "
- "group-hover:outline-purple-400 group-hover:outline-4 "
- "group-hover:decoration-purple-900 group-hover:text-purple-100",
- ),
- ],
- children=[session.game.name],
+def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse:
+ device_list = Device.objects.order_by("name")
+ counts = model_counts(request)
+ fragment = Fragment(
+ session_row(session, device_list, get_token(request)),
+ NavbarPlaytime(
+ counts["today_played"], counts["last_7_played"], oob=True
+ ),
)
- name_td = Td(
- attributes=[
- (
- "class",
- "px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top "
- "w-24 h-12 group",
- )
- ],
- children=[
- Span(
- attributes=[("class", "inline-block relative")],
- children=[name_link],
- )
- ],
- )
- start_td = Td(
- attributes=[
- ("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell")
- ],
- children=[date_filter(session.timestamp_start, "d/m/Y H:i")],
- )
-
- if not session.timestamp_end:
- end_url = reverse("games:list_sessions_end_session", args=[session.id])
- end_inner: SafeText | str = A(
- href=end_url,
- attributes=[
- ("hx-get", end_url),
- ("hx-target", "closest tr"),
- ("hx-swap", "outerHTML"),
- ("hx-indicator", "#indicator"),
- (
- "onClick",
- "document.querySelector('#last-session-start')"
- ".classList.remove('invisible')",
- ),
- ],
- children=[
- Span(
- attributes=[("class", "text-yellow-300")],
- children=["Finish now?"],
- )
- ],
- )
- elif session.duration_manual:
- end_inner = "--"
- else:
- end_inner = date_filter(session.timestamp_end, "d/m/Y H:i")
- end_td = Td(
- attributes=[
- ("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell")
- ],
- children=[end_inner],
- )
- duration_td = Td(
- attributes=[("class", "px-2 sm:px-4 md:px-6 md:py-2 font-mono")],
- children=[session.duration_formatted()],
- )
- return Tr(children=[name_td, start_td, end_td, duration_td])
+ return HttpResponse(str(fragment))
def clone_session_by_id(session_id: int) -> Session:
@@ -404,9 +335,13 @@ def clone_session_by_id(session_id: int) -> Session:
def new_session_from_existing_session(
request: HttpRequest, session_id: int
) -> HttpResponse:
- session = clone_session_by_id(session_id)
+ clone_session_by_id(session_id)
if request.htmx:
- return HttpResponse(_session_row_fragment(session))
+ # Clone adds a new row whose position depends on sort + pagination,
+ # which a single-row swap cannot place — refresh the list instead.
+ response = HttpResponse(status=204)
+ response["HX-Refresh"] = "true"
+ return response
return redirect("games:list_sessions")
@@ -416,7 +351,7 @@ def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
session.timestamp_end = timezone.now()
session.save()
if request.htmx:
- return HttpResponse(_session_row_fragment(session))
+ return _row_with_navbar(request, session)
return redirect("games:list_sessions")
@@ -426,11 +361,7 @@ def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
session.timestamp_start = timezone.now()
session.save()
if request.htmx:
- # The list table is rebuilt server-side per request; a full refresh
- # avoids swapping in a row fragment whose layout could drift from it.
- response = HttpResponse(status=204)
- response["HX-Refresh"] = "true"
- return response
+ return _row_with_navbar(request, session)
return redirect("games:list_sessions")
diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py
index 40c101d..9912f7b 100644
--- a/tests/test_rendered_pages.py
+++ b/tests/test_rendered_pages.py
@@ -267,7 +267,7 @@ class RenderedPagesTest(TestCase):
def test_reset_session_start_to_now_via_htmx(self):
# The inline "reset start" endpoint sets timestamp_start to now and
- # asks htmx to refresh the list (the table is rebuilt server-side).
+ # returns an in-place row swap plus an OOB navbar update.
running = Session.objects.create(
game=self.game,
timestamp_start=datetime(2020, 1, 1, 10, 0, tzinfo=ZONEINFO),
@@ -277,7 +277,11 @@ class RenderedPagesTest(TestCase):
reverse("games:list_sessions_reset_session_start", args=[running.id]),
HTTP_HX_REQUEST="true",
)
- self.assertEqual(resp["HX-Refresh"], "true")
+ self.assertEqual(resp.status_code, 200)
+ body = resp.content.decode()
+ self.assertIn(f'id="session-row-{running.id}"', body)
+ self.assertIn('id="navbar-playtime"', body)
+ self.assertNotIn("HX-Refresh", resp.headers)
running.refresh_from_db()
self.assertGreaterEqual(running.timestamp_start, before)
diff --git a/tests/test_session_endpoints.py b/tests/test_session_endpoints.py
new file mode 100644
index 0000000..555de40
--- /dev/null
+++ b/tests/test_session_endpoints.py
@@ -0,0 +1,70 @@
+import pytest
+from django.urls import reverse
+from django.utils import timezone
+
+from games.models import Device, Game, Platform, Session
+
+
+@pytest.fixture
+def auth_client(client, django_user_model):
+ user = django_user_model.objects.create_user(username="u", password="p")
+ client.force_login(user)
+ return client
+
+
+@pytest.fixture
+def running_session(db):
+ platform = Platform.objects.create(name="PC")
+ game = Game.objects.create(name="Hades", platform=platform)
+ device = Device.objects.create(name="Deck")
+ return Session.objects.create(
+ game=game, device=device, timestamp_start=timezone.now()
+ )
+
+
+def test_end_session_htmx_returns_row_and_oob_navbar(auth_client, running_session):
+ url = reverse("games:list_sessions_end_session", args=[running_session.pk])
+ response = auth_client.get(url, HTTP_HX_REQUEST="true")
+ body = response.content.decode()
+ assert response.status_code == 200
+ assert f'id="session-row-{running_session.pk}"' in body
+ assert 'id="navbar-playtime"' in body
+ assert 'hx-swap-oob="true"' in body
+ running_session.refresh_from_db()
+ assert running_session.timestamp_end is not None
+
+
+def test_reset_session_start_htmx_returns_row_no_refresh_header(
+ auth_client, running_session
+):
+ original_start = running_session.timestamp_start
+ url = reverse(
+ "games:list_sessions_reset_session_start", args=[running_session.pk]
+ )
+ response = auth_client.get(url, HTTP_HX_REQUEST="true")
+ body = response.content.decode()
+ assert response.status_code == 200
+ assert f'id="session-row-{running_session.pk}"' in body
+ assert 'id="navbar-playtime"' in body
+ assert "HX-Refresh" not in response.headers
+ running_session.refresh_from_db()
+ assert running_session.timestamp_start > original_start
+
+
+def test_clone_htmx_returns_hx_refresh(auth_client, running_session):
+ url = reverse(
+ "games:list_sessions_start_session_from_session",
+ args=[running_session.pk],
+ )
+ before = Session.objects.count()
+ response = auth_client.get(url, HTTP_HX_REQUEST="true")
+ assert response.status_code == 204
+ assert response.headers.get("HX-Refresh") == "true"
+ assert Session.objects.count() == before + 1
+
+
+def test_end_session_non_htmx_redirects(auth_client, running_session):
+ url = reverse("games:list_sessions_end_session", args=[running_session.pk])
+ response = auth_client.get(url)
+ assert response.status_code == 302
+ assert response.url == reverse("games:list_sessions")