Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion common/components/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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",
Expand All @@ -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).
"""
Expand All @@ -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", ""),
)
)

Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 46 additions & 0 deletions e2e/test_session_reset_e2e.py
Original file line number Diff line number Diff line 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")
11 changes: 11 additions & 0 deletions games/templates/icons/reset.html
Original file line number Diff line number Diff line change
@@ -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>
5 changes: 5 additions & 0 deletions games/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@
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(
Expand Down
34 changes: 33 additions & 1 deletion games/views/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions tests/test_rendered_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
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

Expand Down Expand Up @@ -247,6 +248,47 @@ def test_session_row_fragment_via_htmx(self):
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):
Expand Down
Loading