Files
fuj-management/docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Jan Novak c85748b3aa
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)
- Extract AssembleAdults(ctx) from ServeAdults so HTML and JSON API share one reconcile path.
- HTMLHandler gains *api.Handler; ServeAdults loads real data and renders adults.tmpl.
- AdultsPageData view model + qrHref/qrHrefAll funcMap (URL-encode /qr params, YYYY-MM→MM/YYYY).
- adults.tmpl: full reconcile table, per-cell status classes + cell-unpaid-current, Pay button hrefs,
  totals row, credits/debts/unmatched sections, filter controls, sheet links.
- static/js/filters.js: NFD-normalize name filter + month-range column hiding; future months hidden by default.
- TestAdultsPage asserts member name and cell text against fixture data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:09:47 +02:00

13 KiB

Plan: Go rewrite — M6.2 Adults page

Context

M6.1 landed the template skeleton, embed.FS, and HTML routes (progress §M6.1, commit 78e5059). Every page renders chrome + a "Coming in M6.X" placeholder body. M6.2 fills in /adults — the most data-rich page and the template that drives the rest of the M6 work (juniors mostly mirrors it).

Acceptance criteria, verbatim from progress §M6.2:

/adults page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to /qr

The Go side has done excellent prep: buildAdultsResponse in go/internal/web/api/build_adults.go already produces the full view model (currently consumed only by /api/adults), the wire types in go/internal/web/api/types.go match the Python view-model 1:1, and the CSS is already lifted into go/internal/web/static/css/app.css. M6.2 is therefore mostly template authoring + handler wiring + a small filters script, not a fresh port.

