Files
fuj-management/docs/plans/2026-05-08-1259-go-m6-5-modal-js.md
Jan Novak 309c26f209
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
feat(go): M6.5 — member-detail modal JS module for /adults and /juniors
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>
2026-05-08 13:14:41 +02:00

289 lines
14 KiB
Markdown

# 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/<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/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 `<script src="/static/js/filters.js" defer>`;
M6.5 adds a sibling `static/js/member-detail.js` with the same loading
pattern.
## Approach
### 1. Add `[i]` info icon + `#memberModal` markup to both templates
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
and [go/internal/web/templates/juniors.tmpl](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:
```gotmpl
<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](templates/adults.html#L644-L707)):
```gotmpl
<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.
```gotmpl
<script src="/static/js/member-detail.js" defer></script>
```
### 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/<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 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"`,
- `<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](go/internal/web/templates/adults.tmpl) — add `[i]` icon, modal markup, `data-page`, script tag |
| edit | [go/internal/web/templates/juniors.tmpl](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](go/internal/web/api/handler.go) — `/api/adults`, `/api/juniors` already serve the modal's data
- [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42), [go/internal/web/api/juniors.go](go/internal/web/api/juniors.go) — typed responses
- [go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29) — `Expected` "?" sentinel marshalling
- [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — modal CSS already lifted
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — IIFE / `'use strict'` / `data-*` attribute style to match
- [templates/adults.html:725-962](templates/adults.html#L725-L962) — Python reference implementation to port (logic, not literal copy)
## Verification
End-to-end:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make web-go &` — boot Go on `:8080`.
3. 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]`.
- `Esc` closes the modal; `ArrowDown` / `ArrowUp` walk visible rows
(after applying name filter).
- Click outside the `.modal-content` closes the modal.
- Open DevTools Network tab → reload; confirm one request to `/api/adults`
fires on page load (data is pre-warmed), no further requests on row
click.
4. Browser: `http://localhost:8080/juniors`
- Same checks; verify `expected = "?"` rows render the question-mark
status without throwing JS errors.
5. Cross-check against Python: `make web-py &` → `http://localhost:5001/adults`
should look visually equivalent (modulo nav styling) for the same fixture.
6. After commit: `tea pr create --base main --head feat/go-m6-5-modal-js`.
## Branching
Per [CLAUDE.md](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](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L117)
with the merge SHA.
## Out of scope (deferred)
- **`/qr` modal** — separate `#qrModal` element, 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 `/payments` has 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.