Adds static/js/member-detail.js: fetches /api/<page> once on page load, caches the response, and renders a per-member detail modal on [i] row click. Keyboard nav: Esc closes, ↑/↓ walk visible (filtered) rows. All modal CSS was already in place from M6.1. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
14 KiB
M6.5 — Member-detail modal JS module (/adults, /juniors)
Plan-mode note: per project convention this plan should live at
docs/plans/2026-05-08-HHMM-go-m6-5-modal-js.md(usedate "+%Y-%m-%d-%H%M"when copying). The first execution step after approval is to copy this file there. Branch:feat/go-m6-5-modal-jsoffmain.
Context
The Go port of the Flask app is at milestone M6.5: the /adults and
/juniors pages currently render their tables (go/internal/web/templates/adults.tmpl,
go/internal/web/templates/juniors.tmpl)
but have no row-click member-detail modal — the feature that lets a user click
a member's row to inspect status per month, exception overrides, "other"
transactions, the matched payment list, and a debug raw-payments view.
The Python implementation lives inline in
templates/adults.html:718-993
(matching code in templates/juniors.html — same
modal markup and JS). It uses globals injected via Jinja:
memberData, sortedMonths, monthLabels, rawPaymentsByPerson. M6.5
replaces this with a clean static JS module.
Per milestone wording — "fetches /api/adults (or juniors), renders
status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)" —
and per the user's design choice: the JS module fetches /api/<page> once
on page load and caches the response in a module-scoped variable. Subsequent
row clicks render synchronously from cache. This keeps HTML purely
server-rendered and reuses the parity-tested JSON contract already shipped in
M5.
All the building blocks are already in place:
- JSON API:
/api/adultsand/api/juniorsalready returnmember_data,month_labels,raw_paymentsas nested objects (see go/internal/web/api/adults.go:27-42 and the analogous juniors response). JuniorExpectedserialises as"?"for the unknown sentinel via customMarshalJSON(go/internal/web/api/types.go:24-29) — JS sees a literal string"?". - CSS:
#memberModal,.modal-content,.modal-section,.modal-table,.tx-list,.tx-item,.tx-meta,.tx-amount,.info-icon,.raw-togglewere lifted whole during M6.1 and live in go/internal/web/static/css/app.css (lines 168-487). No CSS work needed. - Member-row hooks:
tr.member-row[data-name="..."]andtd.member-nameare already rendered by both templates. - JS module convention:
static/js/filters.jsis loaded with<script src="/static/js/filters.js" defer>; M6.5 adds a siblingstatic/js/member-detail.jswith the same loading pattern.
Approach
1. Add [i] info icon + #memberModal markup to both templates
In go/internal/web/templates/adults.tmpl and go/internal/web/templates/juniors.tmpl:
a. Inside the <td class="member-name">{{$row.Name}}</td> cell (line 59 of
each template), append the icon button:
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
Using a data-name attribute and a CSS class (rather than onclick="...")
keeps the template free of inline JS — the module wires up the listener
itself.
b. After the closing </div> of the table-container / sections, before the
<script> tag at line 140 (adults) / 120 (juniors), inject the modal
markup. Mirror the Python structure verbatim
(templates/adults.html:644-707):
<div id="memberModal" class="modal" onclick="if (event.target === this) this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title" id="modalMemberName">—</span>
<span id="modalTier" class="modal-tier"></span>
<a href="#" class="modal-close" onclick="event.preventDefault(); document.getElementById('memberModal').classList.remove('active')">[x]</a>
</div>
<div class="modal-section">
<div class="modal-section-title">Status by Month</div>
<table class="modal-table">
<thead><tr><th>Month</th><th>Sessions</th><th>Expected</th><th>Paid</th><th>Status</th></tr></thead>
<tbody id="modalStatusBody"></tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display:none">
<div class="modal-section-title">Exception Overrides</div>
<div id="modalExceptionList" class="tx-list"></div>
</div>
<div class="modal-section" id="modalOtherSection" style="display:none">
<div class="modal-section-title">Other Payments</div>
<div id="modalOtherList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">Matched Transactions</div>
<div id="modalTxList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments (debug)
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display:none"></div>
</div>
</div>
</div>
c. Add the script tag alongside filters.js. To tell the module which API
endpoint to fetch, use a data-page attribute on body (or on the
filter container — already exists). Simplest: add data-page="adults" /
data-page="juniors" to the existing <div id="filterContainer">.
The module reads it on load.
<script src="/static/js/member-detail.js" defer></script>
2. Create go/internal/web/static/js/member-detail.js
A single shared module — both pages use the same DOM contract, so the same
JS works for both. Structure (vanilla ES, no build step, matches filters.js
style — IIFE, 'use strict', no globals leaked):
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const page = container.dataset.page; // "adults" | "juniors"
if (!page) return;
let apiData = null; // cached /api/<page> response
let currentMemberName = null;
// ── Data load ─────────────────────────────────────────────────────────
async function loadData() {
if (apiData) return apiData;
const r = await fetch('/api/' + page);
if (!r.ok) throw new Error('failed to fetch /api/' + page);
apiData = await r.json();
return apiData;
}
// Pre-warm immediately so first click is instant.
loadData().catch(err => console.error('[member-detail]', err));
// ── Modal render ──────────────────────────────────────────────────────
async function showMember(name) { /* port of Python showMemberDetails */ }
function toggleRawPayments(ev) { /* port */ }
function closeModal() { document.getElementById('memberModal').classList.remove('active'); }
function navigateMember(dir) { /* port — walk visible .member-row */ }
// ── Wiring ────────────────────────────────────────────────────────────
document.querySelectorAll('.info-icon[data-name]').forEach(el => {
el.addEventListener('click', ev => {
ev.stopPropagation();
showMember(el.dataset.name);
});
});
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
document.addEventListener('keydown', e => {
const modal = document.getElementById('memberModal');
if (e.key === 'Escape') { closeModal(); return; }
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
});
}());
The four port functions transcribe Python literally, with two adjustments:
data.tier— read directly from the API response (already present asAdultsMemberData.Tier/JuniorsMemberData.Tier).- Junior
expectedmay equal the literal string"?"(sentinel fromExpected.MarshalJSON). The status-row formatter must treat stringexpectedas "unknown / single attendance" and skip the numeric comparisons — same logic Python uses when it sees"?".
navigateMember walks document.querySelectorAll('tr.member-row') filtered
by style.display !== 'none', finds the index whose dataset.name
matches currentMemberName, and calls showMember on the next/previous
visible row. Identical to Python lines 944-962 but uses dataset.name
instead of parsing childNodes[0].textContent.
3. Wire data-page attribute
Append data-page="adults" to the filterContainer div in adults.tmpl,
data-page="juniors" in juniors.tmpl. One-line edit per template.
4. Unit test
Extend go/internal/web/html_handler_test.go
with assertions that the rendered HTML for /adults and /juniors contains:
class="info-icon"with the expecteddata-nameper fixture row,id="memberModal",<script src="/static/js/member-detail.js",data-page="adults"/data-page="juniors"on the filter container.
These are markup-level checks only — the JS module behaviour is not unit tested in Go (would require headless-browser tooling that is out of scope for this milestone). Manual browser verification covers it.
Critical files
| Action | Path |
|---|---|
| edit | go/internal/web/templates/adults.tmpl — add [i] icon, modal markup, data-page, script tag |
| edit | go/internal/web/templates/juniors.tmpl — same |
| new | go/internal/web/static/js/member-detail.js — modal module |
| edit | go/internal/web/html_handler_test.go — markup assertions |
No changes to: app.css (already complete), assets.go (the embed.FS
glob static/js/* already picks up new files), server.go, render.go,
the API handlers, or any domain code.
Reused existing infrastructure
- go/internal/web/api/handler.go —
/api/adults,/api/juniorsalready serve the modal's data - go/internal/web/api/adults.go:27-42, go/internal/web/api/juniors.go — typed responses
- go/internal/web/api/types.go:24-29 —
Expected"?" sentinel marshalling - go/internal/web/static/css/app.css — modal CSS already lifted
- go/internal/web/static/js/filters.js — IIFE /
'use strict'/data-*attribute style to match - templates/adults.html:725-962 — Python reference implementation to port (logic, not literal copy)
Verification
End-to-end:
cd go && make go-build go-test go-lint— all green.make web-go &— boot Go on:8080.- Browser:
http://localhost:8080/adults- Click
[i]next to a member name → modal opens with that member's data. - Status table populated; cells colour-coded (cell-ok / cell-unpaid).
- Exception section visible only if member has exceptions; exception amount shown with "*" override style.
- "Other Payments" section visible only if member has other-purpose transactions.
- Matched transactions list newest-first; raw-payments section starts
hidden,
[show]toggles to[hide]. Esccloses the modal;ArrowDown/ArrowUpwalk visible rows (after applying name filter).- Click outside the
.modal-contentcloses the modal. - Open DevTools Network tab → reload; confirm one request to
/api/adultsfires on page load (data is pre-warmed), no further requests on row click.
- Click
- Browser:
http://localhost:8080/juniors- Same checks; verify
expected = "?"rows render the question-mark status without throwing JS errors.
- Same checks; verify
- Cross-check against Python:
make web-py &→http://localhost:5001/adultsshould look visually equivalent (modulo nav styling) for the same fixture. - After commit:
tea pr create --base main --head feat/go-m6-5-modal-js.
Branching
Per CLAUDE.md: create feat/go-m6-5-modal-js off main,
commit with Co-Authored-By trailer, push with -u, open MR via
tea pr create. Do not merge from CLI. After the user merges in Gitea:
- Add CHANGELOG entry (timestamp via
date "+%Y-%m-%d %H:%M %Z"). - Tick M6.5 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:117 with the merge SHA.
Out of scope (deferred)
/qrmodal — separate#qrModalelement, lives in M6.6 alongside/sync-bank,/flush-cache,/version.- Pay buttons inside the modal — Python's modal does not include Pay-from-modal; current Go pages already render row-level Pay buttons in M6.2/M6.3.
- Modal on
/payments— Python/paymentshas no modal, neither does Go (confirmed in M6.4 plan). - Deep-linking (
/adults#name=Foo) — not in Python, not adding. - Headless-browser JS tests — out of scope for this milestone; manual browser verification per §Verification.