Compare commits

...

12 Commits

Author SHA1 Message Date
96c14f0b22 Merge pull request 'fix(ci): resolve image tag via Gitea API instead of artifact' (#42) from fix/gitops-tag-via-api into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 8s
Build and Push / build-go (push) Successful in 40s
Reviewed-on: #42
2026-06-12 19:59:40 +02:00
6d7dbfa624 fix(ci): resolve image tag via Gitea API instead of artifact
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
upload/download-artifact@v4 is not supported on Gitea (GHES). Replace
with a direct Gitea API call in gitops-update: look up the tag name
whose commit SHA matches workflow_run.head_sha. Reverts the artifact
upload from build.yaml; no changes to build.yaml logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:59:24 +02:00
c00111cff1 Merge pull request 'fix(ci): pass Go image tag from build to gitops via artifact' (#41) from fix/gitops-pass-tag-via-artifact into main
Some checks failed
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 7s
Build and Push / build-go (push) Failing after 42s
Reviewed-on: #41
2026-06-12 19:53:19 +02:00
d263d8a534 fix(ci): pass Go image tag from build to gitops via artifact
All checks were successful
Deploy to K8s / deploy (push) Successful in 14s
github.event.workflow_run.head_branch is not populated for tag pushes
in Gitea Actions, causing the image tag to resolve to empty (-go suffix
with no version). Fix: build-go uploads the full image reference as a
one-line artifact; gitops-update downloads it via download-artifact@v4
with run-id from the workflow_run event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:52:02 +02:00
af030c8255 Merge pull request 'fix(ci): separate git credentials from --git-repo URL to fix tea pr create' (#40) from fix/gitops-tea-url into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 42s
Build and Push / build-go (push) Successful in 1m23s
Reviewed-on: #40
2026-06-12 19:39:31 +02:00
ad127d36ea fix(ci): separate git credentials from --git-repo URL to fix tea pr create
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
tea pr create matches the remote URL against the configured login URL to
auto-detect owner/repo. Embedding credentials in the URL (user:token@host)
breaks that match and produces "path segment [0] is empty". Store creds
via git credential helper instead and pass a clean URL to uh-cli.

Also adds set -x to the PR step for shell-level tracing in CI logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:37:29 +02:00
29938d7a0c chore(changelog): log gitops-update workflow addition
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:32:20 +02:00
1df1863725 Merge pull request 'feat(ci): gitops image-update PR workflow for home-kubernetes' (#39) from feat/gitops-pr-action into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #39
2026-06-12 19:31:51 +02:00
995abfacb2 feat(ci): add gitops-update workflow to open image-bump PR in home-kubernetes
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
After a successful Go image build, uh-cli opens a PR against
kacerr/home-kubernetes that bumps the fuj-management Deployment
(namespace fuj) to the newly published image tag. Supports
workflow_run auto-trigger and workflow_dispatch with dry-run option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:28:29 +02:00
f047150004 fix(tests): derive fee test expectations from constants, not hardcoded values
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Configured-month cases now read expected values from AdultFeeMonthlyRate /
JuniorFeeMonthlyRate via a mustRate helper that panics if a test month is
removed from the map. Fallback cases use AdultFeeDefault / JuniorFeeDefault.

This way the tests verify dispatch logic (0/1/2+ branching, map vs. fallback)
without breaking when rates are intentionally updated in the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:37:52 +02:00
6f2994b8ad Merge pull request 'feat(display): limit /adults and /juniors to last N months by default' (#38) from feat/months-to-show into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 14s
Build and Push / build (push) Successful in 9s
Build and Push / build-go (push) Successful in 1m10s
Reviewed-on: #38
2026-06-08 09:31:46 +00:00
c2a381bb63 fix(display): default from-selector to last N months; keep all months selectable
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Instead of hiding older months entirely, show all months in the from/to
selectors but default the from-select to the last MONTHS_TO_SHOW months
on page load. The "All" button resets to full history as before.

Python: passes months_to_show to render_template, IIFE sets fromSelect.value.
Go: adds MonthsToShow to response structs, data-months-to-show attr in
templates, filters.js reads it and defaults fromSelect after hideFutureMonths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:28:40 +02:00
18 changed files with 304 additions and 46 deletions

View File

@@ -0,0 +1,105 @@
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, jq
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates jq
- 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
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
else
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
# Look up the tag name that points to the triggering commit SHA via the API.
SHA="${{ github.event.workflow_run.head_sha }}"
GIT_TAG=$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
fi
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' || '' }}

View File

@@ -1,5 +1,11 @@
# 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-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). - 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).

18
app.py
View File

@@ -33,12 +33,6 @@ from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_
from sync_fio_to_sheets import sync_to_sheets from sync_fio_to_sheets import sync_to_sheets
from infer_payments import infer_payments 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): def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
mod_time = get_sheet_modified_time(cache_key) mod_time = get_sheet_modified_time(cache_key)
if mod_time: if mod_time:
@@ -181,7 +175,7 @@ def api_adults():
) )
result = reconcile(members, sorted_months, transactions, exceptions) result = reconcile(members, sorted_months, transactions, exceptions)
vm = build_adults_view_model( vm = build_adults_view_model(
members, _last_n_months(sorted_months), result, transactions, members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"), datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
) )
@@ -205,7 +199,7 @@ def api_juniors():
adapted_members = adapt_junior_members(junior_members) adapted_members = adapt_junior_members(junior_members)
result = reconcile(adapted_members, sorted_months, transactions, exceptions) result = reconcile(adapted_members, sorted_months, transactions, exceptions)
vm = build_juniors_view_model( vm = build_juniors_view_model(
junior_members, adapted_members, _last_n_months(sorted_months), result, transactions, junior_members, adapted_members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"), datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT, attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
) )
@@ -254,14 +248,14 @@ def adults_view():
record_step("reconcile") record_step("reconcile")
vm = build_adults_view_model( vm = build_adults_view_model(
members, _last_n_months(sorted_months), result, transactions, members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"), datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url, payments_url=payments_url,
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():
@@ -290,14 +284,14 @@ def juniors_view():
record_step("reconcile") record_step("reconcile")
vm = build_juniors_view_model( vm = build_juniors_view_model(
junior_members, adapted_members, _last_n_months(sorted_months), result, transactions, junior_members, adapted_members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"), datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url, payments_url=payments_url,
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():

View 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.)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
return AdultsResponse{}, err return AdultsResponse{}, err
} }
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
return buildAdultsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
} }
// ServeJuniors handles GET /api/juniors. // ServeJuniors handles GET /api/juniors.
@@ -74,16 +74,7 @@ func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error)
return JuniorsResponse{}, err return JuniorsResponse{}, err
} }
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year()) result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
return buildJuniorsResponse(members, lastNMonths(sortedMonths, h.Config.MonthsToShow), result, txns, h.Config, time.Now().Format("2006-01")), nil return buildJuniorsResponse(members, sortedMonths, 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. // ServePayments handles GET /api/payments.

View File

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

View File

@@ -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();
}
}()); }());

View File

@@ -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…">

View File

@@ -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…">

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -1045,6 +1045,7 @@
}); });
toSelect.value = maxMonthIdx; toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter(); applyMonthFilter();
})(); })();
</script> </script>

View File

@@ -1026,6 +1026,7 @@
}); });
toSelect.value = maxMonthIdx; toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter(); applyMonthFilter();
})(); })();
</script> </script>