Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9.7 KiB
Design: Issue #53 — Rebuild _session_row_fragment via a shared row builder
Date: 2026-06-20 Issue: #53 Follow-on: #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
- One canonical session-row builder shared by
list_sessionsand the htmx fragment — no duplicated<tr>markup, so the two cannot drift. - 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 inlist_sessions). New function ingames/views/session.py.TableRow— owns the<tr>markup. Unchanged, already shared.
Both consumers go through the same dict builder and the same renderer:
# list_sessions
rows = [session_row_data(s, device_list, csrf_token) for s in sessions]
# → paginated_table_content → SimpleTable → TableRow(data=dict)
# _session_row_fragment
def _session_row_fragment(session, device_list, csrf_token) -> SafeText:
return str(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) is
deleted entirely.
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); start–end string vialocal_strftime;session.duration_formatted_with_mark();SessionDeviceSelector(session, device_list, csrf_token);session.created_at.strftime(dateformat); theButtonGroupof 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
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:
# 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()embedsNavbarPlaytime(today_played, last_7_played)in place of the inline markup (no visual change).- htmx endpoints render
NavbarPlaytime(..., oob=True), which addshx-swap-oob="true", and append it to their response body. htmx applies it to the matching#navbar-playtimeregardless 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 |
TableRow(session_row_data(...)) + NavbarPlaytime(..., oob=True) |
reset_session_start |
TableRow(session_row_data(...)) + NavbarPlaytime(..., oob=True) |
new_session_from_existing_session (clone) |
204 + HX-Refresh: true |
- end / reset return the fresh row plus the OOB navbar fragment in one response body.
The triggering button targets
#session-row-{pk}withhx-swap="outerHTML"; htmx extracts the OOB<li>and swaps the remainder (the<tr>) into the row.reset_session_startdrops its current204 + HX-Refreshworkaround. - clone stays on
HX-Refresh: it creates a new session whose correct position depends on sort + pagination, which a single-rowouterHTMLswap cannot place. Its htmx branch returns204 + 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_sessionURL,hx_target=f"#session-row-{session.pk}",hx_swap="outerHTML". Keephrefas a no-JS fallback. - Reset start to now: same
hx_target/hx_swap; keep existinghx_confirmandhreffallback. (Previously itshx_gethit 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— addSessionRowData,session_row_data(); rewrite_session_row_fragment()to delegate; updatelist_sessionsto use the builder; rewireend_session,reset_session_start,new_session_from_existing_session.common/layout.py— addNavbarPlaytime; use it insideNavbar().- (If
NavbarPlaytimeis placed incommon/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.
SessionDeviceSelectorcustom element re-initializes on swap via its nativeconnectedCallback; its JS module is already loaded by the list page, so no extrascripts=wiring is needed.
Testing
Unit (tests/):
session_row_datareturns 6cell_dataentries androw_id == "session-row-{pk}", with the device/created/actions cells present._session_row_fragmentoutput containsid="session-row-{pk}"and 6<td>/<th>cells (regression against the 4-column drift).NavbarPlaytime(oob=True)emitsid="navbar-playtime"andhx-swap-oob="true";oob=Falseomits 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; setstimestamp_end.reset_session_start(htmx) likewise; setstimestamp_startto ~now; noHX-Refreshheader.new_session_from_existing_session(htmx) returns status 204 withHX-Refresh: trueand 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.