feat(session): reset running session start time to now from list

Adds a confirm-gated button on running sessions in the session list that
sets timestamp_start to now (issue #33). The htmx path returns HX-Refresh;
ButtonGroup gains optional hx_confirm/hx_swap keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:37:44 +02:00
parent bf60a2a06b
commit 2a1585831f
7 changed files with 170 additions and 16 deletions
+10 -1
View File
@@ -328,6 +328,8 @@ def _button_group_button(
title: str = "",
hx_get: str = "",
hx_target: str = "",
hx_swap: str = "",
hx_confirm: str = "",
) -> Element:
"""Generate a single button-group button (inner <button> inside <a>)."""
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
@@ -337,6 +339,10 @@ def _button_group_button(
a_attrs.append(("hx-get", hx_get))
if hx_target:
a_attrs.append(("hx-target", hx_target))
if hx_swap:
a_attrs.append(("hx-swap", hx_swap))
if hx_confirm:
a_attrs.append(("hx-confirm", hx_confirm))
a_attrs.append(
(
"class",
@@ -361,7 +367,8 @@ def _button_group_button(
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
"""Generate a button group div.
Each button dict accepts: href, slot (required), color, title, hx_get, hx_target.
Each button dict accepts: href, slot (required), color, title, hx_get,
hx_target, hx_swap, hx_confirm.
Empty dicts (no slot) are silently skipped — matching the template behavior
for conditional buttons (e.g., end-session only when session is active).
"""
@@ -378,6 +385,8 @@ def ButtonGroup(buttons: list[dict] | None = None) -> Element:
title=btn.get("title", ""),
hx_get=btn.get("hx_get", ""),
hx_target=btn.get("hx_target", ""),
hx_swap=btn.get("hx_swap", ""),
hx_confirm=btn.get("hx_confirm", ""),
)
)
@@ -19,8 +19,8 @@ accidental clicks (the original start time is overwritten).
None`), exactly like the green "Finish session now" button.
- **Appearance:** gray button, new "reset" icon.
- **Behavior:** confirm dialog before resetting; on confirm, sets
`timestamp_start = timezone.now()`, saves, and updates the row in place via
htmx.
`timestamp_start = timezone.now()`, saves, and refreshes the list via htmx so
the new start time shows.
Out of scope: changing the existing Finish/Edit/Delete buttons; resetting end
time; bulk operations.
@@ -36,7 +36,8 @@ registration needed.
### 2. New view — `games/views/session.py`
Mirrors the existing `end_session` view:
Mirrors the existing `end_session` view, but the htmx path returns an empty
`204` with an `HX-Refresh: true` header instead of a row fragment:
```python
@login_required
@@ -45,11 +46,19 @@ def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
session.timestamp_start = timezone.now()
session.save()
if request.htmx:
return HttpResponse(_session_row_fragment(session))
response = HttpResponse(status=204)
response["HX-Refresh"] = "true"
return response
return redirect("games:list_sessions")
```
`_session_row_fragment` already exists and is used by `end_session`.
**Why `HX-Refresh` and not a row swap:** `_session_row_fragment` (used by
`end_session`) renders a legacy 4-column `<tr>` that no longer matches the live
session-list table (6 columns, built inline by `list_sessions`) and carries no
`id="session-row-{pk}"`. Swapping it into the current table would produce a
malformed row. The list table is rebuilt server-side on every request, so a full
htmx refresh is the simplest correct update — and consistent with the existing
Finish button, which also does a full-page navigation.
### 3. New URL — `games/urls.py`
@@ -82,11 +91,12 @@ Finish button:
```python
{
"href": reverse(
"games:list_sessions_reset_session_start", args=[session.pk]
),
"hx_get": reverse(
"games:list_sessions_reset_session_start", args=[session.pk]
),
"hx_target": f"#session-row-{session.pk}",
"hx_swap": "outerHTML",
"hx_confirm": "Reset this session's start time to now?",
"slot": Icon("reset"),
"title": "Reset start to now",
@@ -96,16 +106,15 @@ if session.timestamp_end is None
else {}
```
Placement: directly after the Finish button, before Edit.
Placement: directly after the Finish button, before Edit. `href` is a graceful
fallback (the non-htmx view path redirects); `hx_get` + `hx_confirm` drive the
confirm dialog and htmx refresh when JS is active.
## Rationale: htmx for reset, plain href for Finish
## Rationale: htmx confirm
The reset button is htmx-driven (`hx-get` + `hx-target` + `hx-swap` +
`hx-confirm`) so the confirm dialog and in-place row update come from htmx with
no inline JS consistent with the project's "no inline JS" convention. The
existing Finish button uses a plain `href` (full-page navigation). This minor
inconsistency is left as-is to keep the change focused; the reset view still
returns a redirect for the non-htmx path, so it degrades gracefully.
The confirm dialog comes from htmx's built-in `hx-confirm`, which only fires on
htmx-driven requests — so the button must use `hx-get` (not just `href`). No
inline JS is needed, consistent with the project's conventions.
## Testing
+46
View File
@@ -0,0 +1,46 @@
"""Browser test for the session-list "Reset start to now" button (issue #33).
Drives the real session list against pytest-django's ``live_server``: clicks the
reset button on a running session, accepts the confirm dialog, and asserts the
row's start time is updated in place via htmx.
"""
import datetime as dt
import pytest
from django.urls import reverse
from playwright.sync_api import Page, expect
from games.models import Game, Platform, Session
@pytest.fixture
def authenticated_page(live_server, page: Page, django_user_model) -> Page:
django_user_model.objects.create_user(username="tester", password="secret123")
page.goto(f"{live_server.url}{reverse('login')}")
page.fill('input[name="username"]', "tester")
page.fill('input[name="password"]', "secret123")
page.click('button:has-text("Login")')
page.wait_for_url(f"{live_server.url}/tracker**")
return page
def test_reset_session_start_to_now(authenticated_page: Page, live_server):
page = authenticated_page
platform = Platform.objects.create(name="PC", icon="pc", group="PC")
game = Game.objects.create(name="Reset Game", platform=platform)
session = Session.objects.create(
game=game,
timestamp_start=dt.datetime(2020, 1, 1, 10, 0, tzinfo=dt.timezone.utc),
)
page.goto(f"{live_server.url}{reverse('games:list_sessions')}")
row = page.locator(f"#session-row-{session.id}")
expect(row).to_contain_text("2020")
page.on("dialog", lambda dialog: dialog.accept())
row.locator('button[title="Reset start to now"]').click()
# htmx swaps the row in place; the old 2020 start time is gone.
expect(row).not_to_contain_text("2020")
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-black dark:text-white w-4 h-4">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 313 B

+5
View File
@@ -147,6 +147,11 @@ urlpatterns = [
session.end_session,
name="list_sessions_end_session",
),
path(
"session/start/reset-to-now/from-list/<int:session_id>",
session.reset_session_start,
name="list_sessions_reset_session_start",
),
path("session/list", session.list_sessions, name="list_sessions"),
path("session/search", session.search_sessions, name="search_sessions"),
path(
+32
View File
@@ -144,6 +144,24 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
}
if session.timestamp_end is None
else {},
{
"href": reverse(
"games:list_sessions_reset_session_start",
args=[session.pk],
),
"hx_get": reverse(
"games:list_sessions_reset_session_start",
args=[session.pk],
),
"hx_confirm": (
"Reset this session's start time to now?"
),
"slot": Icon("reset"),
"title": "Reset start to now",
"color": "gray",
}
if session.timestamp_end is None
else {},
{
"href": reverse(
"games:edit_session", args=[session.pk]
@@ -379,6 +397,20 @@ def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
return redirect("games:list_sessions")
@login_required
def reset_session_start(request: HttpRequest, session_id: int) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
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 redirect("games:list_sessions")
@login_required
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
session = get_object_or_404(Session, id=session_id)
+42
View File
@@ -13,6 +13,7 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from games.models import Game, GameStatusChange, Platform, Purchase, Session
@@ -247,6 +248,47 @@ class RenderedPagesTest(TestCase):
self.assertIn(self.game.name, html)
self.assertNoEscapedTags(html)
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).
running = Session.objects.create(
game=self.game,
timestamp_start=datetime(2020, 1, 1, 10, 0, tzinfo=ZONEINFO),
)
before = timezone.now()
resp = self.client.get(
reverse("games:list_sessions_reset_session_start", args=[running.id]),
HTTP_HX_REQUEST="true",
)
self.assertEqual(resp["HX-Refresh"], "true")
running.refresh_from_db()
self.assertGreaterEqual(running.timestamp_start, before)
def test_reset_session_start_redirects_without_htmx(self):
running = Session.objects.create(
game=self.game,
timestamp_start=datetime(2020, 1, 1, 10, 0, tzinfo=ZONEINFO),
)
resp = self.client.get(
reverse("games:list_sessions_reset_session_start", args=[running.id])
)
self.assertRedirects(resp, reverse("games:list_sessions"))
def test_reset_button_only_shown_for_running_sessions(self):
running = Session.objects.create(
game=self.game,
timestamp_start=datetime(2020, 1, 1, 10, 0, tzinfo=ZONEINFO),
)
html = self.get("games:list_sessions").content.decode()
self.assertIn(
reverse("games:list_sessions_reset_session_start", args=[running.id]),
html,
)
self.assertNotIn(
reverse("games:list_sessions_reset_session_start", args=[self.session.id]),
html,
)
# --- statuschange --------------------------------------------------------
def test_statuschange_list_and_delete(self):