Merge pull request #54 from KucharczykL/feat/issue-33-reset-session-start

feat(session): reset running session start time to now (#33)
This commit is contained in:
2026-06-20 20:48:19 +02:00
committed by GitHub
7 changed files with 285 additions and 2 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", ""),
)
)
@@ -0,0 +1,138 @@
# Reset running session start to now (issue #33)
## Problem
Sometimes a session is started but a sizeable amount of time passes before play
actually begins. The current UX to fix this is: edit the session, press "Set to
now", submit. This is three steps across two pages.
## Goal
Add a one-click button in the session list — next to the existing "Finish
session now", "Edit", and "Delete" buttons — that sets a running session's
`timestamp_start` to the current time. A confirmation dialog protects against
accidental clicks (the original start time is overwritten).
## Scope
- **Visibility:** the button shows only on running sessions (`timestamp_end is
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 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.
## Design
### 1. New icon — `games/templates/icons/reset.html`
A rotate/counterclockwise-arrow SVG signifying "reset". Styled like sibling
icons (`text-black dark:text-white w-4 h-4`). Icons are auto-loaded by file stem
(`common/icons.py`), so `Icon("reset")` resolves once the file exists — no
registration needed.
### 2. New view — `games/views/session.py`
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
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:
response = HttpResponse(status=204)
response["HX-Refresh"] = "true"
return response
return redirect("games:list_sessions")
```
**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`
```python
path(
"session/start/reset-to-now/from-list/<int:session_id>",
session.reset_session_start,
name="list_sessions_reset_session_start",
),
```
### 4. Extend `ButtonGroup` — `common/components/primitives.py`
The button-group button dict currently supports `href`, `slot`, `color`,
`title`, `hx_get`, `hx_target`. Add two optional keys threaded through both
`ButtonGroup()` and `_button_group_button()`:
- `hx_confirm` — emitted as `hx-confirm` on the `<a>`; htmx shows a native
`confirm()` dialog before issuing the request.
- `hx_swap` — emitted as `hx-swap` on the `<a>`; needed so the returned row
fragment replaces the row (`outerHTML`) rather than htmx's default.
Both are additive and optional; existing callers are unaffected. Update the
`ButtonGroup` docstring to list the new keys.
### 5. Button in the session list — `games/views/session.py`
Added to the `ButtonGroup` list in `list_sessions`, guarded the same way as the
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_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 {}
```
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 confirm
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
### Unit (`tests/`)
- `reset_session_start` sets `timestamp_start` to ~now and saves.
- Returns the row fragment when called via htmx; redirects to `list_sessions`
otherwise.
- Session list renders the reset button only for running sessions
(`timestamp_end is None`), not for finished ones.
### E2E (`e2e/`)
- On the session list with a running session, click the reset button, accept the
confirm dialog (`page.on("dialog", lambda d: d.accept())`), and assert the
row's displayed start time updated to ~now.
## No TypeScript build
`hx-confirm` is built into htmx; no new custom element or `.ts` file, so `make
ts` is not required for this change.
+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(
+33 -1
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]
@@ -234,7 +252,7 @@ def add_session(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return redirect("games:list_sessions")
else:
if game_id:
game = Game.objects.get(id=game_id)
game = get_object_or_404(Game, id=game_id)
form = SessionForm(
initial={
**initial,
@@ -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
@@ -264,6 +265,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):