Compare commits
11 Commits
241fecfb2c
...
fix/gitops
| Author | SHA1 | Date | |
|---|---|---|---|
| ad127d36ea | |||
| 29938d7a0c | |||
| 1df1863725 | |||
| 995abfacb2 | |||
| f047150004 | |||
| 6f2994b8ad | |||
| c2a381bb63 | |||
| c0487e3af0 | |||
| 37fc17cf9c | |||
| 20b618685f | |||
| 72e29b1882 |
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
.gitea/workflows/gitops-update.yaml
Normal file
100
.gitea/workflows/gitops-update.yaml
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
name: GitOps image update
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Auto-fires when "Build and Push" completes successfully (tag push).
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build and Push"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
# Manual trigger for dry-runs and one-off bumps.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Git tag to deploy (without the -go suffix, e.g. 0.37)"
|
||||||
|
required: true
|
||||||
|
dry_run:
|
||||||
|
description: "Dry run — print diff, do not open a PR"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
uh_cli_version:
|
||||||
|
description: "uh-cli version override (e.g. v0.2.0). Defaults to v0.1.0."
|
||||||
|
required: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEA_VERSION: "0.9.2"
|
||||||
|
# Resolved priority: manual input → repo/org variable → hardcoded default.
|
||||||
|
UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitops-pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Skip if triggered by workflow_run that did not succeed.
|
||||||
|
if: >
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
github.event.workflow_run.conclusion == 'success'
|
||||||
|
container:
|
||||||
|
image: ubuntu:latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Install git, curl, ca-certificates
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends git curl ca-certificates
|
||||||
|
|
||||||
|
- name: Install tea
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
|
||||||
|
-o /usr/local/bin/tea
|
||||||
|
chmod +x /usr/local/bin/tea
|
||||||
|
|
||||||
|
- name: Install uh-cli
|
||||||
|
run: |
|
||||||
|
curl -fsSL \
|
||||||
|
"https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64" \
|
||||||
|
-o /usr/local/bin/uh-cli
|
||||||
|
chmod +x /usr/local/bin/uh-cli
|
||||||
|
|
||||||
|
- name: Resolve image tag
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
|
GIT_TAG="${{ inputs.tag }}"
|
||||||
|
else
|
||||||
|
# workflow_run: use the ref name of the triggering workflow (the pushed git tag).
|
||||||
|
GIT_TAG="${{ github.event.workflow_run.head_branch }}"
|
||||||
|
fi
|
||||||
|
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
|
||||||
|
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved image: ${IMAGE}"
|
||||||
|
|
||||||
|
- name: Configure git identity and credentials
|
||||||
|
run: |
|
||||||
|
git config --global user.name "uh-cli bot"
|
||||||
|
git config --global user.email "bot@hrajfrisbee.cz"
|
||||||
|
# Store credentials separately so the --git-repo URL stays clean.
|
||||||
|
# Tea matches the login URL against the remote URL; embedded credentials
|
||||||
|
# break that matching and cause "path segment [0] is empty" on pr create.
|
||||||
|
git config --global credential.helper store
|
||||||
|
echo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz" >> ~/.git-credentials
|
||||||
|
|
||||||
|
- name: Authenticate tea
|
||||||
|
run: |
|
||||||
|
tea login add \
|
||||||
|
--name ci \
|
||||||
|
--url https://gitea.home.hrajfrisbee.cz \
|
||||||
|
--token "$GITEA_TOKEN"
|
||||||
|
|
||||||
|
- name: Open image-update PR (or dry run)
|
||||||
|
run: |
|
||||||
|
set -x
|
||||||
|
uh-cli -v gitops deployment update \
|
||||||
|
--deployment-name fuj-management \
|
||||||
|
--deployment-namespace fuj \
|
||||||
|
--set-image "${{ steps.resolve.outputs.image }}" \
|
||||||
|
--git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
|
||||||
|
--git-path gitops/home-kubernetes \
|
||||||
|
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
|
||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -1,5 +1,16 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
|
||||||
|
|
||||||
|
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
|
||||||
|
- Supports `workflow_run` auto-trigger and `workflow_dispatch` with `dry_run` / `uh_cli_version` inputs.
|
||||||
|
- Requires `GITOPS_TOKEN` repo secret (Gitea PAT with write+PR access to `home-kubernetes`).
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync.
|
||||||
|
|||||||
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`.
|
||||||
112
docs/plans/2026-06-12-1927-gitops-pr-action.md
Normal file
112
docs/plans/2026-06-12-1927-gitops-pr-action.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Plan: Gitea Action to open a gitops image-update PR for fuj-management
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Go image of this app is built and pushed by the `build-go` job in
|
||||||
|
[.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), tagged
|
||||||
|
`gitea.home.hrajfrisbee.cz/kacerr/fuj-management:<git-tag>-go` (e.g. `0.37-go`).
|
||||||
|
|
||||||
|
Kubernetes manifests live in a **separate** repo,
|
||||||
|
`gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes`. Today, bumping the image in
|
||||||
|
the `fuj-management` Deployment (namespace `fuj`) is a manual edit there.
|
||||||
|
|
||||||
|
We want CI to automate that bump: when a new Go image is built, open a PR against
|
||||||
|
`home-kubernetes` that swaps the image to the freshly built tag — using the
|
||||||
|
`uh-cli gitops deployment update` command. The user reviews/merges that PR in
|
||||||
|
Gitea (matching the existing branch-per-change, merge-in-browser workflow).
|
||||||
|
|
||||||
|
Decisions confirmed with the user:
|
||||||
|
- **Separate workflow file** (not a job inside build.yaml).
|
||||||
|
- **New `GITOPS_TOKEN` secret** for home-kubernetes write + PR access.
|
||||||
|
- **uh-cli version pinned with a default, overridable via env/var/input.**
|
||||||
|
|
||||||
|
## How uh-cli works (from `/Users/jan.novak/srv/go/uh-cli/docs/`)
|
||||||
|
|
||||||
|
- `uh-cli gitops deployment update` clones `--git-repo`, walks `--git-path`
|
||||||
|
recursively for a `kind: Deployment` whose `metadata.name`/`namespace` match,
|
||||||
|
edits the first container image surgically, commits on a new branch
|
||||||
|
`gitops/update-<name>-<timestamp>`, pushes, and **opens the PR itself** via
|
||||||
|
`tea pr create`. PR base is always `main`; title/body are hardcoded (no flags).
|
||||||
|
- Requires on PATH: `git` and `tea` (tea only for the PR flow; `--force` skips it).
|
||||||
|
- Auth: token embedded in the `--git-repo` URL (`https://user:TOKEN@host/...`);
|
||||||
|
`tea login add` for PR creation; git identity via `git config`/env vars.
|
||||||
|
- `--dry-run` prints the unified diff and makes no git changes. Global `-v`
|
||||||
|
(placed **before** the subcommand) enables debug logging on stderr.
|
||||||
|
- Release binaries are named `uh-cli-<version>-linux-amd64` (version includes the
|
||||||
|
`v`), attached to the Gitea release. Latest tag today is **`v0.1.0`**.
|
||||||
|
|
||||||
|
## Change: new workflow `.gitea/workflows/gitops-update.yaml`
|
||||||
|
|
||||||
|
Triggers:
|
||||||
|
- `workflow_run` on `workflows: ["Build and Push"]`, `types: [completed]`, gated
|
||||||
|
to `conclusion == 'success'` — auto-fires after the image build succeeds.
|
||||||
|
- `workflow_dispatch` with inputs: `tag` (git tag without the `-go` suffix, e.g.
|
||||||
|
`0.37`), `dry_run` (boolean, default false), `uh_cli_version` (optional override).
|
||||||
|
|
||||||
|
Single job `gitops-pr`, `runs-on: ubuntu-latest`, in a `container: ubuntu:latest`
|
||||||
|
for a hermetic install (matches the uh-cli CI doc pattern). Steps:
|
||||||
|
|
||||||
|
1. **Install git, curl, ca-certificates, tea** — apt-get + download tea
|
||||||
|
`0.9.2` from `gitea.com/gitea/tea/releases/...` to `/usr/local/bin/tea`.
|
||||||
|
2. **Install uh-cli** — download
|
||||||
|
`https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64`
|
||||||
|
to `/usr/local/bin/uh-cli`.
|
||||||
|
`UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}`.
|
||||||
|
3. **Resolve image tag** — if `workflow_dispatch`, use `inputs.tag`; else use
|
||||||
|
`github.event.workflow_run.head_branch` (the pushed tag name). Output
|
||||||
|
`gitea.home.hrajfrisbee.cz/${{ github.repository }}:<tag>-go`.
|
||||||
|
4. **Configure git identity** — `git config --global user.name/email` for the bot.
|
||||||
|
5. **Authenticate tea** — `tea login add --name ci --url https://gitea.home.hrajfrisbee.cz --token "$GITEA_TOKEN"`.
|
||||||
|
6. **Open image-update PR** — run, with `--dry-run` appended only when the
|
||||||
|
dispatch `dry_run` input is true:
|
||||||
|
```
|
||||||
|
uh-cli -v gitops deployment update \
|
||||||
|
--deployment-name fuj-management \
|
||||||
|
--deployment-namespace fuj \
|
||||||
|
--set-image "<resolved image>" \
|
||||||
|
--git-repo "https://fuj-gitops-bot:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
|
||||||
|
--git-path gitops/home-kubernetes
|
||||||
|
```
|
||||||
|
|
||||||
|
`GITEA_TOKEN` is sourced from `secrets.GITOPS_TOKEN` at job level.
|
||||||
|
|
||||||
|
Job-level guard: `if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}`.
|
||||||
|
|
||||||
|
## Prerequisites (user must set up in Gitea — call out in handoff)
|
||||||
|
|
||||||
|
1. **Create `GITOPS_TOKEN` secret** in the `fuj-management` repo: a Gitea token
|
||||||
|
for a user (`fuj-gitops-bot` or `kacerr`) that has **write + pull-request**
|
||||||
|
access to `kacerr/home-kubernetes`. The username in the `--git-repo` URL must
|
||||||
|
match that token's owner (adjust `fuj-gitops-bot` if using `kacerr`).
|
||||||
|
2. **uh-cli `v0.1.0` release assets must exist** (the `uh-cli-v0.1.0-linux-amd64`
|
||||||
|
binary attached to the release). If not yet published, cut that release in the
|
||||||
|
uh-cli repo first, or set `UH_CLI_VERSION` to a published tag.
|
||||||
|
3. **Confirm the manifest path**: `--git-path gitops/home-kubernetes` must contain
|
||||||
|
the `fuj-management` Deployment; `--deployment-namespace fuj` disambiguates.
|
||||||
|
Cannot verify from this repo — verify against home-kubernetes (narrow the path
|
||||||
|
if uh-cli reports an ambiguity error).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **New**: `.gitea/workflows/gitops-update.yaml` (the workflow above).
|
||||||
|
- After it works: prepend a `CHANGELOG.md` entry; save this plan to
|
||||||
|
`docs/plans/<ts>-gitops-pr-action.md` per CLAUDE.md convention.
|
||||||
|
|
||||||
|
## Branching
|
||||||
|
|
||||||
|
Feature work → branch `feat/gitops-pr-action` off `main`, commit with the
|
||||||
|
`Co-Authored-By` trailer, push with `-u`, open the MR with
|
||||||
|
`tea pr create --base main --head feat/gitops-pr-action`. Do not merge from CLI.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **Dry run (manual)**: trigger `gitops-update.yaml` via workflow_dispatch with
|
||||||
|
`tag=0.37`, `dry_run=true`. Confirm logs show the unified diff (image line
|
||||||
|
`…:0.37-go`) and `-v` debug milestones; **no PR** is created.
|
||||||
|
2. **Real run (manual)**: re-trigger with `dry_run=false`. Confirm a PR appears in
|
||||||
|
`home-kubernetes` against `main` with the image bump, and the PR URL is printed.
|
||||||
|
3. **Auto-trigger**: push a new git tag to fuj-management → `Build and Push`
|
||||||
|
completes → `gitops-update` fires via `workflow_run` and opens the PR.
|
||||||
|
(Note: `workflow_run`/`head_branch` behavior depends on this Gitea/act_runner
|
||||||
|
version; if it doesn't fire, manual `workflow_dispatch` is the fallback and the
|
||||||
|
plan still delivers the core capability.)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,24 +5,40 @@ import "testing"
|
|||||||
func TestCalculateFee(t *testing.T) {
|
func TestCalculateFee(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// mustRate returns the configured rate for a month that must be in the map.
|
||||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
|
// It panics immediately if the month is absent — so if a rate entry is ever
|
||||||
|
// removed, the test fails loudly rather than silently comparing against
|
||||||
|
// Go's zero value.
|
||||||
|
mustRate := func(month string) int {
|
||||||
|
r, ok := AdultFeeMonthlyRate[month]
|
||||||
|
if !ok {
|
||||||
|
panic("test month not in AdultFeeMonthlyRate: " + month)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
count int
|
count int
|
||||||
month string
|
month string
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", 0},
|
{"zero short-circuits", 0, "2026-05", 0},
|
||||||
{"zero empty month", 0, "", 0},
|
{"zero empty month", 0, "", 0},
|
||||||
{"single practice", 1, "2026-05", 200},
|
// Single practice returns AdultFeeSingle regardless of month.
|
||||||
{"single ignores monthKey", 1, "unknown", 200},
|
{"single practice", 1, "2026-05", AdultFeeSingle},
|
||||||
{"two practices configured month", 2, "2026-05", 700},
|
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
|
||||||
{"two practices reduced march", 2, "2026-03", 350},
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
{"two practices early season", 2, "2025-09", 750},
|
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
|
||||||
{"high count same as two", 5, "2026-05", 700},
|
// when rates are updated — the assertion verifies dispatch logic, not rate values.
|
||||||
{"unknown future month falls back", 2, "2027-01", 700},
|
{"two practices configured month", 2, "2026-05", mustRate("2026-05")},
|
||||||
{"empty month falls back", 2, "", 700},
|
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
|
||||||
|
{"two practices early season", 2, "2025-09", mustRate("2025-09")},
|
||||||
|
{"high count same as two", 5, "2026-05", mustRate("2026-05")},
|
||||||
|
// Two+ practices for an unknown/future month: must fall back to AdultFeeDefault.
|
||||||
|
{"unknown future month falls back", 2, "2027-01", AdultFeeDefault},
|
||||||
|
{"empty month falls back", 2, "", AdultFeeDefault},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -5,24 +5,37 @@ import "testing"
|
|||||||
func TestCalculateJuniorFee(t *testing.T) {
|
func TestCalculateJuniorFee(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// mustRate returns the configured rate for a month that must be in the map.
|
||||||
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
|
// Panics immediately if the month is absent so a removed entry causes a loud
|
||||||
|
// failure rather than a silent comparison against Go's zero value.
|
||||||
|
mustRate := func(month string) Expected {
|
||||||
|
r, ok := JuniorFeeMonthlyRate[month]
|
||||||
|
if !ok {
|
||||||
|
panic("test month not in JuniorFeeMonthlyRate: " + month)
|
||||||
|
}
|
||||||
|
return Expected{Value: r}
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
count int
|
count int
|
||||||
month string
|
month string
|
||||||
want Expected
|
want Expected
|
||||||
}{
|
}{
|
||||||
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
||||||
{"zero empty month", 0, "", Expected{Value: 0}},
|
{"zero empty month", 0, "", Expected{Value: 0}},
|
||||||
|
// Single practice returns the Unknown sentinel regardless of month.
|
||||||
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
||||||
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
||||||
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
|
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
|
||||||
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
|
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
|
||||||
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
|
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
|
||||||
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
|
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
|
||||||
{"empty month falls back", 2, "", Expected{Value: 500}},
|
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
|
||||||
|
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
|
||||||
|
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
const container = document.getElementById('filterContainer');
|
const container = document.getElementById('filterContainer');
|
||||||
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