From c85748b3aa22845181eeb3560fb97cd7df885bb4 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 01:09:47 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(go):=20M6.2=20=E2=80=94=20adults=20pag?= =?UTF-8?q?e=20(table,=20filters,=20credits/debts/unmatched,=20Pay=20butto?= =?UTF-8?q?ns)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 8 ++ ...-05-03-2349-go-backend-rewrite-progress.md | 6 +- ...-05-08-0052-go-rewrite-m6-2-adults-page.md | 131 +++++++++++++++++ go/internal/web/api/handler.go | 15 +- go/internal/web/html_handler.go | 27 +++- go/internal/web/html_handler_test.go | 76 +++++++++- go/internal/web/render.go | 43 +++++- go/internal/web/server.go | 2 +- go/internal/web/static/js/filters.js | 91 ++++++++++++ go/internal/web/templates/adults.tmpl | 134 +++++++++++++++++- 10 files changed, 517 insertions(+), 16 deletions(-) create mode 100644 docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md create mode 100644 go/internal/web/static/js/filters.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7bbfa..6c8ca2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons) + +- `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler. +- `go/internal/web/render.go`: added `AdultsPageData` view model (`PageData` + `api.AdultsResponse` + `Error`); `tmplFuncs` with `qrHref` / `qrHrefAll` (URL-encode QR Platba params, convert YYYY-MM → MM/YYYY). +- `go/internal/web/html_handler.go`: `HTMLHandler` gains `*api.Handler`; `ServeAdults` loads real reconcile data and renders the full adults page. +- `go/internal/web/templates/adults.tmpl`: full table (per-member rows, per-cell status classes, `data-month-idx`, Pay button hrefs to `/qr`), totals row, credits/debts/unmatched sections, filter controls, sheet links. +- `go/internal/web/static/js/filters.js`: name filter (NFD-normalize) + month-range hide/show by `data-month-idx`; future months hidden by default. + ## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS - `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache). diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md index c3133cc..9398b06 100644 --- a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend- **Current milestone:** M6 — Go-native HTML frontend **Started:** 2026-05-04 -**Last updated:** 2026-05-08 (M6.1) +**Last updated:** 2026-05-08 (M6.2) ## How to use @@ -110,8 +110,8 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port. -- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` -- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` +- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059` +- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` - [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering - [ ] **M6.4** `/payments` page: grouped-by-person ledger view - [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) diff --git a/docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md b/docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md new file mode 100644 index 0000000..f2d5e7a --- /dev/null +++ b/docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md @@ -0,0 +1,131 @@ +# Plan: Go rewrite — M6.2 Adults page + +## Context + +M6.1 landed the template skeleton, embed.FS, and HTML routes ([progress §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113), 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](2026-05-03-2349-go-backend-rewrite-progress.md#L114): + +> `/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](../../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](../../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](../../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](../../go/internal/web/server.go#L49) — `mux.HandleFunc("GET /adults", hh.ServeAdults)` already wired. +- [go/internal/web/html_handler.go:16-18](../../go/internal/web/html_handler.go#L16-L18) — placeholder handler renders a `PageData{Active, Build}` shell only. Needs to gain access to the data layer. +- [go/internal/web/render.go:11](../../go/internal/web/render.go#L11) — `PageData` struct 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](../../go/internal/web/api/handler.go#L36-L45) — `ServeAdults` already does `loadAll` → `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](../../go/internal/web/api/build_adults.go#L17) — `buildAdultsResponse(...) AdultsResponse` returns 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](../../go/internal/web/templates/adults.tmpl) — currently a 5-line placeholder. Replace contents. +- [go/internal/web/static/css/app.css](../../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](../../templates/adults.html) (Python source). Sections to mirror in markup terms: +- Reconcile table — [adults.html:534-585](../../templates/adults.html#L534-L585) +- Totals row — [adults.html:571-582](../../templates/adults.html#L571-L582) +- Credits / Debts — [adults.html:587-609](../../templates/adults.html#L587-L609) +- Unmatched — [adults.html:611-629](../../templates/adults.html#L611-L629) +- Filter controls — [adults.html:515-532](../../templates/adults.html#L515-L532) +- Filter JS (name + month range) — [adults.html:1019-1051](../../templates/adults.html#L1019-L1051) + +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](../../go/internal/web/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](../../templates/adults.html#L489-L629). Notable mechanics: + - Filter controls block with ``, ``, `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 `` 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` — `Pay` 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 `` 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](../../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]` ``/`` outside the range. + - `All` button: clear filters, restore all rows/cells. + - Match Python's behaviour byte-for-byte from [adults.html:864-1051](../../templates/adults.html#L864-L1051) but trimmed of modal/`memberData` calls. + - Loaded via `` from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include). +6. **`` 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 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 `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-side** — `AdultsResponse.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](../../app.py#L233) 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](../../templates/adults.html#L868-L873). +- **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](../../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](../../go/internal/web/api/types.go#L91-L122) — `MonthCell`, `TotalCell`, `MemberRow`, `Credit` shapes. +- [go/internal/web/api/handler.go:36-96](../../go/internal/web/api/handler.go#L36-L96) — pattern for `loadAll` + `Reconcile` + `buildAdultsResponse`; the bit to refactor into `AssembleAdults`. +- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — class names available; no edits needed. +- [templates/adults.html:489-629](../../templates/adults.html#L489-L629) — markup reference for filter controls, table, totals, credits/debts, unmatched. +- [templates/adults.html:864-1051](../../templates/adults.html#L864-L1051) — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All). +- [tests/test_app.py:50-75](../../tests/test_app.py#L50-L75) — `test_adults_route` in Python, mirror its assertions in Go: status 200, body contains member name + `750/750 CZK (4)` + `Adults Dashboard`, does not contain `OK`. + +## Verification + +End-to-end smoke after `make go-build`: + +1. `make web-go &` — server boots, no template-parse errors. +2. `curl -i localhost:8080/adults` → `200`, `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-test` — `html_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](2026-05-03-2349-go-backend-rewrite-progress.md#L114) 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: + +```bash +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. diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go index ccd5f3e..18bdc74 100644 --- a/go/internal/web/api/handler.go +++ b/go/internal/web/api/handler.go @@ -34,14 +34,23 @@ func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) { // ServeAdults handles GET /api/adults. func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true) + resp, err := h.AssembleAdults(r.Context()) if err != nil { h.writeError(w, r, err) return } + writeJSON(w, resp) +} + +// AssembleAdults loads all data and builds the adults view model. +// Shared between the JSON API route and the HTML handler. +func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) { + members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true) + if err != nil { + return AdultsResponse{}, err + } result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) - writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01"))) + return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil } // ServeJuniors handles GET /api/juniors. diff --git a/go/internal/web/html_handler.go b/go/internal/web/html_handler.go index 04d22ec..562d889 100644 --- a/go/internal/web/html_handler.go +++ b/go/internal/web/html_handler.go @@ -1,20 +1,35 @@ package web -import "net/http" +import ( + "fuj-management/go/internal/web/api" + "net/http" +) // HTMLHandler serves the Go-native HTML frontend. type HTMLHandler struct { - renderer *Renderer - build BuildInfo + renderer *Renderer + build BuildInfo + apiHandler *api.Handler } // NewHTMLHandler constructs an HTMLHandler. -func NewHTMLHandler(r *Renderer, b BuildInfo) *HTMLHandler { - return &HTMLHandler{renderer: r, build: b} +func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler { + return &HTMLHandler{renderer: r, build: b, apiHandler: ah} } func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) { - h.renderer.Render(w, "adults", PageData{Active: "adults", Build: h.build}) + data, err := h.apiHandler.AssembleAdults(r.Context()) + if err != nil { + h.renderer.Render(w, "adults", AdultsPageData{ + PageData: PageData{Active: "adults", Build: h.build}, + Error: err.Error(), + }) + return + } + h.renderer.Render(w, "adults", AdultsPageData{ + PageData: PageData{Active: "adults", Build: h.build}, + Data: data, + }) } func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/web/html_handler_test.go b/go/internal/web/html_handler_test.go index 5b20ae2..0de63bc 100644 --- a/go/internal/web/html_handler_test.go +++ b/go/internal/web/html_handler_test.go @@ -1,21 +1,61 @@ package web_test import ( + "context" "fmt" + "fuj-management/go/internal/config" + "fuj-management/go/internal/domain/reconcile" "fuj-management/go/internal/web" + "fuj-management/go/internal/web/api" "net/http" "net/http/httptest" "strings" "testing" ) +// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee +// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's +// test_adults_route fixture. +type fixtureSources struct{} + +func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) { + return []reconcile.Member{ + {Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{ + "2026-01": {Expected: 750, Attendance: 4}, + }}, + }, []string{"2026-01"}, nil +} + +func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) { + return nil, nil, nil +} + +func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) { + amt := float64(750) + return []reconcile.Transaction{ + {Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt}, + }, nil +} + +func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) { + return nil, nil +} + +func fixtureHandler(t *testing.T) *api.Handler { + t.Helper() + return &api.Handler{ + Sources: fixtureSources{}, + Config: config.Config{BankAccount: "CZ0000000000000000000000"}, + } +} + func TestHTMLHandlerSmoke(t *testing.T) { renderer, err := web.NewRenderer() if err != nil { t.Fatalf("NewRenderer: %v", err) } b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"} - h := web.NewHTMLHandler(renderer, b) + h := web.NewHTMLHandler(renderer, b, fixtureHandler(t)) cases := []struct { path string @@ -52,3 +92,37 @@ func TestHTMLHandlerSmoke(t *testing.T) { }) } } + +func TestAdultsPage(t *testing.T) { + renderer, err := web.NewRenderer() + if err != nil { + t.Fatalf("NewRenderer: %v", err) + } + b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"} + h := web.NewHTMLHandler(renderer, b, fixtureHandler(t)) + + req := httptest.NewRequest(http.MethodGet, "/adults", nil) + w := httptest.NewRecorder() + h.ServeAdults(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + body := w.Body.String() + + for _, want := range []string{ + "Adults Dashboard", + "Test Member", + "750/750 CZK (4)", // paid/expected (attendance) + } { + if !strings.Contains(body, want) { + t.Errorf("body missing %q", want) + } + } + + // Python assertion: cell text never says literally "OK" + if strings.Contains(body, ">OK<") { + t.Error("body should not contain >OK<") + } +} diff --git a/go/internal/web/render.go b/go/internal/web/render.go index a8ae58c..9101d88 100644 --- a/go/internal/web/render.go +++ b/go/internal/web/render.go @@ -2,9 +2,12 @@ package web import ( "fmt" + "fuj-management/go/internal/web/api" "html/template" "log/slog" "net/http" + "net/url" + "strconv" ) // PageData is the view model passed to every HTML template. @@ -13,6 +16,13 @@ type PageData struct { Build BuildInfo } +// AdultsPageData is the view model for the /adults HTML page. +type AdultsPageData struct { + PageData + Data api.AdultsResponse + Error string +} + // Renderer parses and executes HTML templates from the embedded FS. type Renderer struct { tmpls map[string]*template.Template @@ -20,12 +30,43 @@ type Renderer struct { var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"} +// qrHref builds the /qr query URL for a single-month Pay button. +// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message. +func qrHref(account string, amount int, name, rawMonth string) string { + // Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS. + if len(rawMonth) == 7 && rawMonth[4] == '-' { + rawMonth = rawMonth[5:] + "/" + rawMonth[:4] + } + msg := name + ": " + rawMonth + return "/qr?" + url.Values{ + "account": {account}, + "amount": {strconv.Itoa(amount)}, + "message": {msg}, + }.Encode() +} + +// qrHrefAll builds the /qr query URL for a Pay-All button. +// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods. +func qrHrefAll(account string, amount int, name, rawPeriods string) string { + msg := name + ": " + rawPeriods + return "/qr?" + url.Values{ + "account": {account}, + "amount": {strconv.Itoa(amount)}, + "message": {msg}, + }.Encode() +} + +var tmplFuncs = template.FuncMap{ + "qrHref": qrHref, + "qrHrefAll": qrHrefAll, +} + // NewRenderer parses all templates from the embedded FS. // A parse failure should be treated as a startup-time fatal error. func NewRenderer() (*Renderer, error) { tmpls := make(map[string]*template.Template, len(pageNames)) for _, name := range pageNames { - t, err := template.New("").ParseFS(templateFS, + t, err := template.New("").Funcs(tmplFuncs).ParseFS(templateFS, "templates/base.tmpl", "templates/partials/nav.tmpl", "templates/partials/footer.tmpl", diff --git a/go/internal/web/server.go b/go/internal/web/server.go index df92668..624877b 100644 --- a/go/internal/web/server.go +++ b/go/internal/web/server.go @@ -33,7 +33,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S Config: cfg, Logger: logger, } - hh := NewHTMLHandler(renderer, build) + hh := NewHTMLHandler(renderer, build, ah) staticSubFS, err := fs.Sub(staticFS, "static") if err != nil { diff --git a/go/internal/web/static/js/filters.js b/go/internal/web/static/js/filters.js new file mode 100644 index 0000000..6fc5468 --- /dev/null +++ b/go/internal/web/static/js/filters.js @@ -0,0 +1,91 @@ +// Client-side filters for the Adults (and future Juniors) dashboard table. +// Mirrors adults.html:864-1051 from the Python frontend. +(function () { + 'use strict'; + + // NFD-normalize + strip diacritics + lowercase, matching Python's + // unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode().lower() + function normalize(s) { + return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase(); + } + + const container = document.getElementById('filterContainer'); + if (!container) return; + + const currentMonth = container.dataset.currentMonth || ''; + + const nameInput = document.getElementById('nameFilter'); + const fromSelect = document.getElementById('fromMonth'); + const toSelect = document.getElementById('toMonth'); + const applyBtn = document.getElementById('applyFilter'); + const clearBtn = document.getElementById('clearFilter'); + + // ── Month column visibility ─────────────────────────────────────────────── + + // Hide columns whose raw month is in the future by default. + function hideFutureMonths() { + if (!currentMonth) return; + document.querySelectorAll('[data-raw-month]').forEach(el => { + if (el.dataset.rawMonth > currentMonth) { + el.classList.add('month-hidden'); + } + }); + // Sync toMonth select to the last non-hidden month. + const ths = [...document.querySelectorAll('thead th[data-month-idx]')]; + const visibleIdxs = ths + .filter(th => !th.classList.contains('month-hidden')) + .map(th => parseInt(th.dataset.monthIdx, 10)); + if (visibleIdxs.length) { + toSelect.value = String(visibleIdxs[visibleIdxs.length - 1]); + } + } + + function applyMonthFilter() { + const from = fromSelect.value !== '' ? parseInt(fromSelect.value, 10) : -Infinity; + const to = toSelect.value !== '' ? parseInt(toSelect.value, 10) : Infinity; + + document.querySelectorAll('[data-month-idx]').forEach(el => { + const idx = parseInt(el.dataset.monthIdx, 10); + if (idx < from || idx > to) { + el.classList.add('month-hidden'); + } else { + el.classList.remove('month-hidden'); + } + }); + } + + function clearMonthFilter() { + document.querySelectorAll('[data-month-idx]').forEach(el => { + el.classList.remove('month-hidden'); + }); + fromSelect.value = ''; + toSelect.value = ''; + } + + // ── Name row visibility ─────────────────────────────────────────────────── + + function applyNameFilter() { + const query = normalize(nameInput.value.trim()); + document.querySelectorAll('tr.member-row').forEach(row => { + const name = normalize(row.dataset.name || ''); + row.style.display = (!query || name.includes(query)) ? '' : 'none'; + }); + } + + // ── Event wiring ───────────────────────────────────────────────────────── + + nameInput.addEventListener('input', applyNameFilter); + + applyBtn.addEventListener('click', applyMonthFilter); + + clearBtn.addEventListener('click', function () { + nameInput.value = ''; + applyNameFilter(); + clearMonthFilter(); + hideFutureMonths(); + }); + + // ── Initialise ──────────────────────────────────────────────────────────── + + hideFutureMonths(); +}()); diff --git a/go/internal/web/templates/adults.tmpl b/go/internal/web/templates/adults.tmpl index 7e8b672..ce4ca0b 100644 --- a/go/internal/web/templates/adults.tmpl +++ b/go/internal/web/templates/adults.tmpl @@ -1,5 +1,137 @@ {{define "title"}}Adults{{end}} {{define "content"}}

