feat(display): limit /adults and /juniors to last N months by default
All checks were successful
Deploy to K8s / deploy (push) Successful in 17s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 11:18:55 +02:00
parent 37fc17cf9c
commit c0487e3af0
5 changed files with 182 additions and 7 deletions

16
app.py
View File

@@ -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,

View File

@@ -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`.

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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,