Files
timetracker/docs/superpowers/specs/2026-06-20-issue-53-session-row-fragment-rebuild-design.md
T
2026-06-20 21:14:39 +02:00

224 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design: Issue #53 — Rebuild `_session_row_fragment` via a shared row builder
**Date:** 2026-06-20
**Issue:** [#53](https://github.com/KucharczykL/timetracker/issues/53)
**Follow-on:** [#55](https://github.com/KucharczykL/timetracker/issues/55) (standardize all session tables on the canonical builder)
## Problem
`_session_row_fragment()` in `games/views/session.py` renders a **4-column** session
`<tr>` (Name, Start, End, Duration) with a hand-built `Tr`, no `id="session-row-{pk}"`.
The live `list_sessions` table is **6 columns** (Name, Date, Duration, Device, Created,
Actions) with a row id and htmx attributes. The fragment cannot be htmx-swapped into the
live table without producing a malformed, un-targetable row.
In practice the fragment is **dead**: every session action button in the UI is a plain
`href` (full-page navigation). The only htmx caller, `reset_session_start`, returns
`204 + HX-Refresh` (the #33 workaround) rather than the fragment. The fragment's htmx
paths in `end_session` and `new_session_from_existing_session` are never exercised, which
is why the drift went unnoticed.
Root cause: the fragment is an independent re-implementation of a session row. Fixed
properly, there must be exactly one source of truth for a session row, reused by both
the table and any htmx fragment.
## Goal
1. One canonical session-row builder shared by `list_sessions` and the htmx fragment — no
duplicated `<tr>` markup, so the two cannot drift.
2. Real in-place htmx row swap for **finish** and **reset-start** actions on the session
list, with the navbar playtime totals kept correct in the same request via an
out-of-band (OOB) swap.
Non-goals (tracked in #55): migrating the game-detail sessions table (4-column, different
shape) onto the canonical builder. It keeps its current full-navigation buttons for now.
## Architecture
### Single source of truth for a session row
`TableRow` (`common/components/primitives.py:894`) is the only place a `<tr>` is built.
The table reaches it through `list_sessions → row dict → paginated_table_content →
SimpleTable → TableRow(data=dict)`. The fix splits the row into two reused units:
- **`session_row_data(session, device_list, csrf_token) -> SessionRowData`** — owns cell
content, `row_id`, and the row's htmx attributes (the dict currently inlined in
`list_sessions`). New function in `games/views/session.py`.
- **`TableRow`** — owns the `<tr>` markup. Unchanged, already shared.
Both consumers go through the same dict builder and the same renderer:
```python
# list_sessions
rows = [session_row_data(s, device_list, csrf_token) for s in sessions]
# → paginated_table_content → SimpleTable → TableRow(data=dict)
# single-row htmx fragment — returns a Node, not a stringified SafeText
def session_row(session, device_list, csrf_token) -> Node:
return TableRow(session_row_data(session, device_list, csrf_token))
```
The fragment is therefore the *same* row the table renders, for a single session. Change
a column once in `session_row_data` and list + fragment move together. The old hand-built
`Tr` (4-column, the `#last-session-start` toggle, the yellow "Finish now?" link) — and the
`_session_row_fragment` helper returning `SafeText` — are deleted entirely.
**Return `Node`, not `SafeText`.** Per the component-system direction, builders return
`Node` objects and stringification happens only at the `HttpResponse` boundary (Django
str-encodes response content automatically — `HttpResponse(node)` already works across the
codebase, e.g. `purchase.py` `HttpResponse(_refund_confirmation_modal(...))`). `TableRow`
already returns an `Element` (a `Node`), so `session_row` returns it directly with no
`str()`/`mark_safe`. The endpoints combine the row and the OOB navbar with `Fragment`
(also a `Node`) and pass that straight to `HttpResponse`.
`session_row_data` reproduces today's `list_sessions` dict exactly:
- `row_id`: `f"session-row-{session.pk}"`
- `hx_trigger`: `"device-changed from:body"`, `hx_get`: `""`, `hx_select`:
`f"#session-row-{session.pk}"`, `hx_swap`: `"outerHTML"` (the existing self-refresh on
device change)
- `cell_data` (6): `NameWithIcon(session=session)`; startend string via `local_strftime`;
`session.duration_formatted_with_mark()`; `SessionDeviceSelector(session, device_list,
csrf_token)`; `session.created_at.strftime(dateformat)`; the `ButtonGroup` of actions.
The action `ButtonGroup` for a running session (`timestamp_end is None`) switches the
**Finish** and **Reset start** buttons from plain `href` to htmx (see below). `ButtonGroup`
already forwards `hx_get`/`hx_target`/`hx_swap`/`hx_confirm` (`primitives.py:367`).
### Named type
```python
class SessionRowData(TypedDict):
row_id: str
hx_trigger: str
hx_get: str
hx_select: str
hx_swap: str
cell_data: list[Node]
```
Defined in `games/views/session.py` (per the project convention to name compound types
passed between functions).
### Navbar playtime as an OOB-swappable component
The navbar's "Today · Last 7 days" totals live inline in the monolithic `Navbar()`
`Safe` f-string (`common/layout.py:228-231`). Finishing or resetting a session changes a
session's duration → game playtime → these totals, so an in-place row swap would leave
them stale.
Extract the `<li>` into a small component with a stable id:
```python
# common/layout.py (or common/components)
def NavbarPlaytime(today_played: str, last_7_played: str, *, oob: bool = False) -> Node:
# <li id="navbar-playtime" [hx-swap-oob="true"]> ...today · last_7... </li>
```
- `Navbar()` embeds `NavbarPlaytime(today_played, last_7_played)` in place of the inline
markup (no visual change).
- htmx endpoints render `NavbarPlaytime(..., oob=True)`, which adds `hx-swap-oob="true"`,
and append it to their response body. htmx applies it to the matching `#navbar-playtime`
regardless of the primary target.
Totals come from the existing `model_counts(request)` (`games/views/general.py:26`), which
already computes `today_played` / `last_7_played`. The endpoints call it after saving.
### Endpoint behavior
All three endpoints keep their non-htmx branch (`redirect("games:list_sessions")`).
| Endpoint | htmx response |
|---|---|
| `end_session` | `HttpResponse(Fragment(session_row(...), NavbarPlaytime(..., oob=True)))` |
| `reset_session_start` | `HttpResponse(Fragment(session_row(...), NavbarPlaytime(..., oob=True)))` |
| `new_session_from_existing_session` (clone) | `204 + HX-Refresh: true` |
- **end / reset** return a `Fragment` Node holding the fresh row plus the OOB navbar in one
response body, passed straight to `HttpResponse` (no manual stringification). The
triggering button targets `#session-row-{pk}` with `hx-swap="outerHTML"`; htmx extracts
the OOB `<li>` and swaps the remainder (the `<tr>`) into the row. `reset_session_start`
drops its current `204 + HX-Refresh` workaround.
- **clone stays on `HX-Refresh`**: it creates a *new* session whose correct position
depends on sort + pagination, which a single-row `outerHTML` swap cannot place. Its htmx
branch returns `204 + HX-Refresh: true` (replacing the dead fragment return). This is a
deliberate, documented exception.
Both `end_session` and `reset_session_start` need `device_list` and a CSRF token to build
the row (for the `SessionDeviceSelector` cell): `Device.objects.order_by("name")` and
`get_token(request)`, mirroring `list_sessions`.
### List buttons → htmx
In `session_row_data`, for a running session:
- **Finish session now**: add `hx_get` = `list_sessions_end_session` URL,
`hx_target` = `f"#session-row-{session.pk}"`, `hx_swap` = `"outerHTML"`. Keep `href` as
a no-JS fallback.
- **Reset start to now**: same `hx_target`/`hx_swap`; keep existing `hx_confirm` and
`href` fallback. (Previously its `hx_get` hit the 204+refresh path; now it swaps the
row.)
Edit, Delete, and the clone/"play" affordances are unchanged.
## Components / files touched
- `games/views/session.py` — add `SessionRowData`, `session_row_data() -> SessionRowData`,
`session_row() -> Node`; delete the old `_session_row_fragment() -> SafeText`; update
`list_sessions` to use the builder; rewire `end_session`, `reset_session_start`,
`new_session_from_existing_session`. Drop the now-unused `SafeText`/`Tr` imports if no
other references remain.
- `common/layout.py` — add `NavbarPlaytime`; use it inside `Navbar()`.
- (If `NavbarPlaytime` is placed in `common/components`, re-export via `__init__.py`.)
## Data flow (finish from the list)
```
click Finish → hx-get end_session (htmx)
→ session.timestamp_end = now; save()
→ model_counts(request) (fresh totals)
→ response body: <tr id=session-row-pk …>(6 cells)</tr>
+ <li id=navbar-playtime hx-swap-oob=true>…</li>
htmx: OOB <li> → #navbar-playtime ; <tr> → #session-row-pk (outerHTML)
→ row shows end time + duration; navbar totals update; no full reload
→ swapped row keeps device-change self-refresh + device selector custom element
```
## Error handling
- Missing session → `get_object_or_404` (unchanged).
- Non-htmx requests → full-page redirect (unchanged), so the feature degrades to the
current behavior without JS.
- `SessionDeviceSelector` custom element re-initializes on swap via its native
`connectedCallback`; its JS module is already loaded by the list page, so no extra
`scripts=` wiring is needed.
## Testing
Unit (`tests/`):
- `session_row_data` returns 6 `cell_data` entries and `row_id == "session-row-{pk}"`,
with the device/created/actions cells present.
- `session_row(...)` is a `Node`; `str(session_row(...))` contains `id="session-row-{pk}"`
and 6 `<td>/<th>` cells (regression against the 4-column drift).
- `NavbarPlaytime(oob=True)` emits `id="navbar-playtime"` and `hx-swap-oob="true"`;
`oob=False` omits the OOB attribute.
View (`tests/`, htmx requests via `HTTP_HX_REQUEST=true`):
- `end_session` (htmx) response body contains `#session-row-{pk}` and an OOB
`#navbar-playtime`; sets `timestamp_end`.
- `reset_session_start` (htmx) likewise; sets `timestamp_start` to ~now; **no**
`HX-Refresh` header.
- `new_session_from_existing_session` (htmx) returns status 204 with `HX-Refresh: true`
and creates a session.
- Non-htmx variants of all three still redirect to the session list.
E2E (`e2e/`):
- From the session list, finish a running session → its row updates in place (end time +
duration) and the navbar "Today · Last 7 days" totals change, with no full page reload.
## Out of scope (→ #55)
`games/views/game.py` `_sessions_section` (4-column game-detail table, different first
column, no Device/Created) keeps its full-navigation `href` buttons. Migrating it onto
`session_row_data` with configurable visible columns is tracked in #55.