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" ] } } 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. diff --git a/app.py b/app.py index 4f33b81..d83c0f8 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 @@ -255,7 +255,7 @@ def adults_view(): 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(): @@ -291,7 +291,7 @@ def juniors_view(): 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/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/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/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/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/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, 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) 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(); })();