Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f2994b8ad | |||
| c2a381bb63 | |||
| c0487e3af0 | |||
| 37fc17cf9c | |||
| 20b618685f |
@@ -10,7 +10,34 @@
|
|||||||
"Bash(./bin/fuj help *)",
|
"Bash(./bin/fuj help *)",
|
||||||
"Bash(./bin/fuj version *)",
|
"Bash(./bin/fuj version *)",
|
||||||
"Bash(make go-test *)",
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
app.py
6
app.py
@@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir))
|
|||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
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 attendance import get_members_with_fees, get_junior_members_with_fees
|
||||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
@@ -255,7 +255,7 @@ def adults_view():
|
|||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
)
|
)
|
||||||
record_step("process_data")
|
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")
|
@app.route("/juniors")
|
||||||
def juniors_view():
|
def juniors_view():
|
||||||
@@ -291,7 +291,7 @@ def juniors_view():
|
|||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
)
|
)
|
||||||
record_step("process_data")
|
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")
|
@app.route("/payments")
|
||||||
def payments():
|
def payments():
|
||||||
|
|||||||
90
docs/plans/2026-06-08-1110-junior-expected-fix.md
Normal file
90
docs/plans/2026-06-08-1110-junior-expected-fix.md
Normal file
@@ -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/<ts>-junior-expected-fix.md` per
|
||||||
|
the repo plan convention.
|
||||||
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
|
DriveTimeout time.Duration
|
||||||
LogLevel string
|
LogLevel string
|
||||||
ServerAddr string
|
ServerAddr string
|
||||||
|
MonthsToShow int // show last N month columns; 0 means show all
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from the environment, applying defaults that
|
// Load reads configuration from the environment, applying defaults that
|
||||||
@@ -87,6 +88,7 @@ func Load() Config {
|
|||||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||||
LogLevel: env("LOG_LEVEL", "INFO"),
|
LogLevel: env("LOG_LEVEL", "INFO"),
|
||||||
ServerAddr: env("SERVER_ADDR", ":8080"),
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,4 +39,5 @@ type AdultsResponse struct {
|
|||||||
PaymentsURL string `json:"payments_url"`
|
PaymentsURL string `json:"payments_url"`
|
||||||
BankAccount string `json:"bank_account"`
|
BankAccount string `json:"bank_account"`
|
||||||
CurrentMonth string `json:"current_month"`
|
CurrentMonth string `json:"current_month"`
|
||||||
|
MonthsToShow int `json:"months_to_show"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ func buildAdultsResponse(
|
|||||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
BankAccount: cfg.QRAccount,
|
BankAccount: cfg.QRAccount,
|
||||||
CurrentMonth: currentMonth,
|
CurrentMonth: currentMonth,
|
||||||
|
MonthsToShow: cfg.MonthsToShow,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ func buildJuniorsResponse(
|
|||||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
BankAccount: cfg.QRAccount,
|
BankAccount: cfg.QRAccount,
|
||||||
CurrentMonth: currentMonth,
|
CurrentMonth: currentMonth,
|
||||||
|
MonthsToShow: cfg.MonthsToShow,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,4 +38,5 @@ type JuniorsResponse struct {
|
|||||||
PaymentsURL string `json:"payments_url"`
|
PaymentsURL string `json:"payments_url"`
|
||||||
BankAccount string `json:"bank_account"`
|
BankAccount string `json:"bank_account"`
|
||||||
CurrentMonth string `json:"current_month"`
|
CurrentMonth string `json:"current_month"`
|
||||||
|
MonthsToShow int `json:"months_to_show"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
if (!container) return;
|
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 nameInput = document.getElementById('nameFilter');
|
||||||
const fromSelect = document.getElementById('fromMonth');
|
const fromSelect = document.getElementById('fromMonth');
|
||||||
@@ -88,4 +89,10 @@
|
|||||||
// ── Initialise ────────────────────────────────────────────────────────────
|
// ── Initialise ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
hideFutureMonths();
|
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();
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
|
||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<label class="filter-label" for="nameFilter">Member</label>
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
|||||||
@@ -143,6 +143,9 @@
|
|||||||
},
|
},
|
||||||
"current_month": {
|
"current_month": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months_to_show": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -161,7 +164,8 @@
|
|||||||
"attendance_url",
|
"attendance_url",
|
||||||
"payments_url",
|
"payments_url",
|
||||||
"bank_account",
|
"bank_account",
|
||||||
"current_month"
|
"current_month",
|
||||||
|
"months_to_show"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Credit": {
|
"Credit": {
|
||||||
|
|||||||
@@ -187,6 +187,9 @@
|
|||||||
},
|
},
|
||||||
"current_month": {
|
"current_month": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months_to_show": {
|
||||||
|
"type": "integer"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -205,7 +208,8 @@
|
|||||||
"attendance_url",
|
"attendance_url",
|
||||||
"payments_url",
|
"payments_url",
|
||||||
"bank_account",
|
"bank_account",
|
||||||
"current_month"
|
"current_month",
|
||||||
|
"months_to_show"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"MemberOtherEntry": {
|
"MemberOtherEntry": {
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ DRIVE_TIMEOUT = 10 # seconds
|
|||||||
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
|
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
|
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)
|
# Maps cache keys to their source sheet IDs (used by cache_utils)
|
||||||
CACHE_SHEET_MAP = {
|
CACHE_SHEET_MAP = {
|
||||||
"attendance_regular": ATTENDANCE_SHEET_ID,
|
"attendance_regular": ATTENDANCE_SHEET_ID,
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str:
|
|||||||
return re.sub(r"\s+", " ", normalize(name)).strip()
|
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
|
# Name matching
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -466,7 +475,7 @@ def reconcile(
|
|||||||
if not in_window:
|
if not in_window:
|
||||||
continue
|
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:
|
if total_expected > 0:
|
||||||
# Fill-first: iterate in_window in matched_months order (chronological by
|
# Fill-first: iterate in_window in matched_months order (chronological by
|
||||||
@@ -477,7 +486,7 @@ def reconcile(
|
|||||||
remaining = in_window_share
|
remaining = in_window_share
|
||||||
for m, exp in in_window:
|
for m, exp in in_window:
|
||||||
paid_so_far = ledger[member_name][m]["paid"]
|
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)
|
alloc = min(remaining, deficit)
|
||||||
if alloc <= 0:
|
if alloc <= 0:
|
||||||
continue
|
continue
|
||||||
@@ -509,7 +518,7 @@ def reconcile(
|
|||||||
final_balances: dict[str, int] = {}
|
final_balances: dict[str, int] = {}
|
||||||
for name in member_names:
|
for name in member_names:
|
||||||
window_balance = sum(
|
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()
|
for mdata in ledger[name].values()
|
||||||
)
|
)
|
||||||
final_balances[name] = window_balance + credits.get(name, 0)
|
final_balances[name] = window_balance + credits.get(name, 0)
|
||||||
|
|||||||
@@ -1045,6 +1045,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
|
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1026,6 +1026,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
|
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user