Compare commits
9 Commits
feat/go-m6
...
2cbd98df1a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2cbd98df1a | |||
| e618e906ef | |||
| 96e574e6c7 | |||
| 6a8aa37198 | |||
| aa0c17f521 | |||
| 464eeeb2b1 | |||
| daac5d7392 | |||
| c85748b3aa | |||
| 216b5b437a |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
## 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/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).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
|
|||||||
|
|
||||||
**Current milestone:** M6 — Go-native HTML frontend
|
**Current milestone:** M6 — Go-native HTML frontend
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-08 (M6.1)
|
**Last updated:** 2026-05-08 (M6.2 merged)
|
||||||
|
|
||||||
## How to use
|
## 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.
|
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||||
|
|
||||||
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
||||||
- [ ] **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.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.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
- [ ] **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, ↑/↓)
|
- [ ] **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, ↑/↓)
|
||||||
|
|||||||
131
docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Normal file
131
docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Normal 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.
|
||||||
142
docs/plans/2026-05-08-1052-period-selector-missing-old-months.md
Normal file
142
docs/plans/2026-05-08-1052-period-selector-missing-old-months.md
Normal 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 Sep–Nov) 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).
|
||||||
@@ -27,7 +27,9 @@ const (
|
|||||||
|
|
||||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||||
// Source month → target month (source attendance accumulated into target).
|
// 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.
|
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||||
var JuniorMergedMonths = map[string]string{
|
var JuniorMergedMonths = map[string]string{
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// adultMergedMonths is empty so 2025-09 stays as-is
|
// AdultMergedMonths sends 2025-09 → 2025-10
|
||||||
if len(months) != 1 || months[0] != "2025-09" {
|
if len(months) != 1 || months[0] != "2025-10" {
|
||||||
t.Errorf("unexpected months: %v", months)
|
t.Errorf("unexpected months: %v", months)
|
||||||
}
|
}
|
||||||
if len(members) != 2 {
|
if len(members) != 2 {
|
||||||
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byName := map[string]int{}
|
byName := map[string]int{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
byName[m.Name] = m.Fees["2025-09"].Attendance
|
byName[m.Name] = m.Fees["2025-10"].Attendance
|
||||||
}
|
}
|
||||||
if byName["Alice"] != 2 {
|
if byName["Alice"] != 2 {
|
||||||
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
||||||
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byName := map[string]int{}
|
byName := map[string]int{}
|
||||||
for _, m := range members {
|
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 {
|
if byName["Alice"] != 750 {
|
||||||
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,14 +34,23 @@ func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// ServeAdults handles GET /api/adults.
|
// ServeAdults handles GET /api/adults.
|
||||||
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
resp, err := h.AssembleAdults(r.Context())
|
||||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.writeError(w, r, err)
|
h.writeError(w, r, err)
|
||||||
return
|
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())
|
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.
|
// ServeJuniors handles GET /api/juniors.
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import "net/http"
|
import (
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
// HTMLHandler serves the Go-native HTML frontend.
|
// HTMLHandler serves the Go-native HTML frontend.
|
||||||
type HTMLHandler struct {
|
type HTMLHandler struct {
|
||||||
renderer *Renderer
|
renderer *Renderer
|
||||||
build BuildInfo
|
build BuildInfo
|
||||||
|
apiHandler *api.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLHandler constructs an HTMLHandler.
|
// NewHTMLHandler constructs an HTMLHandler.
|
||||||
func NewHTMLHandler(r *Renderer, b BuildInfo) *HTMLHandler {
|
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
||||||
return &HTMLHandler{renderer: r, build: b}
|
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "adults", PageData{Active: "adults", Build: h.build})
|
data, err := h.apiHandler.AssembleAdults(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
|
PageData: PageData{Active: "adults", Build: h.build},
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
|
PageData: PageData{Active: "adults", Build: h.build},
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,21 +1,61 @@
|
|||||||
package web_test
|
package web_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/domain/reconcile"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestHTMLHandlerSmoke(t *testing.T) {
|
||||||
renderer, err := web.NewRenderer()
|
renderer, err := web.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewRenderer: %v", err)
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
}
|
}
|
||||||
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
h := web.NewHTMLHandler(renderer, b)
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
path string
|
path string
|
||||||
@@ -52,3 +92,37 @@ func TestHTMLHandlerSmoke(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdultsPage(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeAdults(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"Adults Dashboard",
|
||||||
|
"Test Member",
|
||||||
|
"750/750 CZK (4)", // paid/expected (attendance)
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python assertion: cell text never says literally "OK"
|
||||||
|
if strings.Contains(body, ">OK<") {
|
||||||
|
t.Error("body should not contain >OK<")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PageData is the view model passed to every HTML template.
|
// PageData is the view model passed to every HTML template.
|
||||||
@@ -13,6 +16,13 @@ type PageData struct {
|
|||||||
Build BuildInfo
|
Build BuildInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdultsPageData is the view model for the /adults HTML page.
|
||||||
|
type AdultsPageData struct {
|
||||||
|
PageData
|
||||||
|
Data api.AdultsResponse
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
// Renderer parses and executes HTML templates from the embedded FS.
|
// Renderer parses and executes HTML templates from the embedded FS.
|
||||||
type Renderer struct {
|
type Renderer struct {
|
||||||
tmpls map[string]*template.Template
|
tmpls map[string]*template.Template
|
||||||
@@ -20,12 +30,43 @@ type Renderer struct {
|
|||||||
|
|
||||||
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
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.
|
// NewRenderer parses all templates from the embedded FS.
|
||||||
// A parse failure should be treated as a startup-time fatal error.
|
// A parse failure should be treated as a startup-time fatal error.
|
||||||
func NewRenderer() (*Renderer, error) {
|
func NewRenderer() (*Renderer, error) {
|
||||||
tmpls := make(map[string]*template.Template, len(pageNames))
|
tmpls := make(map[string]*template.Template, len(pageNames))
|
||||||
for _, name := range pageNames {
|
for _, name := range pageNames {
|
||||||
t, err := template.New("").ParseFS(templateFS,
|
t, err := template.New("").Funcs(tmplFuncs).ParseFS(templateFS,
|
||||||
"templates/base.tmpl",
|
"templates/base.tmpl",
|
||||||
"templates/partials/nav.tmpl",
|
"templates/partials/nav.tmpl",
|
||||||
"templates/partials/footer.tmpl",
|
"templates/partials/footer.tmpl",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
hh := NewHTMLHandler(renderer, build)
|
hh := NewHTMLHandler(renderer, build, ah)
|
||||||
|
|
||||||
staticSubFS, err := fs.Sub(staticFS, "static")
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ tr:hover {
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
background: #ff3333;
|
background: #ff3333;
|
||||||
color: white;
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
|
|||||||
91
go/internal/web/static/js/filters.js
Normal file
91
go/internal/web/static/js/filters.js
Normal 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();
|
||||||
|
}());
|
||||||
@@ -1,5 +1,141 @@
|
|||||||
{{define "title"}}Adults{{end}}
|
{{define "title"}}Adults{{end}}
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<h1>Adults Dashboard</h1>
|
<h1>Adults Dashboard</h1>
|
||||||
<p class="description">Coming in M6.2</p>
|
|
||||||
|
{{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}}
|
{{end}}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ JUNIOR_MONTHLY_RATE = {
|
|||||||
"2026-03": 250 # reduced fee for March 2026
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
}
|
}
|
||||||
ADULT_MERGED_MONTHS = {
|
ADULT_MERGED_MONTHS = {
|
||||||
#"2025-12": "2026-01", # keys are merged into values
|
"2025-09": "2025-10", # keys are merged into values
|
||||||
#"2025-09": "2025-10"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
JUNIOR_MERGED_MONTHS = {
|
JUNIOR_MERGED_MONTHS = {
|
||||||
|
|||||||
@@ -1044,8 +1044,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
|
||||||
fromSelect.value = defaultFrom;
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1025,8 +1025,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
|
||||||
fromSelect.value = defaultFrom;
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user