From cb8a09b571a49010991c44a6dfd0a84b05986207 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Fri, 8 May 2026 12:29:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(go):=20M6.4=20=E2=80=94=20Go-native=20/pay?= =?UTF-8?q?ments=20page=20(grouped-by-person=20ledger)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract AssemblePayments(ctx) from ServePayments in api/handler.go, mirroring the AssembleAdults/AssembleJuniors pattern - Add PaymentsPageData view-model wrapper in render.go - Rewire html_handler.go ServePayments to call AssemblePayments and render with PaymentsPageData - Replace payments.tmpl placeholder with real grouped-by-person ledger: alphabetical member blocks, txn-table (Date/Amount/Purpose/Message), newest-first rows, Unmatched/Unknown bucket - Append ledger CSS classes to app.css (.ledger-container, .member-block, .txn-table, .txn-date/amount/purpose/message, tr:hover) - Add TestPaymentsPage markup test Co-Authored-By: Claude Opus 4.7 --- .../2026-05-08-1220-go-m6-4-payments-page.md | 215 ++++++++++++++++++ go/internal/web/api/handler.go | 17 +- go/internal/web/html_handler.go | 13 +- go/internal/web/html_handler_test.go | 31 +++ go/internal/web/render.go | 7 + go/internal/web/static/css/app.css | 56 +++++ go/internal/web/templates/payments.tmpl | 35 ++- 7 files changed, 368 insertions(+), 6 deletions(-) create mode 100644 docs/plans/2026-05-08-1220-go-m6-4-payments-page.md diff --git a/docs/plans/2026-05-08-1220-go-m6-4-payments-page.md b/docs/plans/2026-05-08-1220-go-m6-4-payments-page.md new file mode 100644 index 0000000..ec5b9a9 --- /dev/null +++ b/docs/plans/2026-05-08-1220-go-m6-4-payments-page.md @@ -0,0 +1,215 @@ +# M6.4 — Go-native `/payments` page (grouped-by-person ledger) + +> Plan-mode note: per project convention this plan should live at +> `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution +> step after approval is to copy this file there. + +## Context + +The Go port of the Flask app is at milestone **M6.4**: replace the placeholder +`/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)) +with a feature-equivalent grouped-by-person ledger view, matching the Python +[templates/payments.html](templates/payments.html) page. + +Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally +much simpler**: + +- No filters (no name input, no month-range selects). +- No totals / credits / debts / unmatched-as-extra-section. +- No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's + payments page itself has none). +- No QR / Pay buttons. +- Single grouping dimension: **by person**, sorted alphabetically, with a + synthetic `"Unmatched / Unknown"` bucket appended. +- Each person's transactions sorted **newest-first**. +- Columns: Date, Amount (right-aligned, green), Purpose, Bank Message. + +The JSON API side is already done (`/api/payments` at +[go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)), +returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL, +PaymentsURL }`. The HTML side just needs to consume it. + +## Approach + +Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed +`PageData` wrapper, render a real template, lift the page-specific CSS into the +shared stylesheet. + +### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)` + +In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor +`ServePayments` (currently inlines the load+build at lines 78-86) to mirror +`AssembleAdults` / `AssembleJuniors`: + +```go +func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) { + resp, err := h.AssemblePayments(r.Context()) + if err != nil { h.writeError(w, r, err); return } + writeJSON(w, resp) +} + +func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) { + txns, err := h.Sources.LoadTransactions(ctx) + if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) } + return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil +} +``` + +Pure refactor — `make parity` should report zero diffs on `/api/payments`. + +### 2. Add `PaymentsPageData` wrapper + +In [go/internal/web/render.go](go/internal/web/render.go), alongside +`AdultsPageData` / `JuniorsPageData`: + +```go +type PaymentsPageData struct { + PageData + Data api.PaymentsResponse + Error string +} +``` + +No changes needed to `pageNames` — `"payments"` is already registered. + +### 3. Rewire HTML handler + +Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52): + +```go +func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) { + data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} } + resp, err := h.apiHandler.AssemblePayments(r.Context()) + if err != nil { data.Error = err.Error() } else { data.Data = resp } + h.renderer.Render(w, "payments", data) +} +``` + +### 4. Replace template stub + +Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) +(currently a 5-line placeholder). Structure: + +```gotmpl +{{define "title"}}Payments Ledger{{end}} +{{define "content"}} +

