Compare commits

..

21 Commits

Author SHA1 Message Date
505d635c66 chore: CHANGELOG entry for M6.3 Go juniors page
Some checks failed
Deploy to K8s / deploy (push) Failing after 11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:26:08 +02:00
9f0e4b0ac3 feat(go): M6.3 — juniors page (table, filters, credits/debts, Pay buttons)
- Extract AssembleJuniors(ctx) from ServeJuniors JSON handler so HTML
  and JSON share the same view-model path (mirrors AssembleAdults pattern)
- Add JuniorsPageData wrapper in render.go
- Wire HTMLHandler.ServeJuniors to AssembleJuniors + render template
- Replace 4-line placeholder juniors.tmpl with full template:
  member table, name filter, month-range filter, totals row,
  Credits + Debts sections, Pay / Pay All buttons via /qr links
  (no Unmatched section — matches Python juniors.html parity)
- J/A attendance breakdown ("3/500 CZK (4:2J,1A)") and "?" sentinel
  rendered via MonthCell.Text from buildJuniorMemberRow, no extra
  template logic needed
- All tests pass; make parity reports 3/3 routes OK

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:25:50 +02:00
2cbd98df1a Merge pull request 'fix: restore Sep+Oct adult merge and stop auto-truncating period selector' (#28) from fix/period-selector-restore into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #28
2026-05-08 09:13:14 +00:00
e618e906ef fix: restore Sep+Oct adult merge and stop auto-truncating period selector
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Two regressions made older periods invisible on the adults dashboard:

- 1257f0d (Mar 9) commented out ADULT_MERGED_MONTHS, removing the
  Sep+Oct 2025 merged label. Restored only the 2025-09 → 2025-10
  mapping (Dec and Jan are billed separately for adults; the
  Dec → Jan mapping stays disabled per product decision). Mirrored
  on the Go side. Test fixtures in sources_test.go now assert Sep
  dates land in merged 2025-10 instead of 2025-09.

- 7774301 (Apr 9) added a JS onload default that set the From
  selector to maxMonthIdx − 4 and immediately filtered the table,
  hiding everything older than 5 months on first load. Dropped that
  default in templates/adults.html and templates/juniors.html so
  the From-selector starts at the oldest available month. Future
  months are still removed from the dropdowns and hidden in the
  table — only the past-month truncation is gone.

Note: the live adults attendance sheet had also been pruned to
start at 02.12.2025; restoring Sep/Oct/Nov 2025 columns from
Sheets version history is required to actually see those periods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:12:26 +02:00
96e574e6c7 chore: tick M6.2 in progress tracker — SHA c85748b
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:38:28 +02:00
6a8aa37198 Merge pull request 'feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)' (#27) from feat/go-m6-2-adults-page into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #27
2026-05-08 08:35:20 +00:00
aa0c17f521 fix(go): align adults cell class names with Python; un-underline Pay buttons
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
- 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 <noreply@anthropic.com>
2026-05-08 10:33:30 +02:00
464eeeb2b1 fix(go): wrap adults table in <div class="table-container">
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
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 <noreply@anthropic.com>
2026-05-08 10:20:49 +02:00
daac5d7392 fix(go): adults template — emit markup that the lifted CSS expects
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
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: <ul><li> → <div class="list-container"><div class="list-item">
  <span class="list-item-name"> + <span class="list-item-val"> (debts red inline).
- Unmatched: <table> → <div class="list-container"> + <div class="unmatched-row">.
- 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: <div class="description"> block under h1 (was at page bottom).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:17:03 +02:00
c85748b3aa feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
- 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
216b5b437a Merge pull request 'feat(go): M6.1 — template skeleton, embed.FS, HTML routes' (#26) from feat/go-m6-1-template-skeleton into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #26
2026-05-07 22:48:22 +00:00
78e5059759 feat(go): M6.1 — template skeleton, embed.FS, HTML routes
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Stand up the Go-native HTML frontend foundation:
- base.tmpl layout + nav/footer partials (three-tier nav, active-link highlighting)
- terminal-green-on-black theme extracted to static/css/app.css (served via embed.FS)
- HTMLHandler with stub pages for all five routes; / redirects to /adults
- NewRenderer parses per-page template sets at startup so parse failures abort boot
- Smoke test: each route returns 200 text/html with exactly one class="active" link

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:45:22 +02:00
2f635db2b4 chore: CHANGELOG for M5.4 parity coercions (PR #25)
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:27:34 +02:00
709a2f2335 Merge pull request 'fix(py): parity coercions — amount/message types + junior '?' sticky' (#25) from fix/py-parity-coercions into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Reviewed-on: #25
2026-05-07 22:26:39 +00:00
58973473c9 fix(py): make junior '?' cell text sticky across exception overrides
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Go's build_juniors sets cellText = "?" + countStr whenever
md.IsUnknown is true, regardless of whether an exception overrides the
expected amount. Python was checking expected == "?" for this branch,
but reconcile replaces expected with the exception amount (e.g. 0)
before the view builder runs, so the "?" was silently dropped to "-".

Fix: derive is_unknown from original_expected == "?" (set before
exception substitution) instead of expected == "?". Also align the
tooltip guard: Go only shows Received/Expected tooltip for non-unknown
months (or when paid > 0), matching the same is_unknown flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:26:04 +02:00
b68d95d217 fix(py): coerce amount to float and message to string in tx projection
Two remaining make parity diffs vs Go:

- amount: non-numeric sheet values like "---" passed through as strings;
  Go's parseFloat silently returns 0.0 for unparseable values. Add
  get_float helper that matches that behaviour.
- message: numeric cell values (e.g. a bank reference in the message
  column) passed through as float64; Go's getVal uses fmt.Sprint and
  always emits a string. Apply get_str to the message field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:26:04 +02:00
07ca1cd9e1 fix(py): coerce VS column to string in payments tx projection
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
The Sheets API returns VS (variabilní symbol) cells as float64 when
the column is number-formatted, so Python was emitting vs: 0 (a JSON
number) while Go's getVal uses fmt.Sprint and always emits vs: "0"
(a string). Add get_str helper that converts whole-number floats via
int() first (matching Go's %g formatting), applied to the vs field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:59:35 +02:00
5dcac25c13 chore: CHANGELOG entry for M5.4 fix #2 (py vs/sync_id)
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:52:04 +02:00
fc47606b1c Merge pull request 'feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection' (#23) from fix/py-payments-add-vs-syncid into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #23
2026-05-07 21:51:12 +00:00
65694ad378 feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Python's fetch_sheet_data read 9 sheet columns but skipped VS and
Sync ID, causing make parity to report extra fields on every raw
payment row emitted by the Go backend. Both columns are already on
the sheet; add idx_vs / idx_sync_id lookups and the matching keys
to the tx dict so the Python /api/* wire shape matches Go's
RawTransaction.

Update /api/* test fixtures to include vs/sync_id keys for realism.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:50:33 +02:00
092dff25a5 Merge pull request 'fix(go): accept single-digit day/month in attendance date headers' (#22) from fix/go-attendance-date-parser into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #22
2026-05-07 21:39:02 +00:00
30 changed files with 1757 additions and 41 deletions

View File

@@ -1,5 +1,58 @@
# 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).
- Dropped the `defaultFrom = maxMonthIdx 4` JS auto-default in `templates/adults.html` and `templates/juniors.html` (introduced by `7774301`); the From-selector now starts at the oldest available month so all non-future periods render on first load. Future-month removal is preserved.
- `go/internal/services/membership/sources_test.go`: `TestLoadAdults` / `TestLoadAdults_Fee` now assert that Sep dates land in the merged `2025-10` bucket.
- **Independent of these code changes**: the live adults attendance Google Sheet header had been pruned to start at `02.12.2025` (Sep/Oct/Nov 2025 columns deleted); restoring those columns from Sheets version history is required to actually see those periods on the dashboard.
## 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 `<ul>` / `<table>` to `<div class="list-container"><div class="list-item">…` / `<div class="unmatched-row">…` 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 `<div class="description">` 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.
- `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).
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person``_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.

View File

@@ -2,9 +2,9 @@
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
**Current milestone:** M5JSON-only `/api/...` routes ✅
**Current milestone:** M6Go-native HTML frontend
**Started:** 2026-05-04
**Last updated:** 2026-05-07 (M5.4)
**Last updated:** 2026-05-08 (M6.2 merged)
## 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.
- [ ] **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` — `c85748b`
- [ ] **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, ↑/↓)

View File

@@ -0,0 +1,88 @@
# Plan: Go rewrite — M6.1 Template skeleton
## Context
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
## Current Go web state (what we're building on)
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
## Approach
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
6. **Replace the existing `helloHandler`**: `GET /{$}``http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
## Files to create / modify
```
go/internal/web/
├── assets.go NEW — //go:embed templates/* static/*
├── render.go NEW — Renderer wraps parsed *template.Template
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
├── html/
│ ├── handler.go NEW — html.Handler + page methods
│ └── handler_test.go NEW — httptest smoke per route
├── templates/
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
│ ├── partials/
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
│ │ └── footer.tmpl NEW — build meta line
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
└── static/
└── css/
└── app.css NEW — verbatim from templates/adults.html:8-487
```
## Key design notes
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
## Critical files to read
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
## Verification
End-to-end smoke from a fresh `make go-build`:
1. `make web-go &` — server boots; no template-parse errors in slog output.
2. `curl -i localhost:8080/``302` to `/adults`.
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
4. `curl -sI localhost:8080/static/css/app.css``200`, `Content-Type: text/css`.
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
7. `make go-test``web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
8. `make go-lint` clean.
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
## Branch & MR
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.

View File

@@ -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 `<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](../../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](../../templates/adults.html#L864-L1051) 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 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.

View File

@@ -0,0 +1,142 @@
# Period selector missing older months — diagnosis
## Context
User reports the "From / To" period selector on the **adults** dashboard now
shows **Dec 2025** as the oldest available period. The older production
deployment shows Sep+Oct 2025, Nov 2025, Dec 2025+Jan 2026 (merged labels) —
i.e. data going back to September 2025. The user wants to know what went
sideways. Confirmed: the dropdown options on both Python and Go genuinely
start at Dec 2025, not just the default selection — Sep/Oct/Nov 2025 are not
in the list at all.
## Root cause — the live adults sheet header is missing those columns
The fresh cache files at `tmp/go/attendance_regular_cache.json` (raw rows from
Google Sheets, modifiedTime `2026-05-06T22:30:02Z`, cached `2026-05-08T00:26`)
contain the actual header row for the adults tab (gid=0):
```text
['FUJ tréninky úterý 20:30-22:00', '', '', '02.12.2025', '09.12.2025',
'16.12.2025', '06.01.2026', '13.01.2026', '20.01.2026', '27.01.2026',
'03.02.2026', '10.02.2026', '17.02.2026', '24.02.2026', '03.03.2026',
'10.03.2026', '17.03.2026', '23.03.2026', '31.03.2026', '13.04.2026',
'20.04.2026', '27.04.2026', '04.05.2026', '', '', '']
```
**The first date column in the live adults sheet is `02.12.2025` (Dec 2 2025).**
There are no September, October, or November 2025 columns in the header at
all. Both backends parse this faithfully (no slicing, no cutoff anywhere) and
correctly produce `sortedMonths = ["2025-12", "2026-01", …, "2026-05"]`.
The juniors sheet (different tab, `JUNIOR_SHEET_GID`) is **fine** — its header
still contains `['', 'tier', '', '15.09.2025', '13.10.2025', '20.10.2025',
'03.11.2025', '24.11.2025', '10.11.2025', '17.11.2025', '01.12.2025', …]`. So
the juniors page still shows Sep+Oct / Nov / Dec+Jan correctly.
So this is a **data issue in the adults attendance Google Sheet**: at some
point between when production's cache was last warmed (showing SepNov) and
2026-05-06, somebody (or some action) removed the columns for September,
October, and November 2025 from the adults tab header.
The code is doing exactly what it should. There is no parser regression.
## What to do
### 1. Restore the missing date columns in the adults attendance sheet
The fix lives in Google Sheets, not in the codebase. Options, in order of
preference:
- **(a) Use Sheets version history.** File → Version history → See version
history; find a version from before the columns were dropped (anything
before about Mar 2026 should still have them). Copy the Sep/Oct/Nov 2025
date column headers and the `TRUE/FALSE` cells underneath them back into
the current sheet. Only restore the 11 missing date columns; do not
full-revert (you'd lose every change since then).
- **(b) Pull from the production server's cache.** The production deployment
evidently still has the older cache, since its dashboard renders those
months. SSH there, copy `tmp/attendance_regular_cache.json`, and you can
reconstruct the per-member Sep/Oct/Nov attendance counts from the
`data[*][2]` map (keys `"2025-09"`, `"2025-10"`, `"2025-11"`). Re-enter
those into the sheet manually as date columns + `TRUE` cells — tedious but
deterministic.
- **(c) Accept the loss.** If the older columns aren't recoverable, the
dashboard correctly reflects what the sheet contains; nothing more to do.
Which to pick depends on whether those months still need to be billed /
reconciled.
### 2. Restore `ADULT_MERGED_MONTHS` (user confirmed this was unintentional)
Independent of the sheet issue: commit `1257f0d` (Mar 9 2026) commented out
the adult merge mappings. Once the Sep/Oct/Nov columns are back in the sheet,
the dashboard would still show them as separate periods instead of the
production-style "Sep+Oct 2025" and "Dec 2025+Jan 2026" merged labels.
User confirmed this was unintentional. Two files to update:
- [scripts/attendance.py:32-35](scripts/attendance.py#L32-L35) — uncomment
the two mappings:
```python
ADULT_MERGED_MONTHS = {
"2025-12": "2026-01", # keys are merged into values
"2025-09": "2025-10",
}
```
- [go/internal/services/membership/sources.go:30](go/internal/services/membership/sources.go#L30) — mirror the same:
```go
var AdultMergedMonths = map[string]string{
"2025-12": "2026-01",
"2025-09": "2025-10",
}
```
After this change, hit `POST /flush-cache` on each backend so the in-process
post-processed adults cache is rebuilt with the new mapping.
### 3. (Optional, separate) Fix the JS auto-default that hides older months
This is **not** the cause of the user's current symptom (that's the sheet
issue), but it will become a UX issue once Sep/Oct/Nov columns are restored:
the Python frontend's `defaultFrom = Math.max(0, maxMonthIdx - 4)` will still
default the From-selector to ~5 months before the latest column on every page
load, hiding restored older months until the user manually picks them.
- [templates/adults.html:1047](templates/adults.html#L1047) — `var defaultFrom = Math.max(0, maxMonthIdx - 4);`
- [templates/juniors.html:1028](templates/juniors.html#L1028) — same line.
Drop those four lines (`defaultFrom`, `fromSelect.value = defaultFrom`,
`toSelect.value = maxMonthIdx`, `applyMonthFilter()`) so the page loads with
all non-future months visible — matching the Go side, which only calls
`hideFutureMonths()` and leaves From at its first option.
Recommend bundling this with step 2 since they touch related UI.
## Verification
1. **After step 1** — `POST /flush-cache` on Python and Go backends; reload
`/adults` on each. Confirm the dropdown now lists Sep/Oct/Nov 2025.
2. **After step 2** — reload `/adults`. Confirm the dropdown shows
"Sep+Oct 2025" as a single period and "Dec 2025+Jan 2026" as a single
period. (Still requires the sheet columns to exist.)
3. **After step 3** — reload `/adults` and `/juniors` on Python. Confirm the
table renders all non-future months on first load (Sep 2025 through the
current month) instead of starting at Dec 2025.
4. **Parity check** — `make parity` should report zero diffs between Python
and Go on `/api/adults` and `/api/juniors`.
## Critical files referenced
- `tmp/go/attendance_regular_cache.json` — current adults sheet rows
(evidence: header starts at `02.12.2025`).
- `tmp/go/attendance_juniors_cache.json` — current juniors sheet rows
(header still has `15.09.2025`).
- [scripts/attendance.py](scripts/attendance.py) — `ADULT_MERGED_MONTHS`
empty after `1257f0d`; `parse_dates` / `group_by_month` faithful.
- [go/internal/services/membership/sources.go](go/internal/services/membership/sources.go) — Go counterpart, same shape.
- [templates/adults.html](templates/adults.html), [templates/juniors.html](templates/juniors.html) — JS onload `defaultFrom = -4` issue (step 3).
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — Go filter UI (already correct, no changes).

View File

@@ -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 2033). |
| [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:3557](../../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 6067 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-<slug>.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.

View File

@@ -27,7 +27,9 @@ const (
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
// Source month → target month (source attendance accumulated into target).
var AdultMergedMonths = map[string]string{}
var AdultMergedMonths = map[string]string{
"2025-09": "2025-10",
}
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
var JuniorMergedMonths = map[string]string{

View File

@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// adultMergedMonths is empty so 2025-09 stays as-is
if len(months) != 1 || months[0] != "2025-09" {
// AdultMergedMonths sends 2025-09 → 2025-10
if len(months) != 1 || months[0] != "2025-10" {
t.Errorf("unexpected months: %v", months)
}
if len(members) != 2 {
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Attendance
byName[m.Name] = m.Fees["2025-10"].Attendance
}
if byName["Alice"] != 2 {
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
}
byName := map[string]int{}
for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Expected
byName[m.Name] = m.Fees["2025-10"].Expected
}
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
// 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 750
if byName["Alice"] != 750 {
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
}

View File

@@ -34,26 +34,44 @@ 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.
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.

View File

@@ -0,0 +1,9 @@
package web
import "embed"
//go:embed templates
var templateFS embed.FS
//go:embed static
var staticFS embed.FS

View File

@@ -0,0 +1,60 @@
package web
import (
"fuj-management/go/internal/web/api"
"net/http"
)
// HTMLHandler serves the Go-native HTML frontend.
type HTMLHandler struct {
renderer *Renderer
build BuildInfo
apiHandler *api.Handler
}
// NewHTMLHandler constructs an HTMLHandler.
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) {
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) {
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) {
h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
}
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
}
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
}

View File

@@ -0,0 +1,128 @@
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, fixtureHandler(t))
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
{"/payments", h.ServePayments},
{"/sync-bank", h.ServeSync},
{"/flush-cache", h.ServeFlushCache},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
tc.handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := w.Body.String()
if n := strings.Count(body, `class="active"`); n != 1 {
t.Errorf(`class="active" count = %d, want 1`, n)
}
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
if !strings.Contains(body, want) {
t.Errorf("missing active link %q in body", want)
}
})
}
}
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<")
}
}

101
go/internal/web/render.go Normal file
View File

@@ -0,0 +1,101 @@
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.
type PageData struct {
Active string
Build BuildInfo
}
// AdultsPageData is the view model for the /adults HTML page.
type AdultsPageData struct {
PageData
Data api.AdultsResponse
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
}
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("").Funcs(tmplFuncs).ParseFS(templateFS,
"templates/base.tmpl",
"templates/partials/nav.tmpl",
"templates/partials/footer.tmpl",
"templates/"+name+".tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse template %q: %w", name, err)
}
tmpls[name] = t
}
return &Renderer{tmpls: tmpls}, nil
}
// Render executes the named template with data, writing to w.
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
t, ok := r.tmpls[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
slog.Error("render template", "name", name, "err", err)
}
}

View File

@@ -6,6 +6,7 @@ import (
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web/api"
"fuj-management/go/internal/web/middleware"
"io/fs"
"log/slog"
"net/http"
)
@@ -19,7 +20,12 @@ type BuildInfo struct {
// Run registers routes and starts the HTTP server on addr.
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
h := &api.Handler{
renderer, err := NewRenderer()
if err != nil {
return fmt.Errorf("init templates: %w", err)
}
ah := &api.Handler{
BuildVersion: build.Version,
BuildCommit: build.Commit,
BuildDate: build.BuildDate,
@@ -27,22 +33,34 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
Config: cfg,
Logger: logger,
}
hh := NewHTMLHandler(renderer, build, ah)
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
return fmt.Errorf("static subfs: %w", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", helloHandler(build))
mux.HandleFunc("GET /api/version", h.ServeVersion)
mux.HandleFunc("GET /api/adults", h.ServeAdults)
mux.HandleFunc("GET /api/juniors", h.ServeJuniors)
mux.HandleFunc("GET /api/payments", h.ServePayments)
// HTML routes
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/adults", http.StatusFound)
})
mux.HandleFunc("GET /adults", hh.ServeAdults)
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
mux.HandleFunc("GET /payments", hh.ServePayments)
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// JSON API routes
mux.HandleFunc("GET /api/version", ah.ServeVersion)
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
mux.HandleFunc("GET /api/payments", ah.ServePayments)
logger.Info("starting server", "addr", addr)
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
}
func helloHandler(build BuildInfo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
build.Version, build.Commit, build.BuildDate)
}
}

View File

@@ -0,0 +1,478 @@
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.nav-archived a {
font-size: 10px;
color: #666;
border-color: #222;
}
.nav-archived a.active {
color: #ccc;
background-color: #333;
border-color: #555;
}
.nav-archived a:hover {
color: #999;
border-color: #444;
}
.description {
margin-bottom: 20px;
text-align: center;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.cell-unpaid-current {
color: #994444;
background-color: rgba(153, 68, 68, 0.05);
position: relative;
}
.cell-overridden {
color: #ffa500 !important;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
text-decoration: none;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
border-bottom: 1px solid #333;
margin-bottom: 5px;
}
.filter-container {
width: 100%;
max-width: 1200px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.filter-input {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
width: 250px;
outline: none;
}
.filter-input:focus {
border-color: #00ff00;
}
.filter-select {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
outline: none;
}
.filter-select:focus {
border-color: #00ff00;
}
.month-hidden {
display: none !important;
}
.filter-label {
color: #888;
text-transform: lowercase;
}
.info-icon {
color: #00ff00;
cursor: pointer;
margin-left: 5px;
font-size: 10px;
opacity: 0.5;
}
.info-icon:hover {
opacity: 1;
}
/* Modal Styles */
#memberModal {
display: none !important;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#memberModal.active {
display: flex !important;
}
.modal-content {
background-color: #0c0c0c;
border: 1px solid #00ff00;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
position: relative;
}
.modal-header {
border-bottom: 1px solid #333;
margin-bottom: 20px;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
color: #00ff00;
font-size: 14px;
text-transform: uppercase;
}
.close-btn {
color: #ff3333;
cursor: pointer;
font-size: 14px;
text-transform: lowercase;
}
.modal-section {
margin-bottom: 25px;
}
.modal-section-title {
color: #555;
text-transform: uppercase;
font-size: 10px;
margin-bottom: 8px;
border-bottom: 1px dashed #222;
}
.raw-toggle {
color: #333;
font-size: 9px;
text-transform: lowercase;
margin-left: 8px;
text-decoration: none;
letter-spacing: 0;
}
.raw-toggle:hover {
color: #666;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table th,
.modal-table td {
text-align: left;
padding: 4px 0;
border-bottom: 1px dashed #1a1a1a;
}
.modal-table th {
color: #666;
font-weight: normal;
font-size: 10px;
}
.tx-list {
list-style: none;
padding: 0;
margin: 0;
}
.tx-item {
padding: 8px 0;
border-bottom: 1px dashed #222;
}
.tx-meta {
color: #555;
font-size: 10px;
margin-bottom: 4px;
}
.tx-main {
display: flex;
justify-content: space-between;
gap: 20px;
}
.tx-amount {
color: #00ff00;
}
.tx-sender {
color: #ccc;
}
.tx-msg {
color: #888;
font-style: italic;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}

View File

@@ -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();
}());

View File

@@ -0,0 +1,141 @@
{{define "title"}}Adults{{end}}
{{define "content"}}
<h1>Adults Dashboard</h1>
{{if .Error}}
<div class="description">Error loading data: {{.Error}}</div>
{{else}}
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
</div>
<div class="filter-item">
<label class="filter-label" for="fromMonth">From</label>
<select id="fromMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<label class="filter-label" for="toMonth">To</label>
<select id="toMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
</div>
</div>
{{if .Data.Results}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{{range $i, $m := .Data.Months}}
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
{{end}}
<th>Balance</th>
</tr>
</thead>
<tbody>
{{range $row := .Data.Results}}
<tr class="member-row" data-name="{{$row.Name}}">
<td class="member-name">{{$row.Name}}</td>
{{range $i, $cell := $row.Months}}
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
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)}}
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
{{end}}
</td>
{{end}}
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
{{$row.Balance}}
{{if gt $row.PayableAmount 0}}
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
{{end}}
</td>
</tr>
{{end}}
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
{{range $i, $t := .Data.Totals}}
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
{{$t.Text}}
</td>
{{end}}
<td></td>
</tr>
</tbody>
</table>
</div>
{{else}}
<div class="description">No members found.</div>
{{end}}
{{if .Data.Credits}}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{{range .Data.Credits}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{if .Data.Debts}}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{{range .Data.Debts}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{if .Data.Unmatched}}
<h2>Unmatched Transactions</h2>
<div class="list-container">
<div class="unmatched-row unmatched-header">
<span>Date</span>
<span>Amount</span>
<span>Sender</span>
<span>Message</span>
</div>
{{range .Data.Unmatched}}
<div class="unmatched-row">
<span>{{.Date}}</span>
<span>{{printf "%.0f" .Amount}}</span>
<span>{{.Sender}}</span>
<span>{{.Message}}</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
<script src="/static/js/filters.js" defer></script>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ — {{template "title" .}}</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
{{template "nav" .}}
{{template "content" .}}
{{template "footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Flush Cache{{end}}
{{define "content"}}
<h1>Flush Cache</h1>
<p class="description">Coming in M6.6</p>
{{end}}

View File

@@ -0,0 +1,121 @@
{{define "title"}}Juniors{{end}}
{{define "content"}}
<h1>Juniors Dashboard</h1>
{{if .Error}}
<div class="description">Error loading data: {{.Error}}</div>
{{else}}
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
</div>
<div class="filter-item">
<label class="filter-label" for="fromMonth">From</label>
<select id="fromMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<label class="filter-label" for="toMonth">To</label>
<select id="toMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
</div>
</div>
{{if .Data.Results}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{{range $i, $m := .Data.Months}}
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
{{end}}
<th>Balance</th>
</tr>
</thead>
<tbody>
{{range $row := .Data.Results}}
<tr class="member-row" data-name="{{$row.Name}}">
<td class="member-name">{{$row.Name}}</td>
{{range $i, $cell := $row.Months}}
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
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)}}
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
{{end}}
</td>
{{end}}
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
{{$row.Balance}}
{{if gt $row.PayableAmount 0}}
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
{{end}}
</td>
</tr>
{{end}}
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
{{range $i, $t := .Data.Totals}}
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
{{$t.Text}}
</td>
{{end}}
<td></td>
</tr>
</tbody>
</table>
</div>
{{else}}
<div class="description">No members found.</div>
{{end}}
{{if .Data.Credits}}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{{range .Data.Credits}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{if .Data.Debts}}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{{range .Data.Debts}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
<script src="/static/js/filters.js" defer></script>
{{end}}

View File

@@ -0,0 +1,3 @@
{{define "footer"}}
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "nav"}}
<div class="nav">
<div>
<a href="/adults"{{if eq .Active "adults"}} class="active"{{end}}>[Adults]</a>
<a href="/juniors"{{if eq .Active "juniors"}} class="active"{{end}}>[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/payments"{{if eq .Active "payments"}} class="active"{{end}}>[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank"{{if eq .Active "sync"}} class="active"{{end}}>[Sync Bank Data]</a>
<a href="/flush-cache"{{if eq .Active "flush"}} class="active"{{end}}>[Flush Cache]</a>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">Coming in M6.4</p>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Sync Bank Data{{end}}
{{define "content"}}
<h1>Sync Bank Data</h1>
<p class="description">Coming in M6.6</p>
{{end}}

View File

@@ -30,8 +30,7 @@ JUNIOR_MONTHLY_RATE = {
"2026-03": 250 # reduced fee for March 2026
}
ADULT_MERGED_MONTHS = {
#"2025-12": "2026-01", # keys are merged into values
#"2025-09": "2025-10"
"2025-09": "2025-10", # keys are merged into values
}
JUNIOR_MERGED_MONTHS = {

View File

@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
idx_sender = get_col_index("Sender")
idx_message = get_col_index("Message")
idx_bank_id = get_col_index("Bank ID")
idx_vs = get_col_index("VS")
idx_sync_id = get_col_index("Sync ID")
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
missing = [name for name, idx in required.items() if idx == -1]
@@ -247,16 +249,33 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
def get_val(idx):
return row[idx] if idx != -1 and idx < len(row) else ""
def get_str(idx):
v = get_val(idx)
if isinstance(v, float) and v.is_integer():
return str(int(v))
return str(v)
def get_float(idx):
v = get_val(idx)
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).strip())
except (ValueError, TypeError):
return 0.0
tx = {
"date": format_date(get_val(idx_date)),
"amount": get_val(idx_amount),
"amount": get_float(idx_amount),
"manual_fix": get_val(idx_manual),
"person": get_val(idx_person),
"purpose": get_val(idx_purpose),
"inferred_amount": get_val(idx_inferred_amount),
"sender": get_val(idx_sender),
"message": get_val(idx_message),
"vs": get_str(idx_vs),
"message": get_str(idx_message),
"bank_id": get_val(idx_bank_id),
"sync_id": get_val(idx_sync_id),
}
transactions.append(tx)

View File

@@ -310,8 +310,9 @@ def build_juniors_view_model(
cell_text = "-"
amount_to_pay = 0
if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?":
is_unknown = original_expected == "?"
if is_unknown or (isinstance(expected, int) and expected > 0):
if is_unknown:
status = "empty"
cell_text = f"?{count_str}"
elif paid >= expected:
@@ -339,7 +340,7 @@ def build_juniors_view_model(
status = "surplus"
cell_text = f"PAID {paid}"
if (isinstance(expected, int) and expected > 0) or paid > 0:
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""

View File

@@ -1044,8 +1044,6 @@
}
});
var defaultFrom = Math.max(0, maxMonthIdx - 4);
fromSelect.value = defaultFrom;
toSelect.value = maxMonthIdx;
applyMonthFilter();
})();

View File

@@ -1025,8 +1025,6 @@
}
});
var defaultFrom = Math.max(0, maxMonthIdx - 4);
fromSelect.value = defaultFrom;
toSelect.value = maxMonthIdx;
applyMonthFilter();
})();

View File

@@ -129,6 +129,7 @@ class TestWebApp(unittest.TestCase):
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
'purpose': '2026-01', 'message': 'test payment',
'sender': 'External Bank User', 'inferred_amount': 750,
'vs': '', 'sync_id': 'abc123',
}]
response = self.client.get('/api/adults')
self.assertEqual(response.status_code, 200)
@@ -155,6 +156,7 @@ class TestWebApp(unittest.TestCase):
mock_fetch_sheet.return_value = [{
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
'vs': '', 'sync_id': 'def456',
}]
response = self.client.get('/api/juniors')
self.assertEqual(response.status_code, 200)
@@ -172,6 +174,7 @@ class TestWebApp(unittest.TestCase):
mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
'vs': '', 'sync_id': 'ghi789',
}]
response = self.client.get('/api/payments')
self.assertEqual(response.status_code, 200)