Adults Dashboard

-

Coming in M6.2

+ +{{if .Error}} +

Error loading data: {{.Error}}

+{{else}} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +{{if .Data.Results}} +
+ + + + + {{range $i, $m := .Data.Months}} + + {{end}} + + + + + + {{range $row := .Data.Results}} + + + {{range $i, $cell := $row.Months}} + + {{end}} + + + + {{end}} + + + {{range $i, $t := .Data.Totals}} + + {{end}} + + + +
Member{{$m}}Balance
{{$row.Name}} + {{$cell.Text}} + {{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}} + Pay + {{end}} + {{$row.Balance}} CZK + {{if gt $row.PayableAmount 0}} + Pay All + {{end}} +
TOTAL{{$t.Text}}
+
+{{else}} +

No members found.

+{{end}} + +{{if .Data.Credits}} +

Credits

+
    + {{range .Data.Credits}} +
  • {{.Name}}: +{{.Amount}} CZK
  • + {{end}} +
+{{end}} + +{{if .Data.Debts}} +

Debts

+
    + {{range .Data.Debts}} +
  • {{.Name}}: −{{.Amount}} CZK
  • + {{end}} +
+{{end}} + +{{if .Data.Unmatched}} +

Unmatched Transactions

+ + + + + + + + + + + {{range .Data.Unmatched}} + + + + + + + {{end}} + +
DateAmountSenderMessage
{{.Date}}{{printf "%.0f" .Amount}} CZK{{.Sender}}{{.Message}}
+{{end}} + +
+ +{{end}} + + {{end}} From daac5d73922151adcbb062206cc4f653a4c1609f Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 10:17:03 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(go):=20adults=20template=20=E2=80=94=20?= =?UTF-8?q?emit=20markup=20that=20the=20lifted=20CSS=20expects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifted-from-Python app.css already styles .list-container/.list-item, .unmatched-row, .balance-pos/.balance-neg, and cell-{status}; the M6.2 template invented .credits-list / .unmatched-table / .balance-cell that had no rules, so those sections rendered unstyled. - Credits / Debts:
  • + (debts red inline). - Unmatched: →
    +
    . - Balance cell: balance-pos / balance-neg with style="position: relative;"; Pay-All button now lives inside it (no separate trailing column). - Total row: cell-{status} + caption span "received / expected" + bold/dark inline styles. - Drop redundant .cell wrapper class; balance value drops trailing "CZK". - Section headings: "Credits (Advance Payments / Surplus)" + "Debts (Missing Payments)". - Source links:
    block under h1 (was at page bottom). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 9 +++ go/internal/web/templates/adults.tmpl | 106 +++++++++++++------------- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c8ca2e..b27ec29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2026-05-08 10:15 CEST — fix(go): adults template — use lifted CSS classes for visual parity + +- Use existing `.balance-pos` / `.balance-neg` (drop invented `balance-cell` / `balance-negative`); Pay-All button now lives inside the balance cell with `position: relative` (matches Python; no separate trailing column). +- Credits / Debts / Unmatched sections rewritten from `
      ` / `
    ` to `
    …` / `
    …` so the lifted CSS actually applies. +- Section headings get the descriptive Python text: "Credits (Advance Payments / Surplus)", "Debts (Missing Payments)". +- Source links moved from page bottom to a `
    ` block under the h1, matching Python's "Source: Attendance Sheet | Payments Ledger". +- Total row uses `cell-{{Status}}` plus the small "received / expected" caption span and Python's inline-styled bold/dark background. +- Drop the redundant `class="cell"` wrapper; debts amount turns red via inline style; balance value drops the trailing "CZK". + ## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons) - `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler. diff --git a/go/internal/web/templates/adults.tmpl b/go/internal/web/templates/adults.tmpl index ce4ca0b..5201d21 100644 --- a/go/internal/web/templates/adults.tmpl +++ b/go/internal/web/templates/adults.tmpl @@ -3,9 +3,15 @@

    Adults Dashboard

    {{if .Error}} -

    Error loading data: {{.Error}}

    +
    Error loading data: {{.Error}}
    {{else}} +
    + Balances calculated by matching Google Sheet payments against attendance fees.
    + Source: Attendance Sheet | + Payments Ledger +
    +
    @@ -30,14 +36,13 @@
    - - + +
    {{if .Data.Results}} -
    -
    +
    @@ -45,7 +50,6 @@ {{end}} - @@ -53,84 +57,82 @@ {{range $i, $cell := $row.Months}} - {{end}} - - {{end}} - - + + {{range $i, $t := .Data.Totals}} - + {{end}} - +
    Member{{$m}}Balance
    {{$row.Name}} + {{$cell.Text}} {{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}} Pay {{end}} {{$row.Balance}} CZK + + {{$row.Balance}} {{if gt $row.PayableAmount 0}} - Pay All + Pay All {{end}}
    TOTAL
    TOTAL{{$t.Text}} + received / expected + {{$t.Text}} +
    -
    {{else}} -

    No members found.

    +
    No members found.
    {{end}} {{if .Data.Credits}} -

    Credits

    -
      +

      Credits (Advance Payments / Surplus)

      +
      {{range .Data.Credits}} -
    • {{.Name}}: +{{.Amount}} CZK
    • +
      + {{.Name}} + {{.Amount}} CZK +
      {{end}} -
    +
    {{end}} {{if .Data.Debts}} -

    Debts

    -
      +

      Debts (Missing Payments)

      +
      {{range .Data.Debts}} -
    • {{.Name}}: −{{.Amount}} CZK
    • +
      + {{.Name}} + {{.Amount}} CZK +
      {{end}} -
    + {{end}} {{if .Data.Unmatched}} -

    Unmatched Transactions

    - - - - - - - - - - - {{range .Data.Unmatched}} - - - - - - - {{end}} - -
    DateAmountSenderMessage
    {{.Date}}{{printf "%.0f" .Amount}} CZK{{.Sender}}{{.Message}}
    +

    Unmatched Transactions

    +
    +
    + Date + Amount + Sender + Message +
    + {{range .Data.Unmatched}} +
    + {{.Date}} + {{printf "%.0f" .Amount}} + {{.Sender}} + {{.Message}} +
    + {{end}} +
    {{end}} - - {{end}} From 464eeeb2b1f9e5b2f69be273fc63afaff17ca4c8 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 10:20:49 +0200 Subject: [PATCH 3/4] fix(go): wrap adults table in
    MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifted CSS defines .table-container with border: 1px solid #333 and max-width: 1200px — without the wrapper the table stretched to full viewport width and showed no border. Mirrors templates/adults.html. Co-Authored-By: Claude Opus 4.7 --- go/internal/web/templates/adults.tmpl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go/internal/web/templates/adults.tmpl b/go/internal/web/templates/adults.tmpl index 5201d21..a3dc249 100644 --- a/go/internal/web/templates/adults.tmpl +++ b/go/internal/web/templates/adults.tmpl @@ -42,6 +42,7 @@
    {{if .Data.Results}} +
    @@ -85,6 +86,7 @@
    +
    {{else}}
    No members found.
    {{end}} From aa0c17f521e1de69afad4bc02399be40170c2028 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 10:33:30 +0200 Subject: [PATCH 4/4] fix(go): align adults cell class names with Python; un-underline Pay buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map unpaid|partial → cell-unpaid (or cell-unpaid-current for current month) and surplus → cell-overridden, matching Python's Jinja logic; avoids emitting non-existent cell-partial/cell-surplus classes that caused Pay buttons to escape the table. - Add text-decoration: none to .pay-btn so anchor-based Pay links don't show the default underline. Co-Authored-By: Claude Opus 4.7 --- go/internal/web/static/css/app.css | 1 + go/internal/web/templates/adults.tmpl | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/go/internal/web/static/css/app.css b/go/internal/web/static/css/app.css index 998030f..4f28cea 100644 --- a/go/internal/web/static/css/app.css +++ b/go/internal/web/static/css/app.css @@ -177,6 +177,7 @@ tr:hover { transform: translateY(-50%); background: #ff3333; color: white; + text-decoration: none; border: none; border-radius: 3px; padding: 2px 6px; diff --git a/go/internal/web/templates/adults.tmpl b/go/internal/web/templates/adults.tmpl index a3dc249..1e29e08 100644 --- a/go/internal/web/templates/adults.tmpl +++ b/go/internal/web/templates/adults.tmpl @@ -59,7 +59,7 @@ {{$row.Name}} {{range $i, $cell := $row.Months}} + class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}"> {{$cell.Text}} {{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}} Pay @@ -77,7 +77,7 @@ TOTAL {{range $i, $t := .Data.Totals}} - + received / expected {{$t.Text}}