Payments Ledger

+

+ All bank transactions from the + payments sheet, + grouped by member. Names are matched against the + attendance sheet. +

+{{if .Error}}

{{.Error}}

{{end}} +
+{{range $person := .Data.SortedPeople}} +
+

{{$person}}

+ + + + + + + + + {{range $tx := index $.Data.GroupedPayments $person}} + + + + + + + {{end}} + +
DateAmountPurposeBank Message
{{$tx.Date}}{{printf "%.0f" $tx.Amount}} CZK{{$tx.Purpose}}{{$tx.Message}}
+
+{{end}} +
+{{end}} +``` + +Notes: +- `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints + the float as-is, but bank-row amounts are always whole CZK). If the user + prefers exact Python parity (`750.0 CZK`), drop the `printf` and use + `{{$tx.Amount}}`. +- Iterating a `map` requires using `.Data.SortedPeople` to drive order then + `index $.Data.GroupedPayments $person` — Go templates don't preserve map + insertion order. + +### 5. Add CSS classes to `app.css` + +Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164) +to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css): +`.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`, +`.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The +terminal-green palette already matches. + +### 6. Unit test + +Add a markup-level test to `go/internal/web/html_handler_test.go` (the file +created in M6.2/M6.3) following the same pattern: stub `Sources` returning a +fixed transaction list, assert response is HTML 200, assert key strings appear +(person names as `

`, "Payments Ledger" `

