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:
@@ -328,6 +328,8 @@ def _button_group_button(
|
|||||||
title: str = "",
|
title: str = "",
|
||||||
hx_get: str = "",
|
hx_get: str = "",
|
||||||
hx_target: str = "",
|
hx_target: str = "",
|
||||||
|
hx_swap: str = "",
|
||||||
|
hx_confirm: str = "",
|
||||||
) -> Element:
|
) -> Element:
|
||||||
"""Generate a single button-group button (inner <button> inside <a>)."""
|
"""Generate a single button-group button (inner <button> inside <a>)."""
|
||||||
color_classes = _GROUP_BUTTON_COLORS.get(color, _GROUP_BUTTON_COLORS["gray"])
|
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))
|
a_attrs.append(("hx-get", hx_get))
|
||||||
if hx_target:
|
if hx_target:
|
||||||
a_attrs.append(("hx-target", 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(
|
a_attrs.append(
|
||||||
(
|
(
|
||||||
"class",
|
"class",
|
||||||
@@ -361,7 +367,8 @@ def _button_group_button(
|
|||||||
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
def ButtonGroup(buttons: list[dict] | None = None) -> Element:
|
||||||
"""Generate a button group div.
|
"""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
|
Empty dicts (no slot) are silently skipped — matching the template behavior
|
||||||
for conditional buttons (e.g., end-session only when session is active).
|
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", ""),
|
title=btn.get("title", ""),
|
||||||
hx_get=btn.get("hx_get", ""),
|
hx_get=btn.get("hx_get", ""),
|
||||||
hx_target=btn.get("hx_target", ""),
|
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.
|
||||||
@@ -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")
|
||||||
@@ -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 |
@@ -147,6 +147,11 @@ urlpatterns = [
|
|||||||
session.end_session,
|
session.end_session,
|
||||||
name="list_sessions_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/list", session.list_sessions, name="list_sessions"),
|
||||||
path("session/search", session.search_sessions, name="search_sessions"),
|
path("session/search", session.search_sessions, name="search_sessions"),
|
||||||
path(
|
path(
|
||||||
|
|||||||
+33
-1
@@ -144,6 +144,24 @@ def list_sessions(request: HttpRequest, search_string: str = "") -> HttpResponse
|
|||||||
}
|
}
|
||||||
if session.timestamp_end is None
|
if session.timestamp_end is None
|
||||||
else {},
|
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(
|
"href": reverse(
|
||||||
"games:edit_session", args=[session.pk]
|
"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")
|
return redirect("games:list_sessions")
|
||||||
else:
|
else:
|
||||||
if game_id:
|
if game_id:
|
||||||
game = Game.objects.get(id=game_id)
|
game = get_object_or_404(Game, id=game_id)
|
||||||
form = SessionForm(
|
form = SessionForm(
|
||||||
initial={
|
initial={
|
||||||
**initial,
|
**initial,
|
||||||
@@ -379,6 +397,20 @@ def end_session(request: HttpRequest, session_id: int) -> HttpResponse:
|
|||||||
return redirect("games:list_sessions")
|
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
|
@login_required
|
||||||
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
|
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse:
|
||||||
session = get_object_or_404(Session, id=session_id)
|
session = get_object_or_404(Session, id=session_id)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.conf import settings
|
|||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from games.models import Game, GameStatusChange, Platform, Purchase, Session
|
from games.models import Game, GameStatusChange, Platform, Purchase, Session
|
||||||
|
|
||||||
@@ -264,6 +265,47 @@ class RenderedPagesTest(TestCase):
|
|||||||
self.assertIn(self.game.name, html)
|
self.assertIn(self.game.name, html)
|
||||||
self.assertNoEscapedTags(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 --------------------------------------------------------
|
# --- statuschange --------------------------------------------------------
|
||||||
|
|
||||||
def test_statuschange_list_and_delete(self):
|
def test_statuschange_list_and_delete(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user