- 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>
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:
/adultspage: 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)
- go/internal/web/server.go:49 —
mux.HandleFunc("GET /adults", hh.ServeAdults)already wired. - go/internal/web/html_handler.go:16-18 — placeholder handler renders a
PageData{Active, Build}shell only. Needs to gain access to the data layer. - go/internal/web/render.go:11 —
PageDatastruct is currently{Active, Build}only. Needs to extend or be wrapped by a typed adults view model. - go/internal/web/api/handler.go:36-45 —
ServeAdultsalready doesloadAll→Reconcile→buildAdultsResponse. We will extract that body into an exported method so the HTML handler can reuse it without duplication. - go/internal/web/api/build_adults.go:17 —
buildAdultsResponse(...) AdultsResponsereturns everything the template needs (Months,Results,Totals,Credits,Debts,Unmatched,BankAccount,CurrentMonth, …). We pass this struct straight into the template. - go/internal/web/templates/adults.tmpl — currently a 5-line placeholder. Replace contents.
- go/internal/web/static/css/app.css — all selectors needed (
.cell-ok,.cell-unpaid,.cell-unpaid-current,.cell-overridden,.unmatched-row,.filter-container,.pay-btn,.member-row, …) already present from M6.1.
Reference for parity: templates/adults.html (Python source). Sections to mirror in markup terms:
- Reconcile table — adults.html:534-585
- Totals row — adults.html:571-582
- Credits / Debts — adults.html:587-609
- Unmatched — adults.html:611-629
- Filter controls — adults.html:515-532
- Filter JS (name + month range) — adults.html:1019-1051
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
- Share data assembly between HTML and JSON — extract
ServeAdults's body into(*api.Handler).AssembleAdults(ctx) (AdultsResponse, error)and haveServeAdultscall it. The HTML handler then calls the same method, keeping/adultsand/api/adultsbyte-identical in semantics (same loaded data, same reconcile, same view model). - Wire HTMLHandler to data — extend
HTMLHandlerwith anapiHandler *api.Handlerfield and pass it fromRun().ServeAdultsbecomes:AssembleAdults→ on error render an error body (or 500) → renderadults.tmplwith a typed view model wrappingPageData+api.AdultsResponse. - Per-page typed view model — add
AdultsPageData{ PageData; Data api.AdultsResponse }in render.go (or a newview.go). Template references.Active,.Build(chrome) and.Data.Results,.Data.Totals,.Data.Months, etc. Keeps the chrome contract for nav/footer untouched. - 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/Allbuttons. 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 iteraterow.Months. Cell<td>gets classcell-{{.Status}}, pluscell-unpaid-currentwhen.RawMonth >= .Data.CurrentMonth, pluscell-overriddenwhen.Overridden. Cells carrydata-month-idx="{{$i}}"so the filter script can hide columns. - Per-cell Pay button visible on hover when
(unpaid|partial)andRawMonth < 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.UnpaidPeriodsfor display and.RawUnpaidPeriodsfor the QR message. - Totals
<tr>iterates.Data.Totals, classestotal-cell-{{.Status}}. - Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
- Filter controls block with
- Tiny filter script — new go/internal/web/static/js/filters.js:
nameFilterinputevent: NFD-normalize + lowercase + substring match against.member-row [data-name](or row's first cell text); toggledisplay:noneon non-matches.fromMonth/toMonthchangeevent +Applybutton: read selecteddata-month-idxrange, togglemonth-hiddenclass on[data-month-idx]<th>/<td>outside the range.Allbutton: clear filters, restore all rows/cells.- Match Python's behaviour byte-for-byte from adults.html:864-1051 but trimmed of modal/
memberDatacalls. - 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).
<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.- Template helper for QR href — add a
funcMaptoRendererwithqrHref(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/templateURL escaping isn't enough — query-param building deserves a Go helper). - Active-link key —
Active: "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
buildAdultsResponseverbatim — no parallel "view model" needed.AdultsResponsealready has every field the template wants./adultsand/api/adultsconsume the same struct. - Extracting
AssembleAdultspreserves 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_paymentsJSON payloads stay deferred to M6.5 (modal needs them; filters do not). current_monthboundary is server-side —AdultsResponse.CurrentMonth(set fromtime.Now().Format("2006-01")inloadAll) is what the template comparesRawMonthagainst forcell-unpaid-currentstyling and Pay-button visibility. Same value Python passes throughvm["current_month"].html/templateautoescaping is sufficient for member names, sender, message, etc. — but Pay-button URLs need expliciturl.Valuesencoding (Czech names have diacritics and spaces). Hence theqrHreffuncMap helper.- Error rendering: if
AssembleAdultsfails, render the base shell with an error banner insidecontentrather thanhttp.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'sunicodedata.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
- go/internal/web/api/build_adults.go — full view model already built; the template just iterates over
AdultsResponse. - go/internal/web/api/types.go:91-122 —
MonthCell,TotalCell,MemberRow,Creditshapes. - go/internal/web/api/handler.go:36-96 — pattern for
loadAll+Reconcile+buildAdultsResponse; the bit to refactor intoAssembleAdults. - go/internal/web/static/css/app.css — class names available; no edits needed.
- templates/adults.html:489-629 — markup reference for filter controls, table, totals, credits/debts, unmatched.
- templates/adults.html:864-1051 — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All).
- tests/test_app.py:50-75 —
test_adults_routein Python, mirror its assertions in Go: status 200, body contains member name +750/750 CZK (4)+Adults Dashboard, does not containOK.
Verification
End-to-end smoke after make go-build:
make web-go &— server boots, no template-parse errors.curl -i localhost:8080/adults→200,Content-Type: text/html.- Browser at
http://localhost:8080/adultsagainst real data:- Table renders with one row per adult member, one column per month, totals row at bottom.
- Cell colors match
/api/adultsJSON — pick aCreditsmember from JSON, confirm green-ish status; pick aDebtsmember, confirm red-ish. - Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
- Per-cell
Paybuttons 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.
- Filters:
- Type a partial member name in
#nameFilter→ only matching rows visible. Test with diacritics (e.g.nemecmatchesNěmec). - Pick
fromMonth=2026-02,toMonth=2026-04, clickApply→ only those columns visible (table + totals row). - Click
All→ everything restored.
- Type a partial member name in
curl -s localhost:8080/adults | grep -F 'Adults Dashboard'matches;grep -F 'Coming in M6.2'does not match.make go-test—html_handler_test.goadults assertions pass with stub Sources fixture (replicatingtest_adults_routefrom Python).make go-lintclean.make paritystill green —/api/adultsJSON unchanged becauseAssembleAdultsextraction is a pure refactor.- CHANGELOG entry per CLAUDE.md (timestamp via
date "+%Y-%m-%d %H:%M %Z"). - 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.