From 309c26f209ccd2695abc22601a9516a14d62aa5e Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 13:14:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(go):=20M6.5=20=E2=80=94=20member-detail=20?= =?UTF-8?q?modal=20JS=20module=20for=20/adults=20and=20/juniors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds static/js/member-detail.js: fetches /api/ 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 --- .../plans/2026-05-08-1259-go-m6-5-modal-js.md | 288 ++++++++++++++++++ go/internal/web/html_handler_test.go | 41 +++ go/internal/web/static/js/member-detail.js | 245 +++++++++++++++ go/internal/web/templates/adults.tmpl | 52 +++- go/internal/web/templates/juniors.tmpl | 52 +++- 5 files changed, 674 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-05-08-1259-go-m6-5-modal-js.md create mode 100644 go/internal/web/static/js/member-detail.js diff --git a/docs/plans/2026-05-08-1259-go-m6-5-modal-js.md b/docs/plans/2026-05-08-1259-go-m6-5-modal-js.md new file mode 100644 index 0000000..591810f --- /dev/null +++ b/docs/plans/2026-05-08-1259-go-m6-5-modal-js.md @@ -0,0 +1,288 @@ +# 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"`, +- ` + {{end}} diff --git a/go/internal/web/templates/juniors.tmpl b/go/internal/web/templates/juniors.tmpl index f5d89b2..5981ffc 100644 --- a/go/internal/web/templates/juniors.tmpl +++ b/go/internal/web/templates/juniors.tmpl @@ -12,7 +12,7 @@ Payments Ledger -
+
@@ -56,7 +56,7 @@ {{range $row := .Data.Results}} - {{$row.Name}} + {{$row.Name}}[i] {{range $i, $cell := $row.Months}} @@ -117,5 +117,53 @@ {{end}} +
+ +
+ + {{end}}