`, expected amount, "Unmatched +/ Unknown" bucket when an unattributed tx exists). + +## Critical files + +| Action | Path | +|---------|------| +| edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` | +| edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` | +| edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` | +| rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template | +| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS | +| edit | `go/internal/web/html_handler_test.go` — payments markup test | + +No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`, +or any domain code — these were completed in M5 / M6.1. + +## Reused existing helpers + +- [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged) +- [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort +- [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames` +- [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link + +## Verification + +End-to-end: + +1. `cd go && make go-build go-test go-lint` — all green. +2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on + `/api/payments`. +3. `make web-go &` then: + - `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`. + - Browser load `http://localhost:8080/payments`: + - "Payments Ledger" heading + description with two clickable sheet links. + - Alphabetical member blocks, each with a table. + - Amounts right-aligned, green, "CZK" suffix. + - Newest-first ordering inside each block. + - "Unmatched / Unknown" bucket sorts in alphabetical position (capital U → + near end). + - Hover on a row darkens the background. + - Nav highlights `[Payments Ledger]` as the active link. + - Cross-check with Python: `make web-py &` → `http://localhost:5001/payments` + should look visually identical except for nav styling. +4. `make web-py & make web-go &` then `tea pr create` once committed (per + project branch-per-feature workflow). + +## Branching + +Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`. +Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user +confirms it works, tick **M6.4** in +[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116) +with the merge SHA after the user merges in Gitea. + +## Out of scope (deferred) + +- **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either). +- **Period selector** — `/payments` has none in Python; not adding one. +- **embed.FS deploy verification** — M6.7. +- **Shared partial extraction** — payments structurally diverges from + adults/juniors enough that there's no obvious common partial to lift; defer. diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go index cad3258..4a3b880 100644 --- a/go/internal/web/api/handler.go +++ b/go/internal/web/api/handler.go @@ -76,13 +76,22 @@ func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) // ServePayments handles GET /api/payments. func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - txns, err := h.Sources.LoadTransactions(ctx) + resp, err := h.AssemblePayments(r.Context()) if err != nil { - h.writeError(w, r, fmt.Errorf("load transactions: %w", err)) + h.writeError(w, r, err) return } - writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx))) + writeJSON(w, resp) +} + +// AssemblePayments loads transactions and builds the payments view model. +// Shared between the JSON API route and the HTML handler. +func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) { + txns, err := h.Sources.LoadTransactions(ctx) + if err != nil { + return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) + } + return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil } func (h *Handler) loadAll(ctx context.Context, adults bool) ( diff --git a/go/internal/web/html_handler.go b/go/internal/web/html_handler.go index 658012e..adacb81 100644 --- a/go/internal/web/html_handler.go +++ b/go/internal/web/html_handler.go @@ -48,7 +48,18 @@ func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) { } func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) { - h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build}) + data, err := h.apiHandler.AssemblePayments(r.Context()) + if err != nil { + h.renderer.Render(w, "payments", PaymentsPageData{ + PageData: PageData{Active: "payments", Build: h.build}, + Error: err.Error(), + }) + return + } + h.renderer.Render(w, "payments", PaymentsPageData{ + PageData: PageData{Active: "payments", Build: h.build}, + Data: data, + }) } func (h *HTMLHandler) ServeSync(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 0de63bc..a871102 100644 --- a/go/internal/web/html_handler_test.go +++ b/go/internal/web/html_handler_test.go @@ -126,3 +126,34 @@ func TestAdultsPage(t *testing.T) { t.Error("body should not contain >OK<") } } + +func TestPaymentsPage(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, "/payments", nil) + w := httptest.NewRecorder() + h.ServePayments(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + body := w.Body.String() + + for _, want := range []string{ + "Payments Ledger", + "

Test Member

", + "750 CZK", + "2026-01-01", + "2026-01", + } { + if !strings.Contains(body, want) { + t.Errorf("body missing %q", want) + } + } +} diff --git a/go/internal/web/render.go b/go/internal/web/render.go index 0484fa5..c9ee3a9 100644 --- a/go/internal/web/render.go +++ b/go/internal/web/render.go @@ -30,6 +30,13 @@ type JuniorsPageData struct { Error string } +// PaymentsPageData is the view model for the /payments HTML page. +type PaymentsPageData struct { + PageData + Data api.PaymentsResponse + Error string +} + // Renderer parses and executes HTML templates from the embedded FS. type Renderer struct { tmpls map[string]*template.Template diff --git a/go/internal/web/static/css/app.css b/go/internal/web/static/css/app.css index 4f28cea..ff30e45 100644 --- a/go/internal/web/static/css/app.css +++ b/go/internal/web/static/css/app.css @@ -476,3 +476,59 @@ tr:hover { color: #00ff00; font-family: monospace; } + +/* /payments ledger */ +.ledger-container { + width: 100%; + max-width: 1000px; + margin-bottom: 40px; +} + +.member-block { + margin-bottom: 20px; +} + +.txn-table { + border-collapse: collapse; + width: 100%; + margin-top: 5px; +} + +.txn-table th, +.txn-table td { + padding: 2px 8px; + text-align: left; + border-bottom: 1px dashed #222; +} + +.txn-table th { + color: #555; + text-transform: lowercase; + font-weight: normal; + border-bottom: 1px solid #333; +} + +.txn-date { + min-width: 80px; + color: #888; +} + +.txn-amount { + min-width: 80px; + text-align: right !important; + color: #00ff00; +} + +.txn-purpose { + min-width: 100px; + color: #aaa; +} + +.txn-message { + color: #666; + font-style: italic; +} + +.txn-table tr:hover { + background-color: #1a1a1a; +} diff --git a/go/internal/web/templates/payments.tmpl b/go/internal/web/templates/payments.tmpl index fe059c2..b67f41e 100644 --- a/go/internal/web/templates/payments.tmpl +++ b/go/internal/web/templates/payments.tmpl @@ -1,5 +1,38 @@ {{define "title"}}Payments Ledger{{end}} {{define "content"}}

Payments Ledger

-

Coming in M6.4

+

+ All bank transactions from the + payments sheet, + grouped by member. Names are matched against the + attendance sheet. +

+{{if .Error}}

{{.Error}}

{{end}} +
+{{range $person := .Data.SortedPeople}} +
+

{{$person}}

+ + + + + + + + + + + {{range $tx := index $.Data.GroupedPayments $person}} + + + + + + + {{end}} + +
DateAmountPurposeBank Message
{{$tx.Date}}{{printf "%.0f" $tx.Amount}} CZK{{$tx.Purpose}}{{$tx.Message}}
+
+{{end}} +
{{end}}