# 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` (use `date "+%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-js` off `main`. ## 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/adults.tmpl), [go/internal/web/templates/juniors.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](templates/adults.html#L718-L993) (matching code in [templates/juniors.html](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/` **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/adults` and `/api/juniors` already return `member_data`, `month_labels`, `raw_payments` as nested objects (see [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42) and the analogous juniors response). Junior `Expected` serialises as `"?"` for the unknown sentinel via custom `MarshalJSON` ([go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29)) — JS sees a literal string `"?"`. - **CSS**: `#memberModal`, `.modal-content`, `.modal-section`, `.modal-table`, `.tx-list`, `.tx-item`, `.tx-meta`, `.tx-amount`, `.info-icon`, `.raw-toggle` were lifted whole during M6.1 and live in [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) (lines 168-487). No CSS work needed. - **Member-row hooks**: `tr.member-row[data-name="..."]` and `td.member-name` are already rendered by both templates. - **JS module convention**: `static/js/filters.js` is loaded with ` ``` ### 2. Create [go/internal/web/static/js/member-detail.js](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): ```js (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/ 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 as `AdultsMemberData.Tier` / `JuniorsMemberData.Tier`). - Junior `expected` may equal the literal string `"?"` (sentinel from `Expected.MarshalJSON`). The status-row formatter must treat string `expected` as "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](go/internal/web/html_handler_test.go) with assertions that the rendered HTML for `/adults` and `/juniors` contains: - `class="info-icon"` with the expected `data-name` per fixture row, - `id="memberModal"`, - `