Current Go web state (what we're building on)

Reference for parity: templates/adults.html (Python source). Sections to mirror in markup terms:

The member-detail modal, Pay-preview modal, and JSON-hydrated memberData/rawPaymentsByPerson globals are explicitly out of scope for M6.2 — they belong to M6.5 (modal JS).

Approach

  1. Share data assembly between HTML and JSON — extract ServeAdults's body into (*api.Handler).AssembleAdults(ctx) (AdultsResponse, error) and have ServeAdults call it. The HTML handler then calls the same method, keeping /adults and /api/adults byte-identical in semantics (same loaded data, same reconcile, same view model).
  2. Wire HTMLHandler to data — extend HTMLHandler with an apiHandler *api.Handler field and pass it from Run(). ServeAdults becomes: AssembleAdults → on error render an error body (or 500) → render adults.tmpl with a typed view model wrapping PageData + api.AdultsResponse.
  3. Per-page typed view model — add AdultsPageData{ PageData; Data api.AdultsResponse } in render.go (or a new view.go). Template references .Active, .Build (chrome) and .Data.Results, .Data.Totals, .Data.Months, etc. Keeps the chrome contract for nav/footer untouched.
  4. Author adults.tmpl — port markup from templates/adults.html:489-629. Notable mechanics:
    • Filter controls block with <input id="nameFilter">, <select id="fromMonth">, <select id="toMonth">, Apply / All buttons. Month dropdown options rendered server-side from .Data.Months (no client-side hydration needed for filters).
    • Reconcile table iterates .Data.Results; each row's months iterate row.Months. Cell <td> gets class cell-{{.Status}}, plus cell-unpaid-current when .RawMonth >= .Data.CurrentMonth, plus cell-overridden when .Overridden. Cells carry data-month-idx="{{$i}}" so the filter script can hide columns.
    • Per-cell Pay button visible on hover when (unpaid|partial) and RawMonth < CurrentMonth<a class="pay-btn" href="/qr?...">Pay</a> with the QR query string built server-side via a template helper (see Key design notes).
    • Per-row "Pay All" button when .PayableAmount > 0, same href construction using .UnpaidPeriods for display and .RawUnpaidPeriods for the QR message.
    • Totals <tr> iterates .Data.Totals, classes total-cell-{{.Status}}.
    • Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
  5. Tiny filter script — new go/internal/web/static/js/filters.js:
    • nameFilter input event: NFD-normalize + lowercase + substring match against .member-row [data-name] (or row's first cell text); toggle display:none on non-matches.
    • fromMonth / toMonth change event + Apply button: read selected data-month-idx range, toggle month-hidden class on [data-month-idx] <th>/<td> outside the range.
    • All button: clear filters, restore all rows/cells.
    • Match Python's behaviour byte-for-byte from adults.html:864-1051 but trimmed of modal/memberData calls.
    • Loaded via <script src="/static/js/filters.js" defer></script> from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include).
  6. <a href="/qr?…"> Pay buttons now, modal in M6.5 — the M6.6 milestone adds /qr. Until then, hrefs return 404. M6.5 will layer modal preview behaviour on top by wrapping clicks; the markup stays the same. This is the simplest staged rollout.
  7. Template helper for QR href — add a funcMap to Renderer with qrHref(account, amount, name, month string) string (and a periods-list variant) that builds /qr?account=…&amount=…&message=… with proper URL encoding. Implementation: net/url.Values{}.Encode(). This keeps URL-construction logic out of the template syntax (html/template URL escaping isn't enough — query-param building deserves a Go helper).
  8. Active-link keyActive: "adults" already set; nav highlighting works as-is (verified in M6.1 smoke test).

Files to create / modify

go/internal/web/
├── api/
│   └── handler.go                MODIFY — extract ServeAdults body to AssembleAdults(ctx)
├── html_handler.go               MODIFY — hold *api.Handler; ServeAdults loads + renders
├── render.go                     MODIFY — add AdultsPageData type; add funcMap with qrHref()
├── server.go                     MODIFY — pass *api.Handler into NewHTMLHandler
├── html_handler_test.go          MODIFY — add adults markup-level assertions w/ stub Sources
├── templates/
│   └── adults.tmpl               MODIFY — replace placeholder w/ full table + filters + sections
└── static/
    └── js/
        └── filters.js            NEW — name + month-range client-side filtering

No new packages; no domain or wire-type changes.

Key design notes

  • Reuse buildAdultsResponse verbatim — no parallel "view model" needed. AdultsResponse already has every field the template wants. /adults and /api/adults consume the same struct.
  • Extracting AssembleAdults preserves the parity contract: anything that changes the JSON also changes the HTML, by construction. (It also sets up the same pattern for M6.3 juniors and M6.4 payments.)
  • Filter UX is DOM-driven, no JSON hydration. member_data / raw_payments JSON payloads stay deferred to M6.5 (modal needs them; filters do not).
  • current_month boundary is server-sideAdultsResponse.CurrentMonth (set from time.Now().Format("2006-01") in loadAll) is what the template compares RawMonth against for cell-unpaid-current styling and Pay-button visibility. Same value Python passes through vm["current_month"].
  • html/template autoescaping is sufficient for member names, sender, message, etc. — but Pay-button URLs need explicit url.Values encoding (Czech names have diacritics and spaces). Hence the qrHref funcMap helper.
  • Error rendering: if AssembleAdults fails, render the base shell with an error banner inside content rather than http.Error. Keeps nav visible so the user can navigate away. Match Python's "No data." fallback at app.py:233 for empty results.
  • No JS framework, no bundler — filter script is plain ES2020, ~80 lines, inline-readable. Matches M6.1's "no JS in M6.1" follow-through (we add JS in M6.2, but kept minimal).
  • NFD-normalize in JS via s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase() to match Python's unicodedata.normalize('NFD', ...). Python ref: adults.html:868-873.
  • Filter persistence is out of scope — Python's filters are session-only (no localStorage). Same here.

Critical files to read

Verification

End-to-end smoke after make go-build:

  1. make web-go & — server boots, no template-parse errors.
  2. curl -i localhost:8080/adults200, Content-Type: text/html.
  3. Browser at http://localhost:8080/adults against real data:
    • Table renders with one row per adult member, one column per month, totals row at bottom.
    • Cell colors match /api/adults JSON — pick a Credits member from JSON, confirm green-ish status; pick a Debts member, confirm red-ish.
    • Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
    • Per-cell Pay buttons appear on hover for past-month unpaid/partial cells; href contains /qr?account=...&amount=...&message=.
    • Per-row "Pay All" button shows for members with payable_amount > 0.
  4. Filters:
    • Type a partial member name in #nameFilter → only matching rows visible. Test with diacritics (e.g. nemec matches Němec).
    • Pick fromMonth=2026-02, toMonth=2026-04, click Apply → only those columns visible (table + totals row).
    • Click All → everything restored.
  5. curl -s localhost:8080/adults | grep -F 'Adults Dashboard' matches; grep -F 'Coming in M6.2' does not match.
  6. make go-testhtml_handler_test.go adults assertions pass with stub Sources fixture (replicating test_adults_route from Python).
  7. make go-lint clean.
  8. make parity still green — /api/adults JSON unchanged because AssembleAdults extraction is a pure refactor.
  9. CHANGELOG entry per CLAUDE.md (timestamp via date "+%Y-%m-%d %H:%M %Z").
  10. Tick M6.2 in progress tracker §M6.2 with the merge commit SHA.

Branch & MR

Branch feat/go-m6-2-adults-page per CLAUDE.md branch-per-feature workflow. Commit, push with -u, then:

tea pr create \
  --title "feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)" \
  --description "..." \
  --base main \
  --head feat/go-m6-2-adults-page

Print the PR URL for the user. User merges in Gitea browser.