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

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 (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/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/adults and /api/juniors already return member_data, month_labels, raw_payments as nested objects (see go/internal/web/api/adults.go:27-42 and the analogous juniors response). Junior Expected serialises as "?" for the unknown sentinel via custom MarshalJSON (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-toggle were 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="..."] 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 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 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 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 — 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

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: 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:

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.