feat(display): limit /adults and /juniors to last N months by default #38
16
app.py
16
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,
|
||||
|
||||
146
docs/plans/2026-06-08-1118-months-to-show.md
Normal file
146
docs/plans/2026-06-08-1118-months-to-show.md
Normal 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`.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user