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,