diff --git a/CHANGELOG.md b/CHANGELOG.md index 1303ffa..f3f78f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page + +- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`). +- Added `JuniorsPageData` to `render.go`; wired `HTMLHandler.ServeJuniors` to call `AssembleJuniors` and render the new template. +- Replaced the 4-line "Coming in M6.3" placeholder in `juniors.tmpl` with the full template: member table, name/month-range filters, totals row, Credits + Debts sections, Pay / Pay All links to `/qr`. No Unmatched section (parity with Python). +- J/A attendance breakdown and `"?"` sentinel rendered via `MonthCell.Text` produced by `buildJuniorMemberRow` — no extra template logic. +- `make parity` reports 3/3 routes OK; all Go tests pass. + ## 2026-05-08 11:11 CEST — fix: period selector showed only Dec 2025+ on adults - Restored the `2025-09 → 2025-10` adult merge in `scripts/attendance.py` and `go/internal/services/membership/sources.go` (commented-out by `1257f0d`); the `2025-12 → 2026-01` mapping stays disabled per product decision (Dec and Jan are billed separately for adults). diff --git a/docs/plans/2026-05-08-1123-go-m6-3-juniors-page.md b/docs/plans/2026-05-08-1123-go-m6-3-juniors-page.md new file mode 100644 index 0000000..f9dd5e0 --- /dev/null +++ b/docs/plans/2026-05-08-1123-go-m6-3-juniors-page.md @@ -0,0 +1,67 @@ +# M6.3 — Go-native `/juniors` page + +## Context + +The Go rewrite is in milestone M6 (Go-native HTML frontend). M6.2 shipped the `/adults` page in commit `c85748b`; the [progress tracker](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) line 115 names M6.3 as: + +> `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering + +The Go side already has `buildJuniorsResponse` (M5.2) that produces the full JSON view model, and the JuniorsResponse uses the same `MemberRow`/`MonthCell`/`TotalCell` types as `AdultsResponse`. Critically, `buildJuniorMemberRow` ([go/internal/web/api/build_juniors.go:142](../../srv/personal/fuj-management/go/internal/web/api/build_juniors.go#L142)) already bakes the J/A breakdown (e.g. `"3/500 CZK (4:2J,2A)"`) and the `"?"` sentinel into `MonthCell.Text` — no template-level junior-specific logic is required for the M6.3 table. + +Today `juniors.tmpl` is a 4-line "Coming in M6.3" placeholder and `HTMLHandler.ServeJuniors` ([html_handler.go:35](../../srv/personal/fuj-management/go/internal/web/html_handler.go#L35)) just renders that placeholder. Goal of M6.3: produce a working `/juniors` page that mirrors the structure of `/adults` and reaches feature parity with the Python `/juniors` route. + +## Approach + +Mirror the M6.2 pattern exactly: + +1. **Extract `AssembleJuniors(ctx)`** from the JSON handler so HTML + JSON share one view-model assembly path (the parity contract). +2. **Add `JuniorsPageData`** wrapper type next to `AdultsPageData` in `render.go`. +3. **Replace `juniors.tmpl`** with an adults-shaped layout. Because `JuniorsResponse` shares the same field names used by `adults.tmpl` (`Months`, `RawMonths`, `Results`, `Totals`, `Credits`, `Debts`, `AttendanceURL`, `PaymentsURL`, `BankAccount`, `CurrentMonth`), the template body is essentially copy/paste with one parity tweak (see "Decisions" below). +4. **Wire `HTMLHandler.ServeJuniors`** to call `AssembleJuniors` and render the new template, parallel to `ServeAdults`. + +No new CSS, no new JS — `filters.js` and the `cell-*` classes (lifted in M6.1) already cover juniors. + +## Files to modify + +| File | Change | +| --- | --- | +| [go/internal/web/api/handler.go](../../srv/personal/fuj-management/go/internal/web/api/handler.go) | Add `AssembleJuniors(ctx) (JuniorsResponse, error)`; refactor existing `ServeJuniors` to call it (mirrors `AssembleAdults` at line 47). | +| [go/internal/web/render.go](../../srv/personal/fuj-management/go/internal/web/render.go) | Add `JuniorsPageData` struct (PageData + `Data api.JuniorsResponse` + `Error string`). | +| [go/internal/web/html_handler.go](../../srv/personal/fuj-management/go/internal/web/html_handler.go) | Replace stub `ServeJuniors` with the same shape as `ServeAdults` (lines 20–33). | +| [go/internal/web/templates/juniors.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/juniors.tmpl) | Replace placeholder with a structurally identical copy of [adults.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/adults.tmpl), with the title swapped to "Juniors Dashboard" and the Unmatched section removed (parity with Python). | +| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) | Tick `M6.3` once merged, append merge SHA. | +| [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) | New top-most entry per CLAUDE.md format. | + +## Decisions captured + +- **No "Unmatched Transactions" section on `/juniors`.** Python's [templates/juniors.html](../../srv/personal/fuj-management/templates/juniors.html) renders only Credits + Debts (no Unmatched block). Although `JuniorsResponse.Unmatched` is populated, parity says we don't render it on juniors. The data stays available for the modal in M6.5. +- **Reuse `qrHref` / `qrHrefAll` template funcs** from [render.go:35–57](../../srv/personal/fuj-management/go/internal/web/render.go#L35-L57). They are name-only (not adult-specific) and already on `tmplFuncs`. +- **Reuse the existing `cell-empty / cell-unpaid / cell-unpaid-current / cell-ok / cell-overridden` cell-class branching** from `adults.tmpl` lines 60–67 verbatim. The "?" sentinel is rendered as plain `{{$cell.Text}}` text — no template branch needed because `buildJuniorMemberRow` produces `"?"` or `"? CZK"` already. +- **No template helper extraction** (e.g. shared `_table_section.tmpl`). The two templates will be near-duplicates, but extracting a shared partial now is premature — wait until M6.4 or later when patterns settle. M6.2 didn't extract one either. +- **Branch**: `feat/go-m6-3-juniors-page`, MR via `tea pr create` per CLAUDE.md. + +## Verification + +End-to-end smoke (parallel-running both backends as in M5/M6.2): + +1. `cd go && make run` (or whatever target boots the Go server on :8080). +2. Browser-load `http://localhost:8080/juniors`. Confirm: + - Member rows render with cells containing fee + attendance breakdown like `0/500 CZK (3:2J,1A)`. + - At least one `"?"` cell visible (any junior with exactly one session in some month). + - Filter input narrows rows by name; From/To month selects hide columns via `data-month-idx`. + - `Pay` button visible on past-month unpaid cells; clicking links to `/qr?...`. (The QR endpoint itself lands in M6.6 — only the link target is in scope here.) + - `Pay All` visible on rows with `Balance < 0`. + - TOTAL row sums correctly per month. + - Credits + Debts sections render when present; no Unmatched section. +3. JSON parity: `make parity` against the M3 fixture corpus must still report zero non-allowlisted diffs for `/api/juniors` (the `AssembleJuniors` extraction must not change wire output). +4. Compare side-by-side against Python `/juniors` on :5001 for the same fixture; the table cells should match cell-for-cell modulo the known M5 allowlist. + +## Out of scope + +- Modal (`[i]` info button + `#memberModal`) — that is M6.5. +- QR endpoint, `/sync-bank`, `/flush-cache`, `/version` pages — M6.6. +- Embed.FS deploy verification — M6.7. + +## Plan-file relocation + +Per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) "Plans" section, plan files belong in `docs/plans/YYYY-MM-DD-HHMM-.md` inside the repo. Plan mode forced this draft into `~/.claude/plans/`; first step after ExitPlanMode is to copy this file to `docs/plans/2026-05-08-HHMM-go-m6-3-juniors-page.md` (resolving the timestamp at that moment) and commit it on the feature branch alongside the implementation. diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go index 18bdc74..cad3258 100644 --- a/go/internal/web/api/handler.go +++ b/go/internal/web/api/handler.go @@ -55,14 +55,23 @@ func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) { // ServeJuniors handles GET /api/juniors. func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false) + resp, err := h.AssembleJuniors(r.Context()) if err != nil { h.writeError(w, r, err) return } + writeJSON(w, resp) +} + +// AssembleJuniors loads all data and builds the juniors view model. +// Shared between the JSON API route and the HTML handler. +func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) { + members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false) + if err != nil { + return JuniorsResponse{}, err + } result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) - writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01"))) + return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil } // ServePayments handles GET /api/payments. diff --git a/go/internal/web/html_handler.go b/go/internal/web/html_handler.go index 562d889..658012e 100644 --- a/go/internal/web/html_handler.go +++ b/go/internal/web/html_handler.go @@ -33,7 +33,18 @@ func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) { } func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) { - h.renderer.Render(w, "juniors", PageData{Active: "juniors", Build: h.build}) + data, err := h.apiHandler.AssembleJuniors(r.Context()) + if err != nil { + h.renderer.Render(w, "juniors", JuniorsPageData{ + PageData: PageData{Active: "juniors", Build: h.build}, + Error: err.Error(), + }) + return + } + h.renderer.Render(w, "juniors", JuniorsPageData{ + PageData: PageData{Active: "juniors", Build: h.build}, + Data: data, + }) } func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) { diff --git a/go/internal/web/render.go b/go/internal/web/render.go index 9101d88..0484fa5 100644 --- a/go/internal/web/render.go +++ b/go/internal/web/render.go @@ -23,6 +23,13 @@ type AdultsPageData struct { Error string } +// JuniorsPageData is the view model for the /juniors HTML page. +type JuniorsPageData struct { + PageData + Data api.JuniorsResponse + 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/templates/juniors.tmpl b/go/internal/web/templates/juniors.tmpl index ead1262..f5d89b2 100644 --- a/go/internal/web/templates/juniors.tmpl +++ b/go/internal/web/templates/juniors.tmpl @@ -1,5 +1,121 @@ {{define "title"}}Juniors{{end}} {{define "content"}}

Juniors Dashboard

-

Coming in M6.3

+ +{{if .Error}} +
Error loading data: {{.Error}}
+{{else}} + +
+ Balances calculated by matching Google Sheet payments against attendance fees.
+ Source: Attendance Sheet | + Payments Ledger +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +{{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}} + {{if gt $row.PayableAmount 0}} + Pay All + {{end}} +
TOTAL + received / expected + {{$t.Text}} +
+
+{{else}} +
No members found.
+{{end}} + +{{if .Data.Credits}} +

Credits (Advance Payments / Surplus)

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

Debts (Missing Payments)

+
+ {{range .Data.Debts}} +
+ {{.Name}} + {{.Amount}} CZK +
+ {{end}} +
+{{end}} + +{{end}} + + {{end}}