From 72e29b1882c778c85f4808d8fa3995f7a19a01dc Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Sun, 24 May 2026 21:58:25 +0200 Subject: [PATCH 1/5] chore(changelog): add entry for fee rate update 2026-05 through 2026-08 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41bd6f3..a47db86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026-05-24 21:58 CEST — feat(fees): update adult monthly rates for 2026-05 through 2026-08 + +- 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added). +- Mirrored in both `scripts/attendance.py` and `go/internal/domain/fees/fees.go`. + ## 2026-05-24 21:42 CEST — feat: multi-account Fio sync + switch QR default to 2502035405/2010 - Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync. -- 2.49.1 From 20b618685f4ec15c3d53d5897fbe7985f90f6403 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Mon, 8 Jun 2026 11:10:41 +0200 Subject: [PATCH 2/5] fix(reconcile): handle '?' expected fee in fill-first allocation Juniors with exactly 1 session get expected='?' (manual-review marker from attendance.py). The fill-first allocation block summed and cast expected values numerically without guarding against this, causing a TypeError: unsupported operand type(s) for +: 'int' and 'str' on the /juniors route whenever any matched payment landed on such a month. Add _expected_amount() helper that coerces non-numeric markers to 0 (same convention the final-balance calculation at line 512 already used) and apply it in the two failing spots plus the existing isinstance check. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-08-1110-junior-expected-fix.md | 90 +++++++++++++++++++ scripts/match_payments.py | 15 +++- 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-06-08-1110-junior-expected-fix.md diff --git a/docs/plans/2026-06-08-1110-junior-expected-fix.md b/docs/plans/2026-06-08-1110-junior-expected-fix.md new file mode 100644 index 0000000..31e8c9e --- /dev/null +++ b/docs/plans/2026-06-08-1110-junior-expected-fix.md @@ -0,0 +1,90 @@ +# Fix `/juniors` 500: `int + str` in reconcile allocation + +## Context + +The `/juniors` route crashes with: + +``` +File ".../scripts/match_payments.py", line 469, in reconcile + total_expected = sum(e for _, e in in_window) +TypeError: unsupported operand type(s) for +: 'int' and 'str' +``` + +**Why it happens:** A junior with exactly **1 session** in a month gets an +expected fee of the string `"?"` (manual-review marker), set in +[attendance.py:107](scripts/attendance.py#L107). That value flows unchanged into +the ledger as `ledger[name][m]["expected"]` +([match_payments.py:370](scripts/match_payments.py#L370)). + +When a bank payment is matched to such a junior+month, the new **fill-first +allocation** block builds `in_window` from those `expected` values and then sums +them ([match_payments.py:469](scripts/match_payments.py#L469)) and later does +`float(exp)` ([match_payments.py:480](scripts/match_payments.py#L480)) — both +blow up on the string `"?"`. Adults never hit this because adults never get +`"?"`, which is why only `/juniors` 500s. + +Note the final-balance code at +[match_payments.py:511-512](scripts/match_payments.py#L511-L512) **already** +handles this defensively (`mdata["expected"] if isinstance(..., int) else 0`). +The allocation block added in the recent `fill-first` work simply missed the +same guard. The intended convention is clear: a `"?"` (unknown) expected counts +as **0** for arithmetic, so any payment landing on such a month becomes surplus +/ positive balance rather than filling a deficit. + +## Approach + +Add one small helper and apply the existing "non-numeric expected → 0" +convention consistently inside `reconcile()`. + +### 1. Helper (near the top of `scripts/match_payments.py`) + +```python +def _expected_amount(value): + """Numeric value of an 'expected' fee; non-numeric markers like '?' → 0.""" + return value if isinstance(value, (int, float)) else 0 +``` + +### 2. Apply it in the allocation block + +- [match_payments.py:469](scripts/match_payments.py#L469): + `total_expected = sum(_expected_amount(e) for _, e in in_window)` +- [match_payments.py:480](scripts/match_payments.py#L480): + `deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)` + +With expected coerced to 0 for `"?"` months: +- A `"?"`-only payment falls into the existing `total_expected == 0` fallback + ([match_payments.py:495-499](scripts/match_payments.py#L495-L499)) and is + recorded as paid (prepayment behaviour) → shows as positive balance. +- Mixed with real months, the `"?"` month gets deficit 0 (skipped), real months + fill first, surplus → credit. Consistent with line 512. + +### 3. Reuse the helper at line 512 (optional consistency tidy) + +Replace the inline `isinstance(mdata["expected"], int)` check at +[match_payments.py:512](scripts/match_payments.py#L512) with +`_expected_amount(mdata["paid"]/expected)` form, i.e. use `_expected_amount(...)`. +This also closes a latent gap where a **float** exception amount would be treated +as 0 (current check only accepts `int`). + +`print_report` (line 552+) iterates adults only, so it's unaffected and needs no +change. + +## Verification + +1. `make web` and open `http://localhost:5001/juniors` — page renders 200 (was + 500). Confirm a junior with a single-session `"?"` month who also has a + matched payment shows a sensible balance. +2. `/adults` still renders unchanged (regression check). +3. `make test` — existing reconcile tests still pass; if there is a + reconcile test fixture, add a case where a junior month has `expected == "?"` + plus a matched transaction and assert no exception + payment counted as + surplus. + +## Notes + +- Single-file change: [scripts/match_payments.py](scripts/match_payments.py). +- This is a bug fix; per CLAUDE.md a small fix may go straight to `main`, but + confirm with the user whether to branch (`fix/junior-expected-question-mark`). +- Add a CHANGELOG.md entry once confirmed working. +- On execution, copy this plan to `docs/plans/-junior-expected-fix.md` per + the repo plan convention. diff --git a/scripts/match_payments.py b/scripts/match_payments.py index 443f9c6..8fc445c 100644 --- a/scripts/match_payments.py +++ b/scripts/match_payments.py @@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str: return re.sub(r"\s+", " ", normalize(name)).strip() +def _expected_amount(value) -> float: + """Numeric value of an expected fee; non-numeric markers like '?' → 0. + + Juniors with exactly 1 session get expected='?' (manual-review marker). + Treat those as 0 for arithmetic so payments become surplus/credit. + """ + return value if isinstance(value, (int, float)) else 0 + + # --------------------------------------------------------------------------- # Name matching # --------------------------------------------------------------------------- @@ -466,7 +475,7 @@ def reconcile( if not in_window: continue - total_expected = sum(e for _, e in in_window) + total_expected = sum(_expected_amount(e) for _, e in in_window) if total_expected > 0: # Fill-first: iterate in_window in matched_months order (chronological by @@ -477,7 +486,7 @@ def reconcile( remaining = in_window_share for m, exp in in_window: paid_so_far = ledger[member_name][m]["paid"] - deficit = max(0.0, float(exp) - paid_so_far) + deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far) alloc = min(remaining, deficit) if alloc <= 0: continue @@ -509,7 +518,7 @@ def reconcile( final_balances: dict[str, int] = {} for name in member_names: window_balance = sum( - int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0) + int(mdata["paid"]) - _expected_amount(mdata["expected"]) for mdata in ledger[name].values() ) final_balances[name] = window_balance + credits.get(name, 0) -- 2.49.1 From 37fc17cf9ce47b140de7fe999fa4b12f4c49f48c Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Mon, 8 Jun 2026 11:11:36 +0200 Subject: [PATCH 3/5] chore(settings): accumulate Claude Code permission allowlist entries Co-Authored-By: Claude Opus 4.8 --- .claude/settings.json | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index 43d8950..dac2975 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -10,7 +10,34 @@ "Bash(./bin/fuj help *)", "Bash(./bin/fuj version *)", "Bash(make go-test *)", - "Bash(make go-lint *)" + "Bash(make go-lint *)", + "Bash(tea pr create --title 'fix\\(go\\): pass raw value to FormatDate so numeric dates format' --description ' *)", + "Bash(git checkout *)", + "Bash(go build *)", + "Bash(go test *)", + "Bash(make parity *)", + "Bash(tea pr create --title 'fix\\(go\\): accept single-digit day/month in attendance date headers' --description ' *)", + "Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management checkout -b fix/period-selector-restore)", + "Bash(git pull *)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management log --oneline -20)", + "Bash(curl -s -o /tmp/fio-transparent.html \"https://ib.fio.cz/ib/transparent?a=2800359168\")", + "Read(//tmp/**)", + "Bash(grep -oE '[0-9]{1,2}\\\\.[0-9]{1,2}\\\\.[0-9]{2,4}' /tmp/fio-transparent.html | head -20)", + "Read(//private/tmp/**)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management status)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat HEAD)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management log -8 --oneline)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management tag --sort=-v:refname)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch --show-current)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management show --stat --format='%H %s%n%nbranch?: %d' 0.33)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management for-each-ref --format='%\\(refname:short\\) %\\(objectname:short\\) %\\(subject\\)' refs/tags)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch -r --contains 0.33)", + "Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat main HEAD -- Makefile scripts/fio_utils.py scripts/sync_fio_to_sheets.py CHANGELOG.md)", + "Bash(cp /Users/jan.novak/.claude/plans/in-python-app-i-m-distributed-muffin.md /Users/jan.novak/srv/personal/fuj-management/docs/plans/2026-06-08-1110-junior-expected-fix.md)" + ], + "additionalDirectories": [ + "/Users/jan.novak/srv/personal/fuj-management/docs/plans" ] } } -- 2.49.1 From c0487e3af0cbbf39c540aca8656f1ef01385a3ba Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Mon, 8 Jun 2026 11:18:55 +0200 Subject: [PATCH 4/5] feat(display): limit /adults and /juniors to last N months by default Show only the last MONTHS_TO_SHOW months (default 5) in the fee table columns so the page fits on screen without horizontal scrolling. Reconciliation still runs over the full month history so balances, credits, and debts are unaffected. Set MONTHS_TO_SHOW=0 to show all months. Implemented in both Python and Go. Co-Authored-By: Claude Opus 4.8 --- app.py | 16 +- docs/plans/2026-06-08-1118-months-to-show.md | 146 +++++++++++++++++++ go/internal/config/config.go | 11 ++ go/internal/web/api/handler.go | 13 +- scripts/config.py | 3 + 5 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-06-08-1118-months-to-show.md diff --git a/app.py b/app.py index 4f33b81..f0d1408 100644 --- a/app.py +++ b/app.py @@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir)) from config import ( ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID, - BANK_ACCOUNT, CREDENTIALS_PATH, + BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW, ) from attendance import get_members_with_fees, get_junior_members_with_fees from match_payments import reconcile, fetch_sheet_data, fetch_exceptions @@ -33,6 +33,12 @@ from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_ from sync_fio_to_sheets import sync_to_sheets from infer_payments import infer_payments + +def _last_n_months(months): + """Return the last MONTHS_TO_SHOW months; 0 means show all.""" + return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months + + def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): mod_time = get_sheet_modified_time(cache_key) if mod_time: @@ -175,7 +181,7 @@ def api_adults(): ) result = reconcile(members, sorted_months, transactions, exceptions) vm = build_adults_view_model( - members, sorted_months, result, transactions, + members, _last_n_months(sorted_months), result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, ) @@ -199,7 +205,7 @@ def api_juniors(): adapted_members = adapt_junior_members(junior_members) result = reconcile(adapted_members, sorted_months, transactions, exceptions) vm = build_juniors_view_model( - junior_members, adapted_members, sorted_months, result, transactions, + junior_members, adapted_members, _last_n_months(sorted_months), result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, ) @@ -248,7 +254,7 @@ def adults_view(): record_step("reconcile") vm = build_adults_view_model( - members, sorted_months, result, transactions, + members, _last_n_months(sorted_months), result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, @@ -284,7 +290,7 @@ def juniors_view(): record_step("reconcile") vm = build_juniors_view_model( - junior_members, adapted_members, sorted_months, result, transactions, + junior_members, adapted_members, _last_n_months(sorted_months), result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, diff --git a/docs/plans/2026-06-08-1118-months-to-show.md b/docs/plans/2026-06-08-1118-months-to-show.md new file mode 100644 index 0000000..b5ab2cf --- /dev/null +++ b/docs/plans/2026-06-08-1118-months-to-show.md @@ -0,0 +1,146 @@ +# Plan: Limit /adults and /juniors to last N months (default 5) + +## Context + +The `/adults` and `/juniors` dashboard tables render one column per month of +attendance/fee history. As the season accumulates months, the tables grow wider +than the screen. The goal is to show only the **last N months** by default +(N = 5), with N configurable via an env var, so the tables fit on screen. This +must be implemented in **both** the Python/Flask version and the Go version, +keeping their behavior identical. + +### Key correctness requirement + +Member **balances, credits, and debts** must continue to reflect *all* history, +not just the visible window. Hiding older columns must not hide older debt. + +This is naturally satisfied because in both codebases the balance math iterates +over the full per-member month map produced by reconcile, while only the +*column rendering* iterates over the passed-in month list: + +- Python `scripts/views.py`: `settled_balance` / `_settled_balance` / + credits / debts loop over `data["months"].items()` (full), whereas columns, + totals, and per-row cells loop over `sorted_months`. +- Go `build_adults.go` / `build_juniors.go`: `settledBalance(mr, ...)` loops + over `mr.Months` (full); columns/totals/cells loop over `sortedMonths`. + +Therefore the correct seam is: run `reconcile()` / `Reconcile()` on the **full** +month list, then trim the list to the last N **only for the view-model builder**. +The member-details modal also keeps full history because it reads the untrimmed +`member_data` / `MemberData`. + +## Approach + +Add a `MONTHS_TO_SHOW` tunable (default 5; `<= 0` means "show all" as an escape +hatch). Trim `sorted_months`/`sortedMonths` to the last N immediately before the +view-model builder, leaving reconcile on the full list. + +### Python + +1. **`scripts/config.py`** — add, next to the existing `CACHE_TTL_SECONDS` + pattern (`int(os.environ.get(...))`): + ```python + MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) + ``` + +2. **`app.py`** — add a small helper (module-level) and apply it in all four + routes that build the adults/juniors view models: + ```python + from config import MONTHS_TO_SHOW # add to existing config import + + def _last_n_months(months): + return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months + ``` + In each route, keep `reconcile(members, sorted_months, ...)` on the full + list, then pass the trimmed list to the builder: + ```python + result = reconcile(members, sorted_months, transactions, exceptions) + display_months = _last_n_months(sorted_months) + vm = build_adults_view_model(members, display_months, result, ...) + ``` + Apply to: `adults_view()` (~`app.py:226`), `juniors_view()` (~`app.py:260`), + and the JSON twins `/api/adults` (~`app.py:161`) and `/api/juniors` + (~`app.py:184`) for parity. + + No changes to `scripts/views.py` — it already derives `months`, + `raw_months`, `totals`, and per-row `row.months` from whatever month list it + receives, and balances/credits/debts from the full `result`. + +### Go + +1. **`go/internal/config/config.go`** — add `MonthsToShow int` to the `Config` + struct (~line 57-67), populate it in `Load()` (~line 80-90) with a new + integer helper modeled on `envDuration`: + ```go + func envInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback + } + ``` + ```go + MonthsToShow: envInt("MONTHS_TO_SHOW", 5), + ``` + (Note: unlike `envDuration`, accept `<= 0` so it can mean "show all".) + +2. **`go/internal/web/api/handler.go`** — in `AssembleAdults` + (lines 50-57) and `AssembleJuniors` (lines 71-78), keep `Reconcile` on the + full `sortedMonths`, then trim before the builder: + ```go + result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) + displayMonths := lastNMonths(sortedMonths, h.Config.MonthsToShow) + return buildAdultsResponse(members, displayMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil + ``` + Add a small helper (e.g. in handler.go): + ```go + func lastNMonths(months []string, n int) []string { + if n > 0 && len(months) > n { + return months[len(months)-n:] + } + return months + } + ``` + No changes to `build_adults.go` / `build_juniors.go` or the templates — they + already derive `Months`, `RawMonths`, `Totals`, and per-row cells from the + passed-in `sortedMonths`, and balances/credits/debts from the full + `result.Members[...]`. + +## Critical files + +- `scripts/config.py` — new `MONTHS_TO_SHOW` constant. +- `app.py` — trim helper + apply in 4 routes (HTML + JSON, adults + juniors). +- `go/internal/config/config.go` — `MonthsToShow` field + `envInt` helper. +- `go/internal/web/api/handler.go` — `lastNMonths` helper + apply in + `AssembleAdults` / `AssembleJuniors`. + +No template or `views.py` / `build_*.go` changes required. + +## Verification + +Python: +- `make test` (and a targeted run, e.g. + `PYTHONPATH=scripts:. python -m unittest tests.test_app`). +- `make web`, open `/adults` and `/juniors`: confirm exactly 5 month columns by + default, and that the **Balance** column / credits / debts are unchanged from + before (compare against an untrimmed run, e.g. `MONTHS_TO_SHOW=0`). +- `MONTHS_TO_SHOW=3 make web` → 3 columns; `MONTHS_TO_SHOW=0` → all columns. +- Spot-check that the month-range filter dropdowns and a member-details modal + (full history) still work. + +Go: +- `cd go && go build ./... && go test ./...`. +- Run the Go server, open `/adults` and `/juniors`: same checks as above + (default 5 columns, balances unchanged, `MONTHS_TO_SHOW` env override works). +- Confirm Python and Go render the same number of columns and identical + balances for the same data. + +## Housekeeping + +- Per `CLAUDE.md`, copy this plan to + `docs/plans/YYYY-MM-DD-HHMM-months-to-show.md` during implementation (create + `docs/plans/` if missing) and add a `CHANGELOG.md` entry once verified. +- Per `CLAUDE.md`, this is a feature → do it on a `feat/months-to-show` branch + and open a Gitea MR with `tea`; do not commit to `main`. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 12f37bd..babaa00 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -64,6 +64,7 @@ type Config struct { DriveTimeout time.Duration LogLevel string ServerAddr string + MonthsToShow int // show last N month columns; 0 means show all } // Load reads configuration from the environment, applying defaults that @@ -87,6 +88,7 @@ func Load() Config { DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10), LogLevel: env("LOG_LEVEL", "INFO"), ServerAddr: env("SERVER_ADDR", ":8080"), + MonthsToShow: envInt("MONTHS_TO_SHOW", 5), } } @@ -121,3 +123,12 @@ func envDuration(key string, defaultSeconds int) time.Duration { } return time.Duration(defaultSeconds) * time.Second } + +func envInt(key string, fallback int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return fallback +} diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go index 234a84c..118bb6f 100644 --- a/go/internal/web/api/handler.go +++ b/go/internal/web/api/handler.go @@ -53,7 +53,7 @@ func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) { return AdultsResponse{}, err } result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) - return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil + return buildAdultsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil } // ServeJuniors handles GET /api/juniors. @@ -74,7 +74,16 @@ func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) return JuniorsResponse{}, err } result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) - return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil + return buildJuniorsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil +} + +// lastNMonths returns the last n elements of months. +// If n <= 0 or n >= len(months), the full slice is returned unchanged. +func lastNMonths(months []string, n int) []string { + if n > 0 && len(months) > n { + return months[len(months)-n:] + } + return months } // ServePayments handles GET /api/payments. diff --git a/scripts/config.py b/scripts/config.py index bb5d72b..19a45be 100644 --- a/scripts/config.py +++ b/scripts/config.py @@ -40,6 +40,9 @@ DRIVE_TIMEOUT = 10 # seconds CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default +# Display settings +MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) # show last N months; 0 = show all + # Maps cache keys to their source sheet IDs (used by cache_utils) CACHE_SHEET_MAP = { "attendance_regular": ATTENDANCE_SHEET_ID, -- 2.49.1 From c2a381bb6398e94ff62dc6e14b064ba8ea63ffbc Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Mon, 8 Jun 2026 11:28:40 +0200 Subject: [PATCH 5/5] fix(display): default from-selector to last N months; keep all months selectable Instead of hiding older months entirely, show all months in the from/to selectors but default the from-select to the last MONTHS_TO_SHOW months on page load. The "All" button resets to full history as before. Python: passes months_to_show to render_template, IIFE sets fromSelect.value. Go: adds MonthsToShow to response structs, data-months-to-show attr in templates, filters.js reads it and defaults fromSelect after hideFutureMonths. Co-Authored-By: Claude Opus 4.8 --- app.py | 18 ++++++------------ go/internal/web/api/adults.go | 1 + go/internal/web/api/build_adults.go | 1 + go/internal/web/api/build_juniors.go | 1 + go/internal/web/api/handler.go | 13 ++----------- go/internal/web/api/juniors.go | 1 + go/internal/web/static/js/filters.js | 9 ++++++++- go/internal/web/templates/adults.tmpl | 2 +- go/internal/web/templates/juniors.tmpl | 2 +- .../fixtures/api-schema/adults.schema.json | 6 +++++- .../fixtures/api-schema/juniors.schema.json | 6 +++++- templates/adults.html | 1 + templates/juniors.html | 1 + 13 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app.py b/app.py index f0d1408..d83c0f8 100644 --- a/app.py +++ b/app.py @@ -33,12 +33,6 @@ from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_ from sync_fio_to_sheets import sync_to_sheets from infer_payments import infer_payments - -def _last_n_months(months): - """Return the last MONTHS_TO_SHOW months; 0 means show all.""" - return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months - - def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): mod_time = get_sheet_modified_time(cache_key) if mod_time: @@ -181,7 +175,7 @@ def api_adults(): ) result = reconcile(members, sorted_months, transactions, exceptions) vm = build_adults_view_model( - members, _last_n_months(sorted_months), result, transactions, + members, sorted_months, result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, ) @@ -205,7 +199,7 @@ def api_juniors(): adapted_members = adapt_junior_members(junior_members) result = reconcile(adapted_members, sorted_months, transactions, exceptions) vm = build_juniors_view_model( - junior_members, adapted_members, _last_n_months(sorted_months), result, transactions, + junior_members, adapted_members, sorted_months, result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, ) @@ -254,14 +248,14 @@ def adults_view(): record_step("reconcile") vm = build_adults_view_model( - members, _last_n_months(sorted_months), result, transactions, + members, sorted_months, result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, ) record_step("process_data") - return render_template("adults.html", **vm) + return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm) @app.route("/juniors") def juniors_view(): @@ -290,14 +284,14 @@ def juniors_view(): record_step("reconcile") vm = build_juniors_view_model( - junior_members, adapted_members, _last_n_months(sorted_months), result, transactions, + junior_members, adapted_members, sorted_months, result, transactions, datetime.now().strftime("%Y-%m"), attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, ) record_step("process_data") - return render_template("juniors.html", **vm) + return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm) @app.route("/payments") def payments(): diff --git a/go/internal/web/api/adults.go b/go/internal/web/api/adults.go index f891e3a..a5fc7c2 100644 --- a/go/internal/web/api/adults.go +++ b/go/internal/web/api/adults.go @@ -39,4 +39,5 @@ type AdultsResponse struct { PaymentsURL string `json:"payments_url"` BankAccount string `json:"bank_account"` CurrentMonth string `json:"current_month"` + MonthsToShow int `json:"months_to_show"` } diff --git a/go/internal/web/api/build_adults.go b/go/internal/web/api/build_adults.go index 61dbaad..008f317 100644 --- a/go/internal/web/api/build_adults.go +++ b/go/internal/web/api/build_adults.go @@ -140,6 +140,7 @@ func buildAdultsResponse( PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", BankAccount: cfg.QRAccount, CurrentMonth: currentMonth, + MonthsToShow: cfg.MonthsToShow, } } diff --git a/go/internal/web/api/build_juniors.go b/go/internal/web/api/build_juniors.go index 9b080cd..e4b8043 100644 --- a/go/internal/web/api/build_juniors.go +++ b/go/internal/web/api/build_juniors.go @@ -136,6 +136,7 @@ func buildJuniorsResponse( PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit", BankAccount: cfg.QRAccount, CurrentMonth: currentMonth, + MonthsToShow: cfg.MonthsToShow, } } diff --git a/go/internal/web/api/handler.go b/go/internal/web/api/handler.go index 118bb6f..234a84c 100644 --- a/go/internal/web/api/handler.go +++ b/go/internal/web/api/handler.go @@ -53,7 +53,7 @@ func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) { return AdultsResponse{}, err } result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) - return buildAdultsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil + return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil } // ServeJuniors handles GET /api/juniors. @@ -74,16 +74,7 @@ func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) return JuniorsResponse{}, err } result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) - return buildJuniorsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil -} - -// lastNMonths returns the last n elements of months. -// If n <= 0 or n >= len(months), the full slice is returned unchanged. -func lastNMonths(months []string, n int) []string { - if n > 0 && len(months) > n { - return months[len(months)-n:] - } - return months + return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil } // ServePayments handles GET /api/payments. diff --git a/go/internal/web/api/juniors.go b/go/internal/web/api/juniors.go index d07c777..3e2c6b6 100644 --- a/go/internal/web/api/juniors.go +++ b/go/internal/web/api/juniors.go @@ -38,4 +38,5 @@ type JuniorsResponse struct { PaymentsURL string `json:"payments_url"` BankAccount string `json:"bank_account"` CurrentMonth string `json:"current_month"` + MonthsToShow int `json:"months_to_show"` } diff --git a/go/internal/web/static/js/filters.js b/go/internal/web/static/js/filters.js index 6fc5468..a238efc 100644 --- a/go/internal/web/static/js/filters.js +++ b/go/internal/web/static/js/filters.js @@ -12,7 +12,8 @@ const container = document.getElementById('filterContainer'); if (!container) return; - const currentMonth = container.dataset.currentMonth || ''; + const currentMonth = container.dataset.currentMonth || ''; + const monthsToShow = parseInt(container.dataset.monthsToShow || '0', 10); const nameInput = document.getElementById('nameFilter'); const fromSelect = document.getElementById('fromMonth'); @@ -88,4 +89,10 @@ // ── Initialise ──────────────────────────────────────────────────────────── hideFutureMonths(); + // Default the from-select to show only the last N months. + if (monthsToShow > 0 && toSelect.value !== '') { + const defaultFrom = Math.max(0, parseInt(toSelect.value, 10) - monthsToShow + 1); + fromSelect.value = String(defaultFrom); + applyMonthFilter(); + } }()); diff --git a/go/internal/web/templates/adults.tmpl b/go/internal/web/templates/adults.tmpl index 2a9129c..f8581bd 100644 --- a/go/internal/web/templates/adults.tmpl +++ b/go/internal/web/templates/adults.tmpl @@ -12,7 +12,7 @@ Payments Ledger -
+
diff --git a/go/internal/web/templates/juniors.tmpl b/go/internal/web/templates/juniors.tmpl index 0f794b4..dd9136a 100644 --- a/go/internal/web/templates/juniors.tmpl +++ b/go/internal/web/templates/juniors.tmpl @@ -12,7 +12,7 @@ Payments Ledger
-
+
diff --git a/go/tests/fixtures/api-schema/adults.schema.json b/go/tests/fixtures/api-schema/adults.schema.json index 437b227..a9c6af7 100644 --- a/go/tests/fixtures/api-schema/adults.schema.json +++ b/go/tests/fixtures/api-schema/adults.schema.json @@ -143,6 +143,9 @@ }, "current_month": { "type": "string" + }, + "months_to_show": { + "type": "integer" } }, "additionalProperties": false, @@ -161,7 +164,8 @@ "attendance_url", "payments_url", "bank_account", - "current_month" + "current_month", + "months_to_show" ] }, "Credit": { diff --git a/go/tests/fixtures/api-schema/juniors.schema.json b/go/tests/fixtures/api-schema/juniors.schema.json index 337a62e..f7d012d 100644 --- a/go/tests/fixtures/api-schema/juniors.schema.json +++ b/go/tests/fixtures/api-schema/juniors.schema.json @@ -187,6 +187,9 @@ }, "current_month": { "type": "string" + }, + "months_to_show": { + "type": "integer" } }, "additionalProperties": false, @@ -205,7 +208,8 @@ "attendance_url", "payments_url", "bank_account", - "current_month" + "current_month", + "months_to_show" ] }, "MemberOtherEntry": { diff --git a/templates/adults.html b/templates/adults.html index d9762b5..92cb081 100644 --- a/templates/adults.html +++ b/templates/adults.html @@ -1045,6 +1045,7 @@ }); toSelect.value = maxMonthIdx; + fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1); applyMonthFilter(); })(); diff --git a/templates/juniors.html b/templates/juniors.html index 2342d27..8efbad1 100644 --- a/templates/juniors.html +++ b/templates/juniors.html @@ -1026,6 +1026,7 @@ }); toSelect.value = maxMonthIdx; + fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1); applyMonthFilter(); })(); -- 2.49.1