Compare commits

...

4 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
12 changed files with 381 additions and 17 deletions

View File

@@ -1,5 +1,20 @@
# 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).

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

@@ -55,14 +55,23 @@ func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
// ServeJuniors handles GET /api/juniors.
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
resp, err := h.AssembleJuniors(r.Context())
if err != nil {
h.writeError(w, r, err)
return
}
writeJSON(w, resp)
}
// AssembleJuniors loads all data and builds the juniors view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) {
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
if err != nil {
return JuniorsResponse{}, err
}
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
}
// ServePayments handles GET /api/payments.

View File

@@ -33,7 +33,18 @@ func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
}
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "juniors", PageData{Active: "juniors", Build: h.build})
data, err := h.apiHandler.AssembleJuniors(r.Context())
if err != nil {
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
Error: err.Error(),
})
return
}
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
Data: data,
})
}
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {

View File

@@ -23,6 +23,13 @@ type AdultsPageData struct {
Error string
}
// JuniorsPageData is the view model for the /juniors HTML page.
type JuniorsPageData struct {
PageData
Data api.JuniorsResponse
Error string
}
// Renderer parses and executes HTML templates from the embedded FS.
type Renderer struct {
tmpls map[string]*template.Template

View File

@@ -1,5 +1,121 @@
{{define "title"}}Juniors{{end}}
{{define "content"}}
<h1>Juniors Dashboard</h1>
<p class="description">Coming in M6.3</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}}
{{end}}
<script src="/static/js/filters.js" defer></script>
{{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

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