Compare commits
33 Commits
feat/go-m5
...
fix/period
| Author | SHA1 | Date | |
|---|---|---|---|
| e618e906ef | |||
| 96e574e6c7 | |||
| 6a8aa37198 | |||
| aa0c17f521 | |||
| 464eeeb2b1 | |||
| daac5d7392 | |||
| c85748b3aa | |||
| 216b5b437a | |||
| 78e5059759 | |||
| 2f635db2b4 | |||
| 709a2f2335 | |||
| 58973473c9 | |||
| b68d95d217 | |||
| 07ca1cd9e1 | |||
| 5dcac25c13 | |||
| fc47606b1c | |||
| 65694ad378 | |||
| 092dff25a5 | |||
| 56c21bcf03 | |||
| 208f762c18 | |||
| 4d035213b5 | |||
| 2b15280d03 | |||
| 723152cdad | |||
| fe0e49a134 | |||
| e5a272b682 | |||
| 8b3064ffab | |||
| 423c3e2a4b | |||
| f4c497681f | |||
| 40e4a9e45e | |||
| 68810369bd | |||
| 2b7eff14c4 | |||
| 7d48e8f607 | |||
| be4ecef20f |
84
CHANGELOG.md
84
CHANGELOG.md
@@ -1,5 +1,89 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-08 11:11 CEST — fix: period selector showed only Dec 2025+ on adults
|
||||||
|
|
||||||
|
- Restored the `2025-09 → 2025-10` adult merge in `scripts/attendance.py` and `go/internal/services/membership/sources.go` (commented-out by `1257f0d`); the `2025-12 → 2026-01` mapping stays disabled per product decision (Dec and Jan are billed separately for adults).
|
||||||
|
- Dropped the `defaultFrom = maxMonthIdx − 4` JS auto-default in `templates/adults.html` and `templates/juniors.html` (introduced by `7774301`); the From-selector now starts at the oldest available month so all non-future periods render on first load. Future-month removal is preserved.
|
||||||
|
- `go/internal/services/membership/sources_test.go`: `TestLoadAdults` / `TestLoadAdults_Fee` now assert that Sep dates land in the merged `2025-10` bucket.
|
||||||
|
- **Independent of these code changes**: the live adults attendance Google Sheet header had been pruned to start at `02.12.2025` (Sep/Oct/Nov 2025 columns deleted); restoring those columns from Sheets version history is required to actually see those periods on the dashboard.
|
||||||
|
|
||||||
|
## 2026-05-08 10:15 CEST — fix(go): adults template — use lifted CSS classes for visual parity
|
||||||
|
|
||||||
|
- Use existing `.balance-pos` / `.balance-neg` (drop invented `balance-cell` / `balance-negative`); Pay-All button now lives inside the balance cell with `position: relative` (matches Python; no separate trailing column).
|
||||||
|
- Credits / Debts / Unmatched sections rewritten from `<ul>` / `<table>` to `<div class="list-container"><div class="list-item">…` / `<div class="unmatched-row">…` so the lifted CSS actually applies.
|
||||||
|
- Section headings get the descriptive Python text: "Credits (Advance Payments / Surplus)", "Debts (Missing Payments)".
|
||||||
|
- Source links moved from page bottom to a `<div class="description">` block under the h1, matching Python's "Source: Attendance Sheet | Payments Ledger".
|
||||||
|
- Total row uses `cell-{{Status}}` plus the small "received / expected" caption span and Python's inline-styled bold/dark background.
|
||||||
|
- Drop the redundant `class="cell"` wrapper; debts amount turns red via inline style; balance value drops the trailing "CZK".
|
||||||
|
|
||||||
|
## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons)
|
||||||
|
|
||||||
|
- `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler.
|
||||||
|
- `go/internal/web/render.go`: added `AdultsPageData` view model (`PageData` + `api.AdultsResponse` + `Error`); `tmplFuncs` with `qrHref` / `qrHrefAll` (URL-encode QR Platba params, convert YYYY-MM → MM/YYYY).
|
||||||
|
- `go/internal/web/html_handler.go`: `HTMLHandler` gains `*api.Handler`; `ServeAdults` loads real reconcile data and renders the full adults page.
|
||||||
|
- `go/internal/web/templates/adults.tmpl`: full table (per-member rows, per-cell status classes, `data-month-idx`, Pay button hrefs to `/qr`), totals row, credits/debts/unmatched sections, filter controls, sheet links.
|
||||||
|
- `go/internal/web/static/js/filters.js`: name filter (NFD-normalize) + month-range hide/show by `data-month-idx`; future months hidden by default.
|
||||||
|
|
||||||
|
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
|
||||||
|
|
||||||
|
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
|
||||||
|
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
|
||||||
|
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
|
||||||
|
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
|
||||||
|
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
|
||||||
|
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
|
||||||
|
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
|
||||||
|
|
||||||
|
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
|
||||||
|
|
||||||
|
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
|
||||||
|
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
|
||||||
|
|
||||||
|
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
|
||||||
|
|
||||||
|
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
|
||||||
|
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
|
||||||
|
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
|
||||||
|
|
||||||
|
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
||||||
|
|
||||||
|
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
||||||
|
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
|
||||||
|
|
||||||
|
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
|
||||||
|
|
||||||
|
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
|
||||||
|
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
|
||||||
|
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
|
||||||
|
|
||||||
|
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
|
||||||
|
|
||||||
|
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
|
||||||
|
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
|
||||||
|
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
|
||||||
|
|
||||||
|
## 2026-05-07 22:55 CEST — feat(go): M5.4 — parity diff binary + `make parity`
|
||||||
|
|
||||||
|
- `go/cmd/parity/main.go`: new standalone binary that GETs `/api/adults`, `/api/juniors`, `/api/payments` from both Python (:5001) and Go (:8080) backends, scrubs an allowlist (`render_time.total`), and prints `cmp.Diff` for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. `/api/version` is excluded by design (returns binary identity — tag/commit/build_date — which differs between independently built backends); still accessible via `make parity ARGS="-route /api/version"`.
|
||||||
|
- `go/cmd/parity/scrub_test.go`: 4 unit tests covering top-level delete, nested delete, missing path, and non-map parent.
|
||||||
|
- `go/go.mod`: `github.com/google/go-cmp` promoted to direct dependency.
|
||||||
|
- `Makefile`: `parity` target added (`.PHONY`, help, `cd go && go run ./cmd/parity`).
|
||||||
|
- `docs/plans/2026-05-07-2254-m5-4-parity-binary.md`: plan archived.
|
||||||
|
|
||||||
|
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
|
||||||
|
|
||||||
|
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` to match Go wire contract.
|
||||||
|
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
|
||||||
|
|
||||||
|
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
|
||||||
|
|
||||||
|
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
|
||||||
|
- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip.
|
||||||
|
- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`.
|
||||||
|
- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns.
|
||||||
|
- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes.
|
||||||
|
- PR #17.
|
||||||
|
|
||||||
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
|
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
|
||||||
|
|
||||||
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
|
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures image run sync sync-2026 test test-v docs
|
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -29,6 +29,7 @@ help:
|
|||||||
@echo " make go-lint - Run golangci-lint on Go code"
|
@echo " make go-lint - Run golangci-lint on Go code"
|
||||||
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
|
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
|
||||||
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
||||||
|
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||||
@echo " make image - Build Python OCI container image"
|
@echo " make image - Build Python OCI container image"
|
||||||
@echo " make run - Run the built Python Docker image locally"
|
@echo " make run - Run the built Python Docker image locally"
|
||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@@ -102,6 +103,9 @@ go-lint:
|
|||||||
web-go: go-build
|
web-go: go-build
|
||||||
./$(GO_BIN) server
|
./$(GO_BIN) server
|
||||||
|
|
||||||
|
parity:
|
||||||
|
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build -t fuj-management:latest \
|
docker build -t fuj-management:latest \
|
||||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
|
|||||||
81
app.py
81
app.py
@@ -7,7 +7,7 @@ import os
|
|||||||
import io
|
import io
|
||||||
import qrcode
|
import qrcode
|
||||||
import logging
|
import logging
|
||||||
from flask import Flask, render_template, g, send_file, request
|
from flask import Flask, render_template, g, send_file, request, jsonify
|
||||||
|
|
||||||
# Configure logging, allowing override via LOG_LEVEL environment variable
|
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -68,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
|
|||||||
"tag": "dev", "commit": "local", "build_date": ""
|
"tag": "dev", "commit": "local", "build_date": ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||||
|
"""Expand pre-stringified JSON fields and rename to match Go API contract."""
|
||||||
|
out = dict(vm)
|
||||||
|
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||||
|
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||||
|
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
warmup_cache()
|
warmup_cache()
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -144,6 +154,75 @@ def sync_bank():
|
|||||||
def version():
|
def version():
|
||||||
return BUILD_META
|
return BUILD_META
|
||||||
|
|
||||||
|
@app.route("/api/version")
|
||||||
|
def api_version():
|
||||||
|
return jsonify(BUILD_META)
|
||||||
|
|
||||||
|
@app.route("/api/adults")
|
||||||
|
def api_adults():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
|
if not members_data:
|
||||||
|
return jsonify({"error": "no data"}), 503
|
||||||
|
members, sorted_months = members_data
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
vm = build_adults_view_model(
|
||||||
|
members, sorted_months, result, transactions,
|
||||||
|
datetime.now().strftime("%Y-%m"),
|
||||||
|
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||||
|
)
|
||||||
|
return jsonify(_unwrap_view_model_for_api(vm))
|
||||||
|
|
||||||
|
@app.route("/api/juniors")
|
||||||
|
def api_juniors():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
|
if not junior_members_data:
|
||||||
|
return jsonify({"error": "no data"}), 503
|
||||||
|
junior_members, sorted_months = junior_members_data
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
adapted_members = adapt_junior_members(junior_members)
|
||||||
|
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||||
|
vm = build_juniors_view_model(
|
||||||
|
junior_members, adapted_members, sorted_months, result, transactions,
|
||||||
|
datetime.now().strftime("%Y-%m"),
|
||||||
|
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||||
|
)
|
||||||
|
return jsonify(_unwrap_view_model_for_api(vm))
|
||||||
|
|
||||||
|
@app.route("/api/payments")
|
||||||
|
def api_payments():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||||
|
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
|
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
|
member_names = []
|
||||||
|
if adults_data:
|
||||||
|
member_names.extend(name for name, _, _ in adults_data[0])
|
||||||
|
if juniors_data:
|
||||||
|
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||||
|
vm = build_payments_view_model(
|
||||||
|
transactions, member_names,
|
||||||
|
attendance_url=attendance_url, payments_url=payments_url,
|
||||||
|
)
|
||||||
|
return jsonify(vm)
|
||||||
|
|
||||||
@app.route("/adults")
|
@app.route("/adults")
|
||||||
def adults_view():
|
def adults_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||||
|
|
||||||
**Current milestone:** M4 — IO layer behind interfaces ✅
|
**Current milestone:** M6 — Go-native HTML frontend
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-07
|
**Last updated:** 2026-05-08 (M6.2 merged)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -98,9 +98,9 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr
|
|||||||
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
||||||
|
|
||||||
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
||||||
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
|
||||||
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
|
||||||
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
- [x] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||||
|
|
||||||
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
||||||
|
|
||||||
@@ -110,8 +110,8 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
|
|||||||
|
|
||||||
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
|
||||||
|
|
||||||
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/`
|
- [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
|
||||||
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
- [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
|
||||||
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
|
||||||
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view
|
||||||
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
|
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
|
||||||
@@ -154,6 +154,7 @@ Goal: Go is the one true backend.
|
|||||||
|
|
||||||
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
||||||
|
|
||||||
|
- 2026-05-07 — `/api/version` excluded from parity diff by design. Each binary's tag/commit/build_date is identity, not a data contract — diffing it would always flag a diff between independently built backends. Route remains reachable via `make parity ARGS="-route /api/version"` for manual inspection.
|
||||||
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
||||||
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
|
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
|
||||||
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
||||||
|
|||||||
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# M5.3 — Python `/api/X` shadow endpoints
|
||||||
|
|
||||||
|
Companion to:
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row)
|
||||||
|
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
|
||||||
|
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
|
||||||
|
|
||||||
|
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
|
||||||
|
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
|
||||||
|
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
|
||||||
|
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
|
||||||
|
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
|
||||||
|
|
||||||
|
### The shim
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _unwrap_view_model_for_api(vm: dict) -> dict:
|
||||||
|
out = dict(vm)
|
||||||
|
out["member_data"] = _json.loads(out.pop("member_data"))
|
||||||
|
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
|
||||||
|
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
|
||||||
|
return out
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the **rename**: `month_labels_json` → `month_labels`, `raw_payments_json` → `raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
|
||||||
|
|
||||||
|
### Route skeleton (`/api/adults`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.route("/api/adults")
|
||||||
|
def api_adults():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||||
|
if not members_data:
|
||||||
|
return jsonify({"error": "no data"}), 503
|
||||||
|
members, sorted_months = members_data
|
||||||
|
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
|
||||||
|
exceptions = get_cached_data(
|
||||||
|
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||||
|
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
|
||||||
|
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||||
|
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||||
|
)
|
||||||
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
|
vm = build_adults_view_model(
|
||||||
|
members, sorted_months, result, transactions,
|
||||||
|
datetime.now().strftime("%Y-%m"),
|
||||||
|
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
||||||
|
)
|
||||||
|
return jsonify(_unwrap_view_model_for_api(vm))
|
||||||
|
```
|
||||||
|
|
||||||
|
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
|
||||||
|
|
||||||
|
## Files to modify
|
||||||
|
|
||||||
|
- [app.py](app.py)
|
||||||
|
- L10: add `jsonify` to `from flask import ...`.
|
||||||
|
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
|
||||||
|
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
|
||||||
|
- [tests/test_app.py](tests/test_app.py)
|
||||||
|
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
|
||||||
|
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
|
||||||
|
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
|
||||||
|
|
||||||
|
## Reusable existing code
|
||||||
|
|
||||||
|
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
|
||||||
|
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
|
||||||
|
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
|
||||||
|
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
|
||||||
|
2. `python -c "import app"` — catches the missing `jsonify` import.
|
||||||
|
3. `make web-py`, then:
|
||||||
|
- `curl -s localhost:5001/api/version | jq .` → `{tag, commit, build_date}`.
|
||||||
|
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
|
||||||
|
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
|
||||||
|
- Same checks on `/api/juniors` and `/api/payments`.
|
||||||
|
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
|
||||||
|
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
|
||||||
|
|
||||||
|
## Out of scope (M5.4 will surface and resolve)
|
||||||
|
|
||||||
|
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
|
||||||
|
|
||||||
|
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
|
||||||
|
- **`unmatched[]`**: same divergence (same raw row dicts).
|
||||||
|
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
|
||||||
|
- **`Decimal` / float precision** in `grouped_payments` amounts.
|
||||||
|
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
|
||||||
|
|
||||||
|
## Branch + MR
|
||||||
|
|
||||||
|
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.
|
||||||
152
docs/plans/2026-05-07-2254-m5-4-parity-binary.md
Normal file
152
docs/plans/2026-05-07-2254-m5-4-parity-binary.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# M5.4 — Parity diff binary (`cmd/parity`) + `make parity`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Per [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:103](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L103), M5.4 is the next milestone in the Go rewrite. M5.1–M5.3 already landed:
|
||||||
|
|
||||||
|
- M5.1: hand-authored Go response structs at [go/internal/web/api/](go/internal/web/api/) + JSON Schemas in [go/tests/fixtures/api-schema/](go/tests/fixtures/api-schema/).
|
||||||
|
- M5.2: Go `/api/version|adults|juniors|payments` handlers in [go/internal/web/api/handler.go](go/internal/web/api/handler.go).
|
||||||
|
- M5.3: Python shadow endpoints in [app.py:157-224](app.py#L157-L224) using `_unwrap_view_model_for_api` for the same JSON shape.
|
||||||
|
|
||||||
|
What's missing: a tool that proves the two backends actually agree on the wire. The M5 gate says "byte-equal JSON between Python and Go for every route." Without a diffing tool, drift between the two implementations slips in silently and we can't gate further milestones (M6 frontend, M7 watch period) on parity.
|
||||||
|
|
||||||
|
This task delivers the **parity contract enforcer**: a Go binary that fetches `/api/*` from both backends, scrubs an allowlist of expected diffs, and prints `cmp.Diff` for everything else. Both backends read the same live Google Sheets — that shared state is what makes parity meaningful, no scenario fixtures needed.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
- New binary `go/cmd/parity/main.go` plus a small support package for the allowlist scrubber.
|
||||||
|
- A unit test for the scrubber.
|
||||||
|
- New `make parity` target.
|
||||||
|
- `go-cmp` promoted to a direct dependency.
|
||||||
|
|
||||||
|
Out of scope (explicitly):
|
||||||
|
- CI integration / nightly job — that's M7.2.
|
||||||
|
- Fixture-driven offline parity — pure-fn parity already runs via `make go-parity` (M3).
|
||||||
|
- Hooking `make parity` into `make go-test-all` — leave it manual since it requires two live servers.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. New binary: `go/cmd/parity/main.go`
|
||||||
|
|
||||||
|
Standalone binary (mirrors task spec — not a subcommand of `fuj`). Stdlib `flag`, no third-party CLI lib.
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
- `-py` — Python base URL, default `http://localhost:5001`
|
||||||
|
- `-go` — Go base URL, default `http://localhost:8080`
|
||||||
|
- `-route` — optional single route to diff (e.g. `/api/adults`); empty means iterate all four
|
||||||
|
- `-timeout` — per-request timeout, default `30s` (sheet fetches are slow on cold cache)
|
||||||
|
|
||||||
|
**Routes**, hard-coded:
|
||||||
|
```go
|
||||||
|
var routes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
|
||||||
|
```
|
||||||
|
All `GET`, no query params (verified in [app.py:157-224](app.py#L157-L224) and [go/internal/web/api/handler.go:27-68](go/internal/web/api/handler.go#L27-L68)).
|
||||||
|
|
||||||
|
**Per route, the binary:**
|
||||||
|
1. `GET py+route` and `GET go+route` (sequential — keep it simple; total wall time ~1–2s on warm cache).
|
||||||
|
2. Verify both return HTTP 200; on non-200, print body and mark route as ERROR.
|
||||||
|
3. Decode each body into `map[string]any` (NOT into `api.AdultsResponse` — using the typed struct would silently drop unknown Python-side keys, defeating the diff. Map gives `cmp.Diff` clean dotted-path field names for free.).
|
||||||
|
4. Run `scrub(m, allowlist)` on both decoded maps.
|
||||||
|
5. `diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())`.
|
||||||
|
6. If `diff != ""`, print `=== /api/X ===` header followed by the diff; track for exit code.
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
- `0` — all routes match (or the only diffs were under the allowlist).
|
||||||
|
- `1` — at least one route had a non-allowlisted diff.
|
||||||
|
- `2` — at least one route failed to fetch / parse (HTTP error, timeout, non-JSON body).
|
||||||
|
|
||||||
|
This makes the binary CI-friendly when M7.2 lands.
|
||||||
|
|
||||||
|
**Output**: human-readable to stdout — header per route, then "OK" or the diff. Final summary line: `parity: 4/4 routes match` or `parity: 2/4 match, 1 diff, 1 error`.
|
||||||
|
|
||||||
|
### 2. Allowlist scrubber
|
||||||
|
|
||||||
|
Live in same package (`main`). Keep it tiny — no need for a separate sub-package.
|
||||||
|
|
||||||
|
```go
|
||||||
|
var defaultAllowlist = []string{"render_time.total", "build_meta"}
|
||||||
|
|
||||||
|
// scrub walks m and deletes any key whose dotted path matches an allowlist entry.
|
||||||
|
// "render_time.total" deletes m["render_time"]["total"] only (preserves render_time.breakdown).
|
||||||
|
// "build_meta" (no dot) deletes m["build_meta"] entirely.
|
||||||
|
func scrub(m map[string]any, paths []string) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Today these fields **don't appear in the JSON** — they're Jinja template-only ([app.py:91-110](app.py#L91-L110)) and the Go side only logs render time via [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go). So today the scrub is a no-op. The implementation is forward-compatible insurance: if someone later adds either field to a JSON response on one side only, the parity binary already tolerates it.
|
||||||
|
|
||||||
|
The implementation note in [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go) ("the elapsed value maps to render_time.total in the M5 JSON allowlist") confirms this is the intended design.
|
||||||
|
|
||||||
|
### 3. Unit test
|
||||||
|
|
||||||
|
Add `go/cmd/parity/scrub_test.go` with a couple of cases:
|
||||||
|
- Scrubbing top-level key (`build_meta`) — key removed; siblings untouched.
|
||||||
|
- Scrubbing nested key (`render_time.total`) — only `total` removed; `breakdown` preserved.
|
||||||
|
- Path that doesn't exist — no-op, no error.
|
||||||
|
- Map without the parent key — no-op (don't panic).
|
||||||
|
|
||||||
|
Runs as part of normal `make go-test` (no `-tags` needed).
|
||||||
|
|
||||||
|
### 4. Makefile target
|
||||||
|
|
||||||
|
Add to [Makefile](Makefile):
|
||||||
|
|
||||||
|
```make
|
||||||
|
parity:
|
||||||
|
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `parity` to the `.PHONY` line at [Makefile:1](Makefile#L1) and a `help` entry like:
|
||||||
|
|
||||||
|
```
|
||||||
|
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't depend on `go-build` — `go run` compiles ad-hoc, and parity is interactive enough that the slight rebuild cost doesn't matter.
|
||||||
|
|
||||||
|
### 5. Dependency
|
||||||
|
|
||||||
|
`github.com/google/go-cmp v0.7.0` is already a transitive dep ([go/go.sum:24-25](go/go.sum#L24-L25)) but not in `go.mod`'s `require` block. After adding the import, run `go mod tidy` inside `go/` to promote it to direct. No new external deps.
|
||||||
|
|
||||||
|
## Files to add / modify
|
||||||
|
|
||||||
|
**Add:**
|
||||||
|
- [go/cmd/parity/main.go](go/cmd/parity/main.go) — flags, route loop, fetch+scrub+diff, exit codes
|
||||||
|
- [go/cmd/parity/scrub_test.go](go/cmd/parity/scrub_test.go) — unit tests for the scrubber
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- [Makefile](Makefile) — `.PHONY`, help block, `parity` target
|
||||||
|
- [go/go.mod](go/go.mod) — promote `go-cmp` to direct (via `go mod tidy`)
|
||||||
|
- [go/go.sum](go/go.sum) — likely unchanged (already pinned)
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M5.4, add commit SHA
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — entry per project convention
|
||||||
|
|
||||||
|
**Per [CLAUDE.md](CLAUDE.md) plans convention:** copy this plan to `docs/plans/2026-05-07-HHMM-m5-4-parity-binary.md` (timestamp via `date "+%Y-%m-%d-%H%M"`) before opening the MR, since plan files are committed for posterity.
|
||||||
|
|
||||||
|
## Existing utilities to reuse
|
||||||
|
|
||||||
|
- `cmp.Diff` + `cmpopts.EquateEmpty()` from `github.com/google/go-cmp/cmp` (already in go.sum).
|
||||||
|
- Stdlib `net/http`, `encoding/json`, `flag` — sufficient; no need to pull a CLI framework.
|
||||||
|
- No existing scrubber to reuse — the one in [scripts/scrub_fixtures.py](scripts/scrub_fixtures.py) operates on the *capture* path and renames PII; the parity scrubber is a different concern (path-based deletion of expected diffs).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Manual smoke test (the binary is meant to be run against live servers):
|
||||||
|
|
||||||
|
1. **Sanity / build:** `cd go && go build ./cmd/parity && go test ./cmd/parity/...` — compiles + scrubber unit test passes.
|
||||||
|
2. **Both backends up:**
|
||||||
|
- Terminal A: `make web-py`
|
||||||
|
- Terminal B: `make web-go`
|
||||||
|
- Terminal C: `make parity`
|
||||||
|
Expected: `parity: 4/4 routes match`, exit 0.
|
||||||
|
3. **Negative test:** add a literal extra field to one Go handler temporarily (e.g. `"diagnostic": "test"` in `VersionResponse`), rebuild, re-run `make parity`. Expected: non-zero exit, diff shown for `/api/version`. Revert.
|
||||||
|
4. **Allowlist test:** in a unit test (or by manually constructing a payload with `render_time.total` injected), confirm the scrubber removes it before the diff stage.
|
||||||
|
5. **CHANGELOG** entry added; progress tracker ticked with the merge commit SHA.
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md) branching policy: this is a feature, so work happens on `feat/go-m5-4-parity-binary`, push with `-u`, open MR with `tea pr create --base main --head feat/go-m5-4-parity-binary`. User merges in Gitea.
|
||||||
|
|
||||||
|
## Notes & risks
|
||||||
|
|
||||||
|
- **Cache-warmth dependency:** Both backends cache aggressively. A cold-cache fetch on Python can take 30s+ — hence the configurable timeout. If parity is run immediately after `flush-cache`, expect the first run to be slow.
|
||||||
|
- **Order-sensitive lists:** `cmp.Diff` on `map[string]any` treats slices as ordered. If either backend ever returns members/transactions in a different order, the diff will flag it — that's a real parity bug, not a false positive, so this is correct behavior. If we hit ordering instability later, fix the source, don't add `cmpopts.SortSlices`.
|
||||||
|
- **Float formatting:** Both sides go through `encoding/json` (Go) and `jsonify` (Python `json.dumps`). Floats in totals/balances may format differently (`123` vs `123.0`). If this surfaces, the Go side already uses typed structs with `int` where appropriate — investigate at the source rather than allowlisting.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Plan: Go rewrite — M6.1 Template skeleton
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
|
||||||
|
|
||||||
|
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
|
||||||
|
|
||||||
|
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
|
||||||
|
|
||||||
|
## Current Go web state (what we're building on)
|
||||||
|
|
||||||
|
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
|
||||||
|
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
|
||||||
|
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
|
||||||
|
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
|
||||||
|
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
|
||||||
|
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
|
||||||
|
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
|
||||||
|
6. **Replace the existing `helloHandler`**: `GET /{$}` → `http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
|
||||||
|
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
|
||||||
|
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
```
|
||||||
|
go/internal/web/
|
||||||
|
├── assets.go NEW — //go:embed templates/* static/*
|
||||||
|
├── render.go NEW — Renderer wraps parsed *template.Template
|
||||||
|
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
|
||||||
|
├── html/
|
||||||
|
│ ├── handler.go NEW — html.Handler + page methods
|
||||||
|
│ └── handler_test.go NEW — httptest smoke per route
|
||||||
|
├── templates/
|
||||||
|
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
|
||||||
|
│ ├── partials/
|
||||||
|
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
|
||||||
|
│ │ └── footer.tmpl NEW — build meta line
|
||||||
|
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
|
||||||
|
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
|
||||||
|
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
|
||||||
|
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
|
||||||
|
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
|
||||||
|
└── static/
|
||||||
|
└── css/
|
||||||
|
└── app.css NEW — verbatim from templates/adults.html:8-487
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key design notes
|
||||||
|
|
||||||
|
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
|
||||||
|
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
|
||||||
|
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
|
||||||
|
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
|
||||||
|
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
|
||||||
|
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
|
||||||
|
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
|
||||||
|
|
||||||
|
## Critical files to read
|
||||||
|
|
||||||
|
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
|
||||||
|
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
|
||||||
|
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
|
||||||
|
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end smoke from a fresh `make go-build`:
|
||||||
|
|
||||||
|
1. `make web-go &` — server boots; no template-parse errors in slog output.
|
||||||
|
2. `curl -i localhost:8080/` → `302` to `/adults`.
|
||||||
|
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
|
||||||
|
4. `curl -sI localhost:8080/static/css/app.css` → `200`, `Content-Type: text/css`.
|
||||||
|
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
|
||||||
|
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
|
||||||
|
7. `make go-test` — `web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
|
||||||
|
8. `make go-lint` clean.
|
||||||
|
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
|
||||||
|
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
|
||||||
|
|
||||||
|
## Branch & MR
|
||||||
|
|
||||||
|
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.
|
||||||
131
docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Normal file
131
docs/plans/2026-05-08-0052-go-rewrite-m6-2-adults-page.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
# Plan: Go rewrite — M6.2 Adults page
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M6.1 landed the template skeleton, embed.FS, and HTML routes ([progress §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113), commit `78e5059`). Every page renders chrome + a "Coming in M6.X" placeholder body. **M6.2 fills in `/adults`** — the most data-rich page and the template that drives the rest of the M6 work (juniors mostly mirrors it).
|
||||||
|
|
||||||
|
Acceptance criteria, verbatim from [progress §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114):
|
||||||
|
|
||||||
|
> `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
|
||||||
|
|
||||||
|
The Go side has done excellent prep: `buildAdultsResponse` in [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) already produces the full view model (currently consumed only by `/api/adults`), the wire types in [go/internal/web/api/types.go](../../go/internal/web/api/types.go) match the Python view-model 1:1, and the CSS is already lifted into [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css). M6.2 is therefore mostly **template authoring + handler wiring + a small filters script**, not a fresh port.
|
||||||
|
|
||||||
|
## Current Go web state (what we're building on)
|
||||||
|
|
||||||
|
- [go/internal/web/server.go:49](../../go/internal/web/server.go#L49) — `mux.HandleFunc("GET /adults", hh.ServeAdults)` already wired.
|
||||||
|
- [go/internal/web/html_handler.go:16-18](../../go/internal/web/html_handler.go#L16-L18) — placeholder handler renders a `PageData{Active, Build}` shell only. Needs to gain access to the data layer.
|
||||||
|
- [go/internal/web/render.go:11](../../go/internal/web/render.go#L11) — `PageData` struct is currently `{Active, Build}` only. Needs to extend or be wrapped by a typed adults view model.
|
||||||
|
- [go/internal/web/api/handler.go:36-45](../../go/internal/web/api/handler.go#L36-L45) — `ServeAdults` already does `loadAll` → `Reconcile` → `buildAdultsResponse`. We will extract that body into an exported method so the HTML handler can reuse it without duplication.
|
||||||
|
- [go/internal/web/api/build_adults.go:17](../../go/internal/web/api/build_adults.go#L17) — `buildAdultsResponse(...) AdultsResponse` returns everything the template needs (`Months`, `Results`, `Totals`, `Credits`, `Debts`, `Unmatched`, `BankAccount`, `CurrentMonth`, …). We pass this struct straight into the template.
|
||||||
|
- [go/internal/web/templates/adults.tmpl](../../go/internal/web/templates/adults.tmpl) — currently a 5-line placeholder. Replace contents.
|
||||||
|
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — all selectors needed (`.cell-ok`, `.cell-unpaid`, `.cell-unpaid-current`, `.cell-overridden`, `.unmatched-row`, `.filter-container`, `.pay-btn`, `.member-row`, …) already present from M6.1.
|
||||||
|
|
||||||
|
Reference for parity: [templates/adults.html](../../templates/adults.html) (Python source). Sections to mirror in markup terms:
|
||||||
|
- Reconcile table — [adults.html:534-585](../../templates/adults.html#L534-L585)
|
||||||
|
- Totals row — [adults.html:571-582](../../templates/adults.html#L571-L582)
|
||||||
|
- Credits / Debts — [adults.html:587-609](../../templates/adults.html#L587-L609)
|
||||||
|
- Unmatched — [adults.html:611-629](../../templates/adults.html#L611-L629)
|
||||||
|
- Filter controls — [adults.html:515-532](../../templates/adults.html#L515-L532)
|
||||||
|
- Filter JS (name + month range) — [adults.html:1019-1051](../../templates/adults.html#L1019-L1051)
|
||||||
|
|
||||||
|
The member-detail modal, Pay-preview modal, and JSON-hydrated `memberData`/`rawPaymentsByPerson` globals are explicitly **out of scope for M6.2** — they belong to M6.5 (modal JS).
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
1. **Share data assembly between HTML and JSON** — extract `ServeAdults`'s body into `(*api.Handler).AssembleAdults(ctx) (AdultsResponse, error)` and have `ServeAdults` call it. The HTML handler then calls the same method, keeping `/adults` and `/api/adults` byte-identical in semantics (same loaded data, same reconcile, same view model).
|
||||||
|
2. **Wire HTMLHandler to data** — extend `HTMLHandler` with an `apiHandler *api.Handler` field and pass it from `Run()`. `ServeAdults` becomes: `AssembleAdults` → on error render an error body (or 500) → render `adults.tmpl` with a typed view model wrapping `PageData` + `api.AdultsResponse`.
|
||||||
|
3. **Per-page typed view model** — add `AdultsPageData{ PageData; Data api.AdultsResponse }` in [render.go](../../go/internal/web/render.go) (or a new `view.go`). Template references `.Active`, `.Build` (chrome) and `.Data.Results`, `.Data.Totals`, `.Data.Months`, etc. Keeps the chrome contract for nav/footer untouched.
|
||||||
|
4. **Author `adults.tmpl`** — port markup from [templates/adults.html:489-629](../../templates/adults.html#L489-L629). Notable mechanics:
|
||||||
|
- Filter controls block with `<input id="nameFilter">`, `<select id="fromMonth">`, `<select id="toMonth">`, `Apply` / `All` buttons. Month dropdown options rendered server-side from `.Data.Months` (no client-side hydration needed for filters).
|
||||||
|
- Reconcile table iterates `.Data.Results`; each row's months iterate `row.Months`. Cell `<td>` gets class `cell-{{.Status}}`, plus `cell-unpaid-current` when `.RawMonth >= .Data.CurrentMonth`, plus `cell-overridden` when `.Overridden`. Cells carry `data-month-idx="{{$i}}"` so the filter script can hide columns.
|
||||||
|
- Per-cell Pay button visible on hover when `(unpaid|partial)` and `RawMonth < CurrentMonth` — `<a class="pay-btn" href="/qr?...">Pay</a>` with the QR query string built server-side via a template helper (see Key design notes).
|
||||||
|
- Per-row "Pay All" button when `.PayableAmount > 0`, same href construction using `.UnpaidPeriods` for display and `.RawUnpaidPeriods` for the QR message.
|
||||||
|
- Totals `<tr>` iterates `.Data.Totals`, classes `total-cell-{{.Status}}`.
|
||||||
|
- Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
|
||||||
|
5. **Tiny filter script** — new [go/internal/web/static/js/filters.js](../../go/internal/web/static/js/filters.js):
|
||||||
|
- `nameFilter` `input` event: NFD-normalize + lowercase + substring match against `.member-row [data-name]` (or row's first cell text); toggle `display:none` on non-matches.
|
||||||
|
- `fromMonth` / `toMonth` `change` event + `Apply` button: read selected `data-month-idx` range, toggle `month-hidden` class on `[data-month-idx]` `<th>`/`<td>` outside the range.
|
||||||
|
- `All` button: clear filters, restore all rows/cells.
|
||||||
|
- Match Python's behaviour byte-for-byte from [adults.html:864-1051](../../templates/adults.html#L864-L1051) but trimmed of modal/`memberData` calls.
|
||||||
|
- Loaded via `<script src="/static/js/filters.js" defer></script>` from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include).
|
||||||
|
6. **`<a href="/qr?…">` Pay buttons now, modal in M6.5** — the M6.6 milestone adds `/qr`. Until then, hrefs return 404. M6.5 will layer modal preview behaviour on top by wrapping clicks; the markup stays the same. This is the simplest staged rollout.
|
||||||
|
7. **Template helper for QR href** — add a `funcMap` to `Renderer` with `qrHref(account, amount, name, month string) string` (and a periods-list variant) that builds `/qr?account=…&amount=…&message=…` with proper URL encoding. Implementation: `net/url.Values{}.Encode()`. This keeps URL-construction logic out of the template syntax (`html/template` URL escaping isn't enough — query-param building deserves a Go helper).
|
||||||
|
8. **Active-link key** — `Active: "adults"` already set; nav highlighting works as-is (verified in M6.1 smoke test).
|
||||||
|
|
||||||
|
## Files to create / modify
|
||||||
|
|
||||||
|
```
|
||||||
|
go/internal/web/
|
||||||
|
├── api/
|
||||||
|
│ └── handler.go MODIFY — extract ServeAdults body to AssembleAdults(ctx)
|
||||||
|
├── html_handler.go MODIFY — hold *api.Handler; ServeAdults loads + renders
|
||||||
|
├── render.go MODIFY — add AdultsPageData type; add funcMap with qrHref()
|
||||||
|
├── server.go MODIFY — pass *api.Handler into NewHTMLHandler
|
||||||
|
├── html_handler_test.go MODIFY — add adults markup-level assertions w/ stub Sources
|
||||||
|
├── templates/
|
||||||
|
│ └── adults.tmpl MODIFY — replace placeholder w/ full table + filters + sections
|
||||||
|
└── static/
|
||||||
|
└── js/
|
||||||
|
└── filters.js NEW — name + month-range client-side filtering
|
||||||
|
```
|
||||||
|
|
||||||
|
No new packages; no domain or wire-type changes.
|
||||||
|
|
||||||
|
## Key design notes
|
||||||
|
|
||||||
|
- **Reuse `buildAdultsResponse` verbatim** — no parallel "view model" needed. `AdultsResponse` already has every field the template wants. `/adults` and `/api/adults` consume the same struct.
|
||||||
|
- **Extracting `AssembleAdults`** preserves the parity contract: anything that changes the JSON also changes the HTML, by construction. (It also sets up the same pattern for M6.3 juniors and M6.4 payments.)
|
||||||
|
- **Filter UX is DOM-driven**, no JSON hydration. `member_data` / `raw_payments` JSON payloads stay deferred to M6.5 (modal needs them; filters do not).
|
||||||
|
- **`current_month` boundary is server-side** — `AdultsResponse.CurrentMonth` (set from `time.Now().Format("2006-01")` in `loadAll`) is what the template compares `RawMonth` against for `cell-unpaid-current` styling and Pay-button visibility. Same value Python passes through `vm["current_month"]`.
|
||||||
|
- **`html/template` autoescaping is sufficient** for member names, sender, message, etc. — but Pay-button URLs need explicit `url.Values` encoding (Czech names have diacritics and spaces). Hence the `qrHref` funcMap helper.
|
||||||
|
- **Error rendering**: if `AssembleAdults` fails, render the base shell with an error banner inside `content` rather than `http.Error`. Keeps nav visible so the user can navigate away. Match Python's `"No data."` fallback at [app.py:233](../../app.py#L233) for empty results.
|
||||||
|
- **No JS framework, no bundler** — filter script is plain ES2020, ~80 lines, inline-readable. Matches M6.1's "no JS in M6.1" follow-through (we add JS in M6.2, but kept minimal).
|
||||||
|
- **NFD-normalize** in JS via `s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()` to match Python's `unicodedata.normalize('NFD', ...)`. Python ref: [adults.html:868-873](../../templates/adults.html#L868-L873).
|
||||||
|
- **Filter persistence is out of scope** — Python's filters are session-only (no localStorage). Same here.
|
||||||
|
|
||||||
|
## Critical files to read
|
||||||
|
|
||||||
|
- [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) — full view model already built; the template just iterates over `AdultsResponse`.
|
||||||
|
- [go/internal/web/api/types.go:91-122](../../go/internal/web/api/types.go#L91-L122) — `MonthCell`, `TotalCell`, `MemberRow`, `Credit` shapes.
|
||||||
|
- [go/internal/web/api/handler.go:36-96](../../go/internal/web/api/handler.go#L36-L96) — pattern for `loadAll` + `Reconcile` + `buildAdultsResponse`; the bit to refactor into `AssembleAdults`.
|
||||||
|
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — class names available; no edits needed.
|
||||||
|
- [templates/adults.html:489-629](../../templates/adults.html#L489-L629) — markup reference for filter controls, table, totals, credits/debts, unmatched.
|
||||||
|
- [templates/adults.html:864-1051](../../templates/adults.html#L864-L1051) — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All).
|
||||||
|
- [tests/test_app.py:50-75](../../tests/test_app.py#L50-L75) — `test_adults_route` in Python, mirror its assertions in Go: status 200, body contains member name + `750/750 CZK (4)` + `Adults Dashboard`, does not contain `OK`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end smoke after `make go-build`:
|
||||||
|
|
||||||
|
1. `make web-go &` — server boots, no template-parse errors.
|
||||||
|
2. `curl -i localhost:8080/adults` → `200`, `Content-Type: text/html`.
|
||||||
|
3. Browser at `http://localhost:8080/adults` against real data:
|
||||||
|
- Table renders with one row per adult member, one column per month, totals row at bottom.
|
||||||
|
- Cell colors match `/api/adults` JSON — pick a `Credits` member from JSON, confirm green-ish status; pick a `Debts` member, confirm red-ish.
|
||||||
|
- Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
|
||||||
|
- Per-cell `Pay` buttons appear on hover for past-month unpaid/partial cells; href contains `/qr?account=...&amount=...&message=`.
|
||||||
|
- Per-row "Pay All" button shows for members with `payable_amount > 0`.
|
||||||
|
4. Filters:
|
||||||
|
- Type a partial member name in `#nameFilter` → only matching rows visible. Test with diacritics (e.g. `nemec` matches `Němec`).
|
||||||
|
- Pick `fromMonth=2026-02`, `toMonth=2026-04`, click `Apply` → only those columns visible (table + totals row).
|
||||||
|
- Click `All` → everything restored.
|
||||||
|
5. `curl -s localhost:8080/adults | grep -F 'Adults Dashboard'` matches; `grep -F 'Coming in M6.2'` does **not** match.
|
||||||
|
6. `make go-test` — `html_handler_test.go` adults assertions pass with stub Sources fixture (replicating `test_adults_route` from Python).
|
||||||
|
7. `make go-lint` clean.
|
||||||
|
8. `make parity` still green — `/api/adults` JSON unchanged because `AssembleAdults` extraction is a pure refactor.
|
||||||
|
9. CHANGELOG entry per CLAUDE.md (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
|
||||||
|
10. Tick M6.2 in [progress tracker §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114) with the merge commit SHA.
|
||||||
|
|
||||||
|
## Branch & MR
|
||||||
|
|
||||||
|
Branch `feat/go-m6-2-adults-page` per CLAUDE.md branch-per-feature workflow. Commit, push with `-u`, then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tea pr create \
|
||||||
|
--title "feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)" \
|
||||||
|
--description "..." \
|
||||||
|
--base main \
|
||||||
|
--head feat/go-m6-2-adults-page
|
||||||
|
```
|
||||||
|
|
||||||
|
Print the PR URL for the user. User merges in Gitea browser.
|
||||||
142
docs/plans/2026-05-08-1052-period-selector-missing-old-months.md
Normal file
142
docs/plans/2026-05-08-1052-period-selector-missing-old-months.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Period selector missing older months — diagnosis
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
User reports the "From / To" period selector on the **adults** dashboard now
|
||||||
|
shows **Dec 2025** as the oldest available period. The older production
|
||||||
|
deployment shows Sep+Oct 2025, Nov 2025, Dec 2025+Jan 2026 (merged labels) —
|
||||||
|
i.e. data going back to September 2025. The user wants to know what went
|
||||||
|
sideways. Confirmed: the dropdown options on both Python and Go genuinely
|
||||||
|
start at Dec 2025, not just the default selection — Sep/Oct/Nov 2025 are not
|
||||||
|
in the list at all.
|
||||||
|
|
||||||
|
## Root cause — the live adults sheet header is missing those columns
|
||||||
|
|
||||||
|
The fresh cache files at `tmp/go/attendance_regular_cache.json` (raw rows from
|
||||||
|
Google Sheets, modifiedTime `2026-05-06T22:30:02Z`, cached `2026-05-08T00:26`)
|
||||||
|
contain the actual header row for the adults tab (gid=0):
|
||||||
|
|
||||||
|
```text
|
||||||
|
['FUJ tréninky úterý 20:30-22:00', '', '', '02.12.2025', '09.12.2025',
|
||||||
|
'16.12.2025', '06.01.2026', '13.01.2026', '20.01.2026', '27.01.2026',
|
||||||
|
'03.02.2026', '10.02.2026', '17.02.2026', '24.02.2026', '03.03.2026',
|
||||||
|
'10.03.2026', '17.03.2026', '23.03.2026', '31.03.2026', '13.04.2026',
|
||||||
|
'20.04.2026', '27.04.2026', '04.05.2026', '', '', '']
|
||||||
|
```
|
||||||
|
|
||||||
|
**The first date column in the live adults sheet is `02.12.2025` (Dec 2 2025).**
|
||||||
|
There are no September, October, or November 2025 columns in the header at
|
||||||
|
all. Both backends parse this faithfully (no slicing, no cutoff anywhere) and
|
||||||
|
correctly produce `sortedMonths = ["2025-12", "2026-01", …, "2026-05"]`.
|
||||||
|
|
||||||
|
The juniors sheet (different tab, `JUNIOR_SHEET_GID`) is **fine** — its header
|
||||||
|
still contains `['', 'tier', '', '15.09.2025', '13.10.2025', '20.10.2025',
|
||||||
|
'03.11.2025', '24.11.2025', '10.11.2025', '17.11.2025', '01.12.2025', …]`. So
|
||||||
|
the juniors page still shows Sep+Oct / Nov / Dec+Jan correctly.
|
||||||
|
|
||||||
|
So this is a **data issue in the adults attendance Google Sheet**: at some
|
||||||
|
point between when production's cache was last warmed (showing Sep–Nov) and
|
||||||
|
2026-05-06, somebody (or some action) removed the columns for September,
|
||||||
|
October, and November 2025 from the adults tab header.
|
||||||
|
|
||||||
|
The code is doing exactly what it should. There is no parser regression.
|
||||||
|
|
||||||
|
## What to do
|
||||||
|
|
||||||
|
### 1. Restore the missing date columns in the adults attendance sheet
|
||||||
|
|
||||||
|
The fix lives in Google Sheets, not in the codebase. Options, in order of
|
||||||
|
preference:
|
||||||
|
|
||||||
|
- **(a) Use Sheets version history.** File → Version history → See version
|
||||||
|
history; find a version from before the columns were dropped (anything
|
||||||
|
before about Mar 2026 should still have them). Copy the Sep/Oct/Nov 2025
|
||||||
|
date column headers and the `TRUE/FALSE` cells underneath them back into
|
||||||
|
the current sheet. Only restore the 11 missing date columns; do not
|
||||||
|
full-revert (you'd lose every change since then).
|
||||||
|
- **(b) Pull from the production server's cache.** The production deployment
|
||||||
|
evidently still has the older cache, since its dashboard renders those
|
||||||
|
months. SSH there, copy `tmp/attendance_regular_cache.json`, and you can
|
||||||
|
reconstruct the per-member Sep/Oct/Nov attendance counts from the
|
||||||
|
`data[*][2]` map (keys `"2025-09"`, `"2025-10"`, `"2025-11"`). Re-enter
|
||||||
|
those into the sheet manually as date columns + `TRUE` cells — tedious but
|
||||||
|
deterministic.
|
||||||
|
- **(c) Accept the loss.** If the older columns aren't recoverable, the
|
||||||
|
dashboard correctly reflects what the sheet contains; nothing more to do.
|
||||||
|
|
||||||
|
Which to pick depends on whether those months still need to be billed /
|
||||||
|
reconciled.
|
||||||
|
|
||||||
|
### 2. Restore `ADULT_MERGED_MONTHS` (user confirmed this was unintentional)
|
||||||
|
|
||||||
|
Independent of the sheet issue: commit `1257f0d` (Mar 9 2026) commented out
|
||||||
|
the adult merge mappings. Once the Sep/Oct/Nov columns are back in the sheet,
|
||||||
|
the dashboard would still show them as separate periods instead of the
|
||||||
|
production-style "Sep+Oct 2025" and "Dec 2025+Jan 2026" merged labels.
|
||||||
|
|
||||||
|
User confirmed this was unintentional. Two files to update:
|
||||||
|
|
||||||
|
- [scripts/attendance.py:32-35](scripts/attendance.py#L32-L35) — uncomment
|
||||||
|
the two mappings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ADULT_MERGED_MONTHS = {
|
||||||
|
"2025-12": "2026-01", # keys are merged into values
|
||||||
|
"2025-09": "2025-10",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [go/internal/services/membership/sources.go:30](go/internal/services/membership/sources.go#L30) — mirror the same:
|
||||||
|
|
||||||
|
```go
|
||||||
|
var AdultMergedMonths = map[string]string{
|
||||||
|
"2025-12": "2026-01",
|
||||||
|
"2025-09": "2025-10",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After this change, hit `POST /flush-cache` on each backend so the in-process
|
||||||
|
post-processed adults cache is rebuilt with the new mapping.
|
||||||
|
|
||||||
|
### 3. (Optional, separate) Fix the JS auto-default that hides older months
|
||||||
|
|
||||||
|
This is **not** the cause of the user's current symptom (that's the sheet
|
||||||
|
issue), but it will become a UX issue once Sep/Oct/Nov columns are restored:
|
||||||
|
the Python frontend's `defaultFrom = Math.max(0, maxMonthIdx - 4)` will still
|
||||||
|
default the From-selector to ~5 months before the latest column on every page
|
||||||
|
load, hiding restored older months until the user manually picks them.
|
||||||
|
|
||||||
|
- [templates/adults.html:1047](templates/adults.html#L1047) — `var defaultFrom = Math.max(0, maxMonthIdx - 4);`
|
||||||
|
- [templates/juniors.html:1028](templates/juniors.html#L1028) — same line.
|
||||||
|
|
||||||
|
Drop those four lines (`defaultFrom`, `fromSelect.value = defaultFrom`,
|
||||||
|
`toSelect.value = maxMonthIdx`, `applyMonthFilter()`) so the page loads with
|
||||||
|
all non-future months visible — matching the Go side, which only calls
|
||||||
|
`hideFutureMonths()` and leaves From at its first option.
|
||||||
|
|
||||||
|
Recommend bundling this with step 2 since they touch related UI.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. **After step 1** — `POST /flush-cache` on Python and Go backends; reload
|
||||||
|
`/adults` on each. Confirm the dropdown now lists Sep/Oct/Nov 2025.
|
||||||
|
2. **After step 2** — reload `/adults`. Confirm the dropdown shows
|
||||||
|
"Sep+Oct 2025" as a single period and "Dec 2025+Jan 2026" as a single
|
||||||
|
period. (Still requires the sheet columns to exist.)
|
||||||
|
3. **After step 3** — reload `/adults` and `/juniors` on Python. Confirm the
|
||||||
|
table renders all non-future months on first load (Sep 2025 through the
|
||||||
|
current month) instead of starting at Dec 2025.
|
||||||
|
4. **Parity check** — `make parity` should report zero diffs between Python
|
||||||
|
and Go on `/api/adults` and `/api/juniors`.
|
||||||
|
|
||||||
|
## Critical files referenced
|
||||||
|
|
||||||
|
- `tmp/go/attendance_regular_cache.json` — current adults sheet rows
|
||||||
|
(evidence: header starts at `02.12.2025`).
|
||||||
|
- `tmp/go/attendance_juniors_cache.json` — current juniors sheet rows
|
||||||
|
(header still has `15.09.2025`).
|
||||||
|
- [scripts/attendance.py](scripts/attendance.py) — `ADULT_MERGED_MONTHS`
|
||||||
|
empty after `1257f0d`; `parse_dates` / `group_by_month` faithful.
|
||||||
|
- [go/internal/services/membership/sources.go](go/internal/services/membership/sources.go) — Go counterpart, same shape.
|
||||||
|
- [templates/adults.html](templates/adults.html), [templates/juniors.html](templates/juniors.html) — JS onload `defaultFrom = -4` issue (step 3).
|
||||||
|
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — Go filter UI (already correct, no changes).
|
||||||
@@ -73,10 +73,18 @@ func serverCmd(args []string) {
|
|||||||
cfg.ServerAddr = *addr
|
cfg.ServerAddr = *addr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
logger := logging.New(cfg.LogLevel)
|
logger := logging.New(cfg.LogLevel)
|
||||||
|
|
||||||
|
sources, err := membership.NewSources(ctx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||||
|
|
||||||
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
|
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
133
go/cmd/parity/main.go
Normal file
133
go/cmd/parity/main.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// /api/version is intentionally excluded — it returns each binary's own build
|
||||||
|
// identity (tag/commit/build_date), which differs by design between independently
|
||||||
|
// built backends. Pass -route /api/version to inspect it manually.
|
||||||
|
var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
||||||
|
|
||||||
|
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||||
|
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
||||||
|
// template context only (app.py inject_render_time) and is logged via
|
||||||
|
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
||||||
|
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||||
|
var defaultAllowlist = []string{"render_time.total"}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
|
||||||
|
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
|
||||||
|
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
|
||||||
|
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: *timeout}
|
||||||
|
|
||||||
|
targets := allRoutes
|
||||||
|
if *route != "" {
|
||||||
|
targets = []string{*route}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, diffs, errs := 0, 0, 0
|
||||||
|
|
||||||
|
for _, r := range targets {
|
||||||
|
pyMap, err1 := fetch(client, *pyURL+r)
|
||||||
|
goMap, err2 := fetch(client, *goURL+r)
|
||||||
|
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
fmt.Printf("=== %s ===\n", r)
|
||||||
|
if err1 != nil {
|
||||||
|
fmt.Printf("ERROR (py): %v\n", err1)
|
||||||
|
}
|
||||||
|
if err2 != nil {
|
||||||
|
fmt.Printf("ERROR (go): %v\n", err2)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
errs++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scrub(pyMap, defaultAllowlist)
|
||||||
|
scrub(goMap, defaultAllowlist)
|
||||||
|
|
||||||
|
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
|
||||||
|
if diff == "" {
|
||||||
|
fmt.Printf("=== %s ===\nOK\n\n", r)
|
||||||
|
matched++
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", r, diff)
|
||||||
|
diffs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(targets)
|
||||||
|
fmt.Printf("parity: %d/%d routes match", matched, total)
|
||||||
|
if diffs > 0 {
|
||||||
|
fmt.Printf(", %d diff", diffs)
|
||||||
|
if diffs > 1 {
|
||||||
|
fmt.Print("s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errs > 0 {
|
||||||
|
fmt.Printf(", %d error", errs)
|
||||||
|
if errs > 1 {
|
||||||
|
fmt.Print("s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if errs > 0 {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if diffs > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(client *http.Client, url string) (map[string]any, error) {
|
||||||
|
resp, err := client.Get(url) //nolint:noctx
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode JSON: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrub removes keys from m whose dotted paths appear in paths.
|
||||||
|
// A bare segment (no dot) deletes a top-level key.
|
||||||
|
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
|
||||||
|
func scrub(m map[string]any, paths []string) {
|
||||||
|
for _, path := range paths {
|
||||||
|
parts := strings.SplitN(path, ".", 2)
|
||||||
|
if len(parts) == 1 {
|
||||||
|
delete(m, parts[0])
|
||||||
|
} else {
|
||||||
|
if child, ok := m[parts[0]].(map[string]any); ok {
|
||||||
|
delete(child, parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
go/cmd/parity/scrub_test.go
Normal file
57
go/cmd/parity/scrub_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestScrubTopLevel(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"build_meta": map[string]any{"tag": "v1"},
|
||||||
|
"other": "keep",
|
||||||
|
}
|
||||||
|
scrub(m, []string{"build_meta"})
|
||||||
|
if _, ok := m["build_meta"]; ok {
|
||||||
|
t.Error("expected build_meta to be removed")
|
||||||
|
}
|
||||||
|
if m["other"] != "keep" {
|
||||||
|
t.Error("expected other to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubNested(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"render_time": map[string]any{
|
||||||
|
"total": "0.123",
|
||||||
|
"breakdown": "fetch:0.1s",
|
||||||
|
},
|
||||||
|
"other": "keep",
|
||||||
|
}
|
||||||
|
scrub(m, []string{"render_time.total"})
|
||||||
|
rt, ok := m["render_time"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("render_time should still be present")
|
||||||
|
}
|
||||||
|
if _, ok := rt["total"]; ok {
|
||||||
|
t.Error("expected render_time.total to be removed")
|
||||||
|
}
|
||||||
|
if rt["breakdown"] != "fetch:0.1s" {
|
||||||
|
t.Error("expected render_time.breakdown to be preserved")
|
||||||
|
}
|
||||||
|
if m["other"] != "keep" {
|
||||||
|
t.Error("expected other to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubMissingPath(t *testing.T) {
|
||||||
|
m := map[string]any{"foo": "bar"}
|
||||||
|
scrub(m, []string{"nonexistent", "render_time.total"})
|
||||||
|
if m["foo"] != "bar" {
|
||||||
|
t.Error("expected foo to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubNestedParentNotMap(t *testing.T) {
|
||||||
|
m := map[string]any{"render_time": "not-a-map"}
|
||||||
|
scrub(m, []string{"render_time.total"})
|
||||||
|
if m["render_time"] != "not-a-map" {
|
||||||
|
t.Error("expected render_time to be unchanged when it is not a map")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ module fuj-management/go
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/invopop/jsonschema v0.14.0
|
github.com/invopop/jsonschema v0.14.0
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func Load() Config {
|
|||||||
return Config{
|
return Config{
|
||||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||||
CacheDir: env("CACHE_DIR", "tmp"),
|
CacheDir: env("CACHE_DIR", "tmp/go"),
|
||||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ type Exception struct {
|
|||||||
Note string
|
Note string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FeeData holds the expected fee and attendance count for one member in one month.
|
// FeeData holds the expected fee and attendance data for one member in one month.
|
||||||
type FeeData struct {
|
type FeeData struct {
|
||||||
Expected int
|
Expected int
|
||||||
Attendance int
|
IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
|
||||||
|
Attendance int
|
||||||
|
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
|
||||||
|
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
|
||||||
}
|
}
|
||||||
|
|
||||||
// Member is one row from the attendance sheet.
|
// Member is one row from the attendance sheet.
|
||||||
@@ -39,11 +42,15 @@ type Member struct {
|
|||||||
type Transaction struct {
|
type Transaction struct {
|
||||||
Date string
|
Date string
|
||||||
Amount float64
|
Amount float64
|
||||||
|
ManualFix string // "manual fix" column; non-empty disables re-inference
|
||||||
Person string // comma-separated canonical names (empty → use inference)
|
Person string // comma-separated canonical names (empty → use inference)
|
||||||
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
||||||
InferredAmount *float64 // nil → fall back to Amount
|
InferredAmount *float64 // nil → fall back to Amount
|
||||||
Sender string
|
Sender string
|
||||||
|
VS string // Variabilní symbol (Czech variable payment symbol)
|
||||||
Message string
|
Message string
|
||||||
|
BankID string
|
||||||
|
SyncID string
|
||||||
UserID string
|
UserID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +76,11 @@ type OtherEntry struct {
|
|||||||
// MonthData is the ledger state for one member in one month.
|
// MonthData is the ledger state for one member in one month.
|
||||||
type MonthData struct {
|
type MonthData struct {
|
||||||
Expected int
|
Expected int
|
||||||
|
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
|
||||||
OriginalExpected int
|
OriginalExpected int
|
||||||
AttendanceCount int
|
AttendanceCount int
|
||||||
|
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
|
||||||
|
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
|
||||||
Exception *Exception
|
Exception *Exception
|
||||||
Paid float64
|
Paid float64
|
||||||
Transactions []TxEntry
|
Transactions []TxEntry
|
||||||
@@ -173,8 +183,11 @@ func Reconcile(
|
|||||||
|
|
||||||
ledger[name][m] = MonthData{
|
ledger[name][m] = MonthData{
|
||||||
Expected: expected,
|
Expected: expected,
|
||||||
|
IsUnknown: fd.IsUnknown,
|
||||||
OriginalExpected: originalExpected,
|
OriginalExpected: originalExpected,
|
||||||
AttendanceCount: attendanceCount,
|
AttendanceCount: attendanceCount,
|
||||||
|
JuniorAttendance: fd.JuniorAttendance,
|
||||||
|
AdultAttendance: fd.AdultAttendance,
|
||||||
Exception: exInfo,
|
Exception: exInfo,
|
||||||
Paid: 0,
|
Paid: 0,
|
||||||
Transactions: []TxEntry{},
|
Transactions: []TxEntry{},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
|
|||||||
|
|
||||||
func TestReconcileExceptionOverride(t *testing.T) {
|
func TestReconcileExceptionOverride(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||||
exceptions := map[ExceptionKey]Exception{
|
exceptions := map[ExceptionKey]Exception{
|
||||||
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
|
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileFallbackToAttendance(t *testing.T) {
|
func TestReconcileFallbackToAttendance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||||
|
|
||||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||||
|
|
||||||
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
|
|||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
Name: "Alice", Tier: "A",
|
||||||
Fees: map[string]FeeData{
|
Fees: map[string]FeeData{
|
||||||
"2026-02": {750, 3},
|
"2026-02": {Expected: 750, Attendance: 3},
|
||||||
"2026-03": {350, 3},
|
"2026-03": {Expected: 350, Attendance: 3},
|
||||||
"2026-04": {150, 2},
|
"2026-04": {Expected: 150, Attendance: 2},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||||
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
Name: "Alice", Tier: "A",
|
||||||
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
|
Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}},
|
||||||
}}
|
}}
|
||||||
sortedMonths := []string{"2026-01", "2026-02"}
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
Name: "Alice", Tier: "A",
|
||||||
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
|
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
|
||||||
}}
|
}}
|
||||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||||
amount := 1250.0
|
amount := 1250.0
|
||||||
@@ -146,7 +146,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
|
|
||||||
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
|
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
|
||||||
|
|
||||||
@@ -158,8 +158,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
|||||||
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{
|
members := []Member{
|
||||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||||
}
|
}
|
||||||
sortedMonths := []string{"2026-01", "2026-02"}
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
Name: "Alice", Tier: "A",
|
||||||
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
|
Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}},
|
||||||
}}
|
}}
|
||||||
sortedMonths := []string{"2026-01", "2026-02"}
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||||
txFn := func(person string) Transaction {
|
txFn := func(person string) Transaction {
|
||||||
return Transaction{
|
return Transaction{
|
||||||
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
||||||
@@ -232,7 +232,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||||
txs := []Transaction{{
|
txs := []Transaction{{
|
||||||
Date: "2026-04-15", Amount: 750,
|
Date: "2026-04-15", Amount: 750,
|
||||||
Person: "Někdo Neznámý", Purpose: "2026-04",
|
Person: "Někdo Neznámý", Purpose: "2026-04",
|
||||||
@@ -252,7 +252,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
|||||||
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
||||||
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
txs := []Transaction{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 750,
|
Date: "2026-01-01", Amount: 750,
|
||||||
Person: "[?] Alice", Purpose: "2026-01",
|
Person: "[?] Alice", Purpose: "2026-01",
|
||||||
@@ -269,7 +269,7 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
|||||||
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
||||||
func TestReconcileOtherPurpose(t *testing.T) {
|
func TestReconcileOtherPurpose(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
txs := []Transaction{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 300,
|
Date: "2026-01-01", Amount: 300,
|
||||||
Person: "Alice", Purpose: "other:shirt",
|
Person: "Alice", Purpose: "other:shirt",
|
||||||
@@ -297,7 +297,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
|
|||||||
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}}
|
||||||
txs := []Transaction{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 1200,
|
Date: "2026-01-01", Amount: 1200,
|
||||||
Person: "Alice", Purpose: "2026-01, 2026-02",
|
Person: "Alice", Purpose: "2026-01, 2026-02",
|
||||||
@@ -322,7 +322,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
|||||||
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
||||||
func TestReconcileInferenceFallback(t *testing.T) {
|
func TestReconcileInferenceFallback(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
|
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}}
|
||||||
txs := []Transaction{{
|
txs := []Transaction{{
|
||||||
Date: "2026-04-15", Amount: 750,
|
Date: "2026-04-15", Amount: 750,
|
||||||
// Person and Purpose are empty → inference path
|
// Person and Purpose are empty → inference path
|
||||||
@@ -340,7 +340,7 @@ func TestReconcileInferenceFallback(t *testing.T) {
|
|||||||
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
||||||
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
txs := []Transaction{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 500,
|
Date: "2026-01-01", Amount: 500,
|
||||||
// empty person+purpose and sender name not matching any member
|
// empty person+purpose and sender name not matching any member
|
||||||
@@ -360,7 +360,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
|||||||
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
||||||
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
|
|
||||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ func TestParseCzechDate(t *testing.T) {
|
|||||||
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
|
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
|
||||||
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
|
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
|
||||||
{"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format
|
{"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format
|
||||||
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
||||||
{"07/05/26", "2026-05-07"}, // slash variant
|
{"07/05/26", "2026-05-07"}, // slash variant
|
||||||
{"", ""},
|
{"", ""},
|
||||||
{"invalid", ""},
|
{"invalid", ""},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package banksync
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/io/fio"
|
||||||
"io"
|
"io"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"fuj-management/go/internal/io/fio"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ const (
|
|||||||
firstDateCol = 3
|
firstDateCol = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||||
// Source month → target month (source attendance accumulated into target).
|
// Source month → target month (source attendance accumulated into target).
|
||||||
var adultMergedMonths = map[string]string{}
|
var AdultMergedMonths = map[string]string{
|
||||||
|
"2025-09": "2025-10",
|
||||||
|
}
|
||||||
|
|
||||||
// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||||
var juniorMergedMonths = map[string]string{
|
var JuniorMergedMonths = map[string]string{
|
||||||
"2025-12": "2026-01",
|
"2025-12": "2026-01",
|
||||||
"2025-09": "2025-10",
|
"2025-09": "2025-10",
|
||||||
}
|
}
|
||||||
@@ -142,7 +144,13 @@ func parseDates(header []string) []struct {
|
|||||||
}
|
}
|
||||||
var dt time.Time
|
var dt time.Time
|
||||||
var err error
|
var err error
|
||||||
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
|
// Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
|
||||||
|
// accepts both single-digit and zero-padded inputs against them, so
|
||||||
|
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
|
||||||
|
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
|
||||||
|
// the same way; the previous "02.01.2006" form silently dropped those
|
||||||
|
// columns and undercounted attendance.
|
||||||
|
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
|
||||||
dt, err = time.Parse(fmt_, raw)
|
dt, err = time.Parse(fmt_, raw)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
@@ -195,7 +203,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
|||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
dates := parseDates(rows[0])
|
dates := parseDates(rows[0])
|
||||||
months := groupByMonth(dates, adultMergedMonths)
|
months := groupByMonth(dates, AdultMergedMonths)
|
||||||
sortedMonths := sortedKeys(months)
|
sortedMonths := sortedKeys(months)
|
||||||
|
|
||||||
var members []reconcile.Member
|
var members []reconcile.Member
|
||||||
@@ -243,8 +251,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
|||||||
|
|
||||||
mainDates := parseDates(adultRows[0])
|
mainDates := parseDates(adultRows[0])
|
||||||
juniorDates := parseDates(juniorRows[0])
|
juniorDates := parseDates(juniorRows[0])
|
||||||
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
|
mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
|
||||||
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
|
jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
|
||||||
|
|
||||||
allMonths := make(map[string]bool)
|
allMonths := make(map[string]bool)
|
||||||
for m := range mainMonths {
|
for m := range mainMonths {
|
||||||
@@ -337,7 +345,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
|||||||
if !exp.Unknown {
|
if !exp.Unknown {
|
||||||
fee = exp.Value
|
fee = exp.Value
|
||||||
}
|
}
|
||||||
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total}
|
feeMap[m] = reconcile.FeeData{
|
||||||
|
Expected: fee,
|
||||||
|
IsUnknown: exp.Unknown,
|
||||||
|
Attendance: total,
|
||||||
|
JuniorAttendance: c.junior,
|
||||||
|
AdultAttendance: c.adult,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
|
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
|
||||||
}
|
}
|
||||||
@@ -365,11 +379,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
}
|
}
|
||||||
idxDate := idx("date")
|
idxDate := idx("date")
|
||||||
idxAmount := idx("amount")
|
idxAmount := idx("amount")
|
||||||
|
idxManualFix := idx("manual fix")
|
||||||
idxPerson := idx("person")
|
idxPerson := idx("person")
|
||||||
idxPurpose := idx("purpose")
|
idxPurpose := idx("purpose")
|
||||||
idxInferred := idx("inferred amount")
|
idxInferred := idx("inferred amount")
|
||||||
idxSender := idx("sender")
|
idxSender := idx("sender")
|
||||||
|
idxVS := idx("vs")
|
||||||
idxMessage := idx("message")
|
idxMessage := idx("message")
|
||||||
|
idxBankID := idx("bank id")
|
||||||
|
idxSyncID := idx("sync id")
|
||||||
|
|
||||||
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
||||||
if idx(label) == -1 {
|
if idx(label) == -1 {
|
||||||
@@ -384,9 +402,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
return fmt.Sprint(row[i])
|
return fmt.Sprint(row[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||||
|
// dispatch on the underlying numeric type (Sheets returns serial-day
|
||||||
|
// numbers as float64). Stringifying first defeats that dispatch.
|
||||||
|
getRaw := func(row []any, i int) any {
|
||||||
|
if i < 0 || i >= len(row) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return row[i]
|
||||||
|
}
|
||||||
|
|
||||||
var txns []reconcile.Transaction
|
var txns []reconcile.Transaction
|
||||||
for _, row := range rows[1:] {
|
for _, row := range rows[1:] {
|
||||||
dateStr := matching.FormatDate(getVal(row, idxDate))
|
dateStr := matching.FormatDate(getRaw(row, idxDate))
|
||||||
amountRaw := row[idxAmount]
|
amountRaw := row[idxAmount]
|
||||||
if idxAmount < 0 || idxAmount >= len(row) {
|
if idxAmount < 0 || idxAmount >= len(row) {
|
||||||
amountRaw = ""
|
amountRaw = ""
|
||||||
@@ -403,11 +431,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
txns = append(txns, reconcile.Transaction{
|
txns = append(txns, reconcile.Transaction{
|
||||||
Date: dateStr,
|
Date: dateStr,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
|
ManualFix: getVal(row, idxManualFix),
|
||||||
Person: getVal(row, idxPerson),
|
Person: getVal(row, idxPerson),
|
||||||
Purpose: getVal(row, idxPurpose),
|
Purpose: getVal(row, idxPurpose),
|
||||||
InferredAmount: inferredAmount,
|
InferredAmount: inferredAmount,
|
||||||
Sender: getVal(row, idxSender),
|
Sender: getVal(row, idxSender),
|
||||||
|
VS: getVal(row, idxVS),
|
||||||
Message: getVal(row, idxMessage),
|
Message: getVal(row, idxMessage),
|
||||||
|
BankID: getVal(row, idxBankID),
|
||||||
|
SyncID: getVal(row, idxSyncID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return txns, nil
|
return txns, nil
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// adultMergedMonths is empty so 2025-09 stays as-is
|
// AdultMergedMonths sends 2025-09 → 2025-10
|
||||||
if len(months) != 1 || months[0] != "2025-09" {
|
if len(months) != 1 || months[0] != "2025-10" {
|
||||||
t.Errorf("unexpected months: %v", months)
|
t.Errorf("unexpected months: %v", months)
|
||||||
}
|
}
|
||||||
if len(members) != 2 {
|
if len(members) != 2 {
|
||||||
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byName := map[string]int{}
|
byName := map[string]int{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
byName[m.Name] = m.Fees["2025-09"].Attendance
|
byName[m.Name] = m.Fees["2025-10"].Attendance
|
||||||
}
|
}
|
||||||
if byName["Alice"] != 2 {
|
if byName["Alice"] != 2 {
|
||||||
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
|
||||||
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
|
|||||||
}
|
}
|
||||||
byName := map[string]int{}
|
byName := map[string]int{}
|
||||||
for _, m := range members {
|
for _, m := range members {
|
||||||
byName[m.Name] = m.Fees["2025-09"].Expected
|
byName[m.Name] = m.Fees["2025-10"].Expected
|
||||||
}
|
}
|
||||||
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750
|
// 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 750
|
||||||
if byName["Alice"] != 750 {
|
if byName["Alice"] != 750 {
|
||||||
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
|
||||||
}
|
}
|
||||||
@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadTransactions(t *testing.T) {
|
func TestLoadTransactions(t *testing.T) {
|
||||||
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
||||||
|
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
|
||||||
|
// serial-day form (float64) — the API returns either depending on cell
|
||||||
|
// formatting, and FormatDate must handle both.
|
||||||
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
||||||
sh := &sheets.Fake{Values: map[string][][]any{
|
sh := &sheets.Fake{Values: map[string][][]any{
|
||||||
paymentsKey: {
|
paymentsKey: {
|
||||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
||||||
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
|
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
|
||||||
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
|
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
s := buildSources(t, &attendance.Fake{}, sh)
|
s := buildSources(t, &attendance.Fake{}, sh)
|
||||||
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
|
|||||||
if txns[0].Amount != 700 {
|
if txns[0].Amount != 700 {
|
||||||
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
||||||
}
|
}
|
||||||
|
if txns[0].Date != "2026-04-01" {
|
||||||
|
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
|
||||||
|
}
|
||||||
|
if txns[1].Date != "2026-05-05" {
|
||||||
|
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadExceptions(t *testing.T) {
|
func TestLoadExceptions(t *testing.T) {
|
||||||
@@ -165,6 +174,28 @@ func TestLoadExceptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
|
||||||
|
// "02.01.2006" format dropped header cells written without leading zeros
|
||||||
|
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
|
||||||
|
// months on the /api/juniors response. Czech sheet authors drop the zero
|
||||||
|
// pad freely; Python's strptime tolerates it, so the parsers must match.
|
||||||
|
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
|
||||||
|
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
|
||||||
|
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
|
||||||
|
// strptime branch — month-first, day-second.
|
||||||
|
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
|
||||||
|
got := parseDates(header)
|
||||||
|
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i, e := range got {
|
||||||
|
if e.month != want[i] {
|
||||||
|
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TTL smoke test: second call within TTL must not call fetch again.
|
// TTL smoke test: second call within TTL must not call fetch again.
|
||||||
func TestLoadAdults_CacheHit(t *testing.T) {
|
func TestLoadAdults_CacheHit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
263
go/internal/web/api/build_adults.go
Normal file
263
go/internal/web/api/build_adults.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type monthSums struct{ expected, paid int }
|
||||||
|
|
||||||
|
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||||
|
// Mirrors scripts/views.py:build_adults_view_model.
|
||||||
|
func buildAdultsResponse(
|
||||||
|
members []domreconcile.Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
result domreconcile.Result,
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
cfg config.Config,
|
||||||
|
currentMonth string,
|
||||||
|
) AdultsResponse {
|
||||||
|
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
|
||||||
|
|
||||||
|
// Collect tier-A names, sorted.
|
||||||
|
var adultNames []string
|
||||||
|
allNames := make([]string, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
allNames = append(allNames, m.Name)
|
||||||
|
if m.Tier == "A" {
|
||||||
|
adultNames = append(adultNames, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(adultNames)
|
||||||
|
|
||||||
|
// Per-month aggregate totals (expected and paid integers).
|
||||||
|
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
monthlyTotals[m] = &monthSums{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MemberRow
|
||||||
|
for _, name := range adultNames {
|
||||||
|
mr := result.Members[name]
|
||||||
|
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||||
|
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||||
|
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||||
|
row.Balance = settledBalance(mr, currentMonth)
|
||||||
|
row.PayableAmount = max(0, -row.Balance)
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row.
|
||||||
|
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||||
|
for i, m := range sortedMonths {
|
||||||
|
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||||
|
status := "empty"
|
||||||
|
if t.expected > 0 || t.paid > 0 {
|
||||||
|
switch {
|
||||||
|
case t.paid == t.expected:
|
||||||
|
status = "ok"
|
||||||
|
case t.paid < t.expected:
|
||||||
|
status = "unpaid"
|
||||||
|
default:
|
||||||
|
status = "surplus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalsCells[i] = TotalCell{
|
||||||
|
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits and debts (settled balance, past months only).
|
||||||
|
var credits, debts []Credit
|
||||||
|
for _, name := range adultNames {
|
||||||
|
bal := settledBalance(result.Members[name], currentMonth)
|
||||||
|
if bal > 0 {
|
||||||
|
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||||
|
} else if bal < 0 {
|
||||||
|
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||||
|
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||||
|
|
||||||
|
// member_data: full reconcile output for all members (not just adults).
|
||||||
|
memberData := make(map[string]AdultsMemberData, len(result.Members))
|
||||||
|
for name, mr := range result.Members {
|
||||||
|
months := make(map[string]AdultsMonthData, len(mr.Months))
|
||||||
|
for m, md := range mr.Months {
|
||||||
|
var exc *ExceptionData
|
||||||
|
if md.Exception != nil {
|
||||||
|
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||||
|
}
|
||||||
|
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||||
|
for i, te := range md.Transactions {
|
||||||
|
txEntries[i] = memberTxFromDomain(te)
|
||||||
|
}
|
||||||
|
months[m] = AdultsMonthData{
|
||||||
|
Expected: md.Expected,
|
||||||
|
OriginalExpected: md.OriginalExpected,
|
||||||
|
AttendanceCount: md.AttendanceCount,
|
||||||
|
Exception: exc,
|
||||||
|
Paid: md.Paid,
|
||||||
|
Transactions: txEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||||
|
for i, oe := range mr.OtherTransactions {
|
||||||
|
otherTxs[i] = memberOtherFromDomain(oe)
|
||||||
|
}
|
||||||
|
memberData[name] = AdultsMemberData{
|
||||||
|
Tier: mr.Tier,
|
||||||
|
Months: months,
|
||||||
|
OtherTransactions: otherTxs,
|
||||||
|
TotalBalance: mr.TotalBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||||
|
for i, tx := range result.Unmatched {
|
||||||
|
unmatched[i] = rawTxFromDomain(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdultsResponse{
|
||||||
|
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||||
|
RawMonths: sortedMonths,
|
||||||
|
Results: ensureSlice(results),
|
||||||
|
Totals: totalsCells,
|
||||||
|
MemberData: memberData,
|
||||||
|
MonthLabels: monthLabels,
|
||||||
|
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||||
|
Credits: ensureSlice(credits),
|
||||||
|
Debts: ensureSlice(debts),
|
||||||
|
Unmatched: unmatched,
|
||||||
|
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||||
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
|
BankAccount: cfg.BankAccount,
|
||||||
|
CurrentMonth: currentMonth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAdultMemberRow(
|
||||||
|
name string,
|
||||||
|
mr domreconcile.MemberResult,
|
||||||
|
sortedMonths []string,
|
||||||
|
monthLabels map[string]string,
|
||||||
|
currentMonth string,
|
||||||
|
monthlyTotals map[string]*monthSums,
|
||||||
|
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||||
|
row = MemberRow{Name: name}
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
md, ok := mr.Months[m]
|
||||||
|
if !ok {
|
||||||
|
md = domreconcile.MonthData{}
|
||||||
|
}
|
||||||
|
paid := int(md.Paid)
|
||||||
|
expected := md.Expected
|
||||||
|
|
||||||
|
if t := monthlyTotals[m]; t != nil {
|
||||||
|
t.expected += expected
|
||||||
|
t.paid += paid
|
||||||
|
}
|
||||||
|
|
||||||
|
var feeDisplay string
|
||||||
|
var isOverridden bool
|
||||||
|
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
|
||||||
|
isOverridden = true
|
||||||
|
if md.AttendanceCount > 0 {
|
||||||
|
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if md.AttendanceCount > 0 {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "empty"
|
||||||
|
cellText := "-"
|
||||||
|
amountToPay := 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case expected > 0:
|
||||||
|
amountToPay = max(0, expected-paid)
|
||||||
|
switch {
|
||||||
|
case paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
case paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
status = "unpaid"
|
||||||
|
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cellText = fmt.Sprintf("PAID %d", paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip := ""
|
||||||
|
if expected > 0 || paid > 0 {
|
||||||
|
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Months = append(row.Months, MonthCell{
|
||||||
|
Text: cellText,
|
||||||
|
Overridden: isOverridden,
|
||||||
|
Status: status,
|
||||||
|
Amount: amountToPay,
|
||||||
|
Month: monthLabels[m],
|
||||||
|
RawMonth: m,
|
||||||
|
Tooltip: tooltip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return row, unpaidMonths, rawUnpaidMonths
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
|
||||||
|
func rawMonthLabel(m string) string {
|
||||||
|
dt, err := time.Parse("2006-01", m)
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return dt.Format("01/2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinComma(parts []string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := parts[0]
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
result += ", " + p
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPlus(parts []string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := parts[0]
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
result += "+" + p
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
184
go/internal/web/api/build_common.go
Normal file
184
go/internal/web/api/build_common.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fuj-management/go/internal/domain/czech"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||||
|
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||||
|
// Mirrors scripts/views.py:get_month_labels.
|
||||||
|
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||||
|
labels := make(map[string]string, len(sortedMonths))
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
dt, err := time.Parse("2006-01", m)
|
||||||
|
if err != nil {
|
||||||
|
labels[m] = m
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mergedIn []string
|
||||||
|
for src, dst := range mergedMonths {
|
||||||
|
if dst == m {
|
||||||
|
mergedIn = append(mergedIn, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(mergedIn)
|
||||||
|
if len(mergedIn) == 0 {
|
||||||
|
labels[m] = dt.Format("Jan 2006")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
|
||||||
|
sort.Strings(allMonths)
|
||||||
|
years := map[int]bool{}
|
||||||
|
for _, x := range allMonths {
|
||||||
|
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||||
|
years[d.Year()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(allMonths))
|
||||||
|
if len(years) > 1 {
|
||||||
|
for _, x := range allMonths {
|
||||||
|
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||||
|
parts = append(parts, d.Format("Jan 2006"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels[m] = strings.Join(parts, "+")
|
||||||
|
} else {
|
||||||
|
for _, x := range allMonths {
|
||||||
|
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||||
|
parts = append(parts, d.Format("Jan"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelsForMonths returns the display labels for sortedMonths in slice order.
|
||||||
|
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
||||||
|
out := make([]string, len(sortedMonths))
|
||||||
|
for i, m := range sortedMonths {
|
||||||
|
out[i] = labels[m]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||||
|
|
||||||
|
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||||
|
// Mirrors scripts/match_payments.py:canonical_member_key.
|
||||||
|
func canonicalKey(name string) string {
|
||||||
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||||
|
// canonicalizing names against memberNames where possible.
|
||||||
|
// Mirrors scripts/views.py:group_payments_by_person (without the
|
||||||
|
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||||
|
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||||
|
canonicalByKey := make(map[string]string, len(memberNames))
|
||||||
|
for _, n := range memberNames {
|
||||||
|
k := canonicalKey(n)
|
||||||
|
if _, exists := canonicalByKey[k]; !exists {
|
||||||
|
canonicalByKey[k] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grouped := make(map[string][]RawTransaction)
|
||||||
|
for _, tx := range txns {
|
||||||
|
person := strings.TrimSpace(tx.Person)
|
||||||
|
if person == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(person, ",") {
|
||||||
|
p = questionMarkRe.ReplaceAllString(p, "")
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := p
|
||||||
|
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
|
||||||
|
key = canonical
|
||||||
|
}
|
||||||
|
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range grouped {
|
||||||
|
sort.Slice(grouped[k], func(i, j int) bool {
|
||||||
|
return grouped[k][i].Date > grouped[k][j].Date
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
|
||||||
|
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
|
||||||
|
inferredAmount := 0.0
|
||||||
|
if tx.InferredAmount != nil {
|
||||||
|
inferredAmount = *tx.InferredAmount
|
||||||
|
}
|
||||||
|
return RawTransaction{
|
||||||
|
Date: tx.Date,
|
||||||
|
Amount: tx.Amount,
|
||||||
|
ManualFix: tx.ManualFix,
|
||||||
|
Person: tx.Person,
|
||||||
|
Purpose: tx.Purpose,
|
||||||
|
InferredAmount: inferredAmount,
|
||||||
|
Sender: tx.Sender,
|
||||||
|
VS: tx.VS,
|
||||||
|
Message: tx.Message,
|
||||||
|
BankID: tx.BankID,
|
||||||
|
SyncID: tx.SyncID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
|
||||||
|
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
|
||||||
|
return MemberTxEntry{
|
||||||
|
Amount: te.Amount,
|
||||||
|
Date: te.Date,
|
||||||
|
Sender: te.Sender,
|
||||||
|
Message: te.Message,
|
||||||
|
Confidence: te.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
|
||||||
|
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
|
||||||
|
return MemberOtherEntry{
|
||||||
|
Amount: oe.Amount,
|
||||||
|
Date: oe.Date,
|
||||||
|
Sender: oe.Sender,
|
||||||
|
Message: oe.Message,
|
||||||
|
Purpose: oe.Purpose,
|
||||||
|
Confidence: oe.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settledBalance computes the settled balance: sum of (paid − expected) for months
|
||||||
|
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
|
||||||
|
// Python's isinstance(exp, int) guard (skips "?" months).
|
||||||
|
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
|
||||||
|
total := 0
|
||||||
|
for m, md := range mr.Months {
|
||||||
|
if m >= currentMonth || md.IsUnknown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += int(md.Paid) - md.Expected
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
|
||||||
|
// json.Marshal emits [] instead of null.
|
||||||
|
func ensureSlice[T any](s []T) []T {
|
||||||
|
if s == nil {
|
||||||
|
return []T{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
276
go/internal/web/api/build_juniors.go
Normal file
276
go/internal/web/api/build_juniors.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||||
|
// Mirrors scripts/views.py:build_juniors_view_model.
|
||||||
|
func buildJuniorsResponse(
|
||||||
|
members []domreconcile.Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
result domreconcile.Result,
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
cfg config.Config,
|
||||||
|
currentMonth string,
|
||||||
|
) JuniorsResponse {
|
||||||
|
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
|
||||||
|
|
||||||
|
allNames := make([]string, 0, len(members))
|
||||||
|
juniorNames := make([]string, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
allNames = append(allNames, m.Name)
|
||||||
|
juniorNames = append(juniorNames, m.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(juniorNames)
|
||||||
|
|
||||||
|
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
monthlyTotals[m] = &monthSums{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MemberRow
|
||||||
|
for _, name := range juniorNames {
|
||||||
|
mr := result.Members[name]
|
||||||
|
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||||
|
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||||
|
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||||
|
row.Balance = settledBalance(mr, currentMonth)
|
||||||
|
row.PayableAmount = max(0, -row.Balance)
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row.
|
||||||
|
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||||
|
for i, m := range sortedMonths {
|
||||||
|
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||||
|
status := "empty"
|
||||||
|
if t.expected > 0 || t.paid > 0 {
|
||||||
|
switch {
|
||||||
|
case t.paid == t.expected:
|
||||||
|
status = "ok"
|
||||||
|
case t.paid < t.expected:
|
||||||
|
status = "unpaid"
|
||||||
|
default:
|
||||||
|
status = "surplus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalsCells[i] = TotalCell{
|
||||||
|
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var credits, debts []Credit
|
||||||
|
for _, name := range juniorNames {
|
||||||
|
bal := settledBalance(result.Members[name], currentMonth)
|
||||||
|
if bal > 0 {
|
||||||
|
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||||
|
} else if bal < 0 {
|
||||||
|
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||||
|
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||||
|
|
||||||
|
// member_data: full reconcile output for all junior members.
|
||||||
|
memberData := make(map[string]JuniorsMemberData, len(result.Members))
|
||||||
|
for name, mr := range result.Members {
|
||||||
|
months := make(map[string]JuniorsMonthData, len(mr.Months))
|
||||||
|
for m, md := range mr.Months {
|
||||||
|
var exc *ExceptionData
|
||||||
|
if md.Exception != nil {
|
||||||
|
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||||
|
}
|
||||||
|
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||||
|
for i, te := range md.Transactions {
|
||||||
|
txEntries[i] = memberTxFromDomain(te)
|
||||||
|
}
|
||||||
|
months[m] = JuniorsMonthData{
|
||||||
|
Expected: juniorExpected(md),
|
||||||
|
OriginalExpected: juniorOriginalExpected(md),
|
||||||
|
AttendanceCount: md.AttendanceCount,
|
||||||
|
Exception: exc,
|
||||||
|
Paid: md.Paid,
|
||||||
|
Transactions: txEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||||
|
for i, oe := range mr.OtherTransactions {
|
||||||
|
otherTxs[i] = memberOtherFromDomain(oe)
|
||||||
|
}
|
||||||
|
memberData[name] = JuniorsMemberData{
|
||||||
|
Tier: mr.Tier,
|
||||||
|
Months: months,
|
||||||
|
OtherTransactions: otherTxs,
|
||||||
|
TotalBalance: mr.TotalBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||||
|
for i, tx := range result.Unmatched {
|
||||||
|
unmatched[i] = rawTxFromDomain(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
|
||||||
|
"/edit#gid=" + config.JuniorSheetGID
|
||||||
|
|
||||||
|
return JuniorsResponse{
|
||||||
|
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||||
|
RawMonths: sortedMonths,
|
||||||
|
Results: ensureSlice(results),
|
||||||
|
Totals: totalsCells,
|
||||||
|
MemberData: memberData,
|
||||||
|
MonthLabels: monthLabels,
|
||||||
|
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||||
|
Credits: ensureSlice(credits),
|
||||||
|
Debts: ensureSlice(debts),
|
||||||
|
Unmatched: unmatched,
|
||||||
|
AttendanceURL: juniorURL,
|
||||||
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
|
BankAccount: cfg.BankAccount,
|
||||||
|
CurrentMonth: currentMonth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildJuniorMemberRow(
|
||||||
|
name string,
|
||||||
|
mr domreconcile.MemberResult,
|
||||||
|
sortedMonths []string,
|
||||||
|
monthLabels map[string]string,
|
||||||
|
currentMonth string,
|
||||||
|
monthlyTotals map[string]*monthSums,
|
||||||
|
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||||
|
row = MemberRow{Name: name}
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
md, ok := mr.Months[m]
|
||||||
|
if !ok {
|
||||||
|
md = domreconcile.MonthData{}
|
||||||
|
}
|
||||||
|
paid := int(md.Paid)
|
||||||
|
|
||||||
|
// Update monthly totals (skip "?" months for expected).
|
||||||
|
if t := monthlyTotals[m]; t != nil {
|
||||||
|
if !md.IsUnknown {
|
||||||
|
t.expected += md.Expected
|
||||||
|
}
|
||||||
|
t.paid += paid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendance breakdown string e.g. ":3J,2A".
|
||||||
|
var breakdown string
|
||||||
|
jc, ac := md.JuniorAttendance, md.AdultAttendance
|
||||||
|
switch {
|
||||||
|
case jc > 0 && ac > 0:
|
||||||
|
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
|
||||||
|
case jc > 0:
|
||||||
|
breakdown = fmt.Sprintf(":%dJ", jc)
|
||||||
|
case ac > 0:
|
||||||
|
breakdown = fmt.Sprintf(":%dA", ac)
|
||||||
|
}
|
||||||
|
countStr := ""
|
||||||
|
if md.AttendanceCount > 0 {
|
||||||
|
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee display string.
|
||||||
|
var feeDisplay string
|
||||||
|
var isOverridden bool
|
||||||
|
if md.Exception != nil {
|
||||||
|
overrideAmount := md.Exception.Amount
|
||||||
|
var origStr string
|
||||||
|
if md.IsUnknown {
|
||||||
|
origStr = "?"
|
||||||
|
isOverridden = true
|
||||||
|
} else {
|
||||||
|
origStr = strconv.Itoa(md.OriginalExpected)
|
||||||
|
isOverridden = overrideAmount != md.OriginalExpected
|
||||||
|
}
|
||||||
|
if isOverridden {
|
||||||
|
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if md.IsUnknown {
|
||||||
|
feeDisplay = "? CZK" + countStr
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "empty"
|
||||||
|
cellText := "-"
|
||||||
|
amountToPay := 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case md.IsUnknown:
|
||||||
|
cellText = "?" + countStr
|
||||||
|
case md.Expected > 0:
|
||||||
|
switch {
|
||||||
|
case paid >= md.Expected:
|
||||||
|
status = "ok"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
case paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
amountToPay = md.Expected - paid
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
status = "unpaid"
|
||||||
|
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||||
|
amountToPay = md.Expected
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cellText = fmt.Sprintf("PAID %d", paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip := ""
|
||||||
|
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
||||||
|
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Months = append(row.Months, MonthCell{
|
||||||
|
Text: cellText,
|
||||||
|
Overridden: isOverridden,
|
||||||
|
Status: status,
|
||||||
|
Amount: amountToPay,
|
||||||
|
Month: monthLabels[m],
|
||||||
|
RawMonth: m,
|
||||||
|
Tooltip: tooltip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return row, unpaidMonths, rawUnpaidMonths
|
||||||
|
}
|
||||||
|
|
||||||
|
// juniorExpected converts domain MonthData to the Expected wire type.
|
||||||
|
// When an exception exists it always produces a concrete int; otherwise
|
||||||
|
// the "?" sentinel is used when IsUnknown=true.
|
||||||
|
func juniorExpected(md domreconcile.MonthData) Expected {
|
||||||
|
if md.Exception == nil && md.IsUnknown {
|
||||||
|
return Expected{Unknown: true}
|
||||||
|
}
|
||||||
|
return Expected{Value: md.Expected}
|
||||||
|
}
|
||||||
|
|
||||||
|
// juniorOriginalExpected converts the original (pre-exception) expected fee.
|
||||||
|
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
|
||||||
|
if md.IsUnknown {
|
||||||
|
return Expected{Unknown: true}
|
||||||
|
}
|
||||||
|
return Expected{Value: md.OriginalExpected}
|
||||||
|
}
|
||||||
44
go/internal/web/api/build_payments.go
Normal file
44
go/internal/web/api/build_payments.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||||
|
// Mirrors scripts/views.py:build_payments_view_model.
|
||||||
|
func buildPaymentsResponse(
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
memberNames []string,
|
||||||
|
) PaymentsResponse {
|
||||||
|
grouped := groupRawPaymentsByPerson(txns, memberNames)
|
||||||
|
|
||||||
|
// Add unmatched/unknown bucket for transactions with no person set.
|
||||||
|
const unknownKey = "Unmatched / Unknown"
|
||||||
|
for _, tx := range txns {
|
||||||
|
if strings.TrimSpace(tx.Person) == "" {
|
||||||
|
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
|
||||||
|
if rows, ok := grouped[unknownKey]; ok {
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
|
||||||
|
grouped[unknownKey] = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedPeople := make([]string, 0, len(grouped))
|
||||||
|
for p := range grouped {
|
||||||
|
sortedPeople = append(sortedPeople, p)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedPeople)
|
||||||
|
|
||||||
|
return PaymentsResponse{
|
||||||
|
GroupedPayments: grouped,
|
||||||
|
SortedPeople: sortedPeople,
|
||||||
|
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||||
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
|
}
|
||||||
|
}
|
||||||
134
go/internal/web/api/handler.go
Normal file
134
go/internal/web/api/handler.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler holds the shared dependencies for all /api/* routes.
|
||||||
|
type Handler struct {
|
||||||
|
BuildVersion string
|
||||||
|
BuildCommit string
|
||||||
|
BuildDate string
|
||||||
|
Sources membership.Sources
|
||||||
|
Config config.Config
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeVersion handles GET /api/version.
|
||||||
|
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, VersionResponse{
|
||||||
|
Tag: h.BuildVersion,
|
||||||
|
Commit: h.BuildCommit,
|
||||||
|
BuildDate: h.BuildDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAdults handles GET /api/adults.
|
||||||
|
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp, err := h.AssembleAdults(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssembleAdults loads all data and builds the adults view model.
|
||||||
|
// Shared between the JSON API route and the HTML handler.
|
||||||
|
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
|
||||||
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return AdultsResponse{}, err
|
||||||
|
}
|
||||||
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||||
|
return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeJuniors handles GET /api/juniors.
|
||||||
|
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||||
|
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServePayments handles GET /api/payments.
|
||||||
|
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
txns, err := h.Sources.LoadTransactions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
||||||
|
members []domreconcile.Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
if adults {
|
||||||
|
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
|
||||||
|
} else {
|
||||||
|
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("load members: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
txns, err = h.Sources.LoadTransactions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("load transactions: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exceptions, err = h.Sources.LoadExceptions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("load exceptions: %w", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) allMemberNames(ctx context.Context) []string {
|
||||||
|
var names []string
|
||||||
|
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
|
||||||
|
for _, m := range adults {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
|
||||||
|
for _, m := range juniors {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if h.Logger != nil {
|
||||||
|
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
9
go/internal/web/assets.go
Normal file
9
go/internal/web/assets.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed templates
|
||||||
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
49
go/internal/web/html_handler.go
Normal file
49
go/internal/web/html_handler.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTMLHandler serves the Go-native HTML frontend.
|
||||||
|
type HTMLHandler struct {
|
||||||
|
renderer *Renderer
|
||||||
|
build BuildInfo
|
||||||
|
apiHandler *api.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTMLHandler constructs an HTMLHandler.
|
||||||
|
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
|
||||||
|
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.apiHandler.AssembleAdults(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
|
PageData: PageData{Active: "adults", Build: h.build},
|
||||||
|
Error: err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
|
PageData: PageData{Active: "adults", Build: h.build},
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "juniors", PageData{Active: "juniors", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
|
||||||
|
}
|
||||||
128
go/internal/web/html_handler_test.go
Normal file
128
go/internal/web/html_handler_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package web_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/domain/reconcile"
|
||||||
|
"fuj-management/go/internal/web"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee
|
||||||
|
// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's
|
||||||
|
// test_adults_route fixture.
|
||||||
|
type fixtureSources struct{}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||||
|
return []reconcile.Member{
|
||||||
|
{Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{
|
||||||
|
"2026-01": {Expected: 750, Attendance: 4},
|
||||||
|
}},
|
||||||
|
}, []string{"2026-01"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
|
||||||
|
amt := float64(750)
|
||||||
|
return []reconcile.Transaction{
|
||||||
|
{Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixtureHandler(t *testing.T) *api.Handler {
|
||||||
|
t.Helper()
|
||||||
|
return &api.Handler{
|
||||||
|
Sources: fixtureSources{},
|
||||||
|
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTMLHandlerSmoke(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
handler http.HandlerFunc
|
||||||
|
}{
|
||||||
|
{"/adults", h.ServeAdults},
|
||||||
|
{"/juniors", h.ServeJuniors},
|
||||||
|
{"/payments", h.ServePayments},
|
||||||
|
{"/sync-bank", h.ServeSync},
|
||||||
|
{"/flush-cache", h.ServeFlushCache},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.path, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
tc.handler(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if n := strings.Count(body, `class="active"`); n != 1 {
|
||||||
|
t.Errorf(`class="active" count = %d, want 1`, n)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("missing active link %q in body", want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdultsPage(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeAdults(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"Adults Dashboard",
|
||||||
|
"Test Member",
|
||||||
|
"750/750 CZK (4)", // paid/expected (attendance)
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python assertion: cell text never says literally "OK"
|
||||||
|
if strings.Contains(body, ">OK<") {
|
||||||
|
t.Error("body should not contain >OK<")
|
||||||
|
}
|
||||||
|
}
|
||||||
94
go/internal/web/render.go
Normal file
94
go/internal/web/render.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PageData is the view model passed to every HTML template.
|
||||||
|
type PageData struct {
|
||||||
|
Active string
|
||||||
|
Build BuildInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdultsPageData is the view model for the /adults HTML page.
|
||||||
|
type AdultsPageData struct {
|
||||||
|
PageData
|
||||||
|
Data api.AdultsResponse
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderer parses and executes HTML templates from the embedded FS.
|
||||||
|
type Renderer struct {
|
||||||
|
tmpls map[string]*template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
|
||||||
|
|
||||||
|
// qrHref builds the /qr query URL for a single-month Pay button.
|
||||||
|
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
|
||||||
|
func qrHref(account string, amount int, name, rawMonth string) string {
|
||||||
|
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
|
||||||
|
if len(rawMonth) == 7 && rawMonth[4] == '-' {
|
||||||
|
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
|
||||||
|
}
|
||||||
|
msg := name + ": " + rawMonth
|
||||||
|
return "/qr?" + url.Values{
|
||||||
|
"account": {account},
|
||||||
|
"amount": {strconv.Itoa(amount)},
|
||||||
|
"message": {msg},
|
||||||
|
}.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// qrHrefAll builds the /qr query URL for a Pay-All button.
|
||||||
|
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
|
||||||
|
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
|
||||||
|
msg := name + ": " + rawPeriods
|
||||||
|
return "/qr?" + url.Values{
|
||||||
|
"account": {account},
|
||||||
|
"amount": {strconv.Itoa(amount)},
|
||||||
|
"message": {msg},
|
||||||
|
}.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var tmplFuncs = template.FuncMap{
|
||||||
|
"qrHref": qrHref,
|
||||||
|
"qrHrefAll": qrHrefAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRenderer parses all templates from the embedded FS.
|
||||||
|
// A parse failure should be treated as a startup-time fatal error.
|
||||||
|
func NewRenderer() (*Renderer, error) {
|
||||||
|
tmpls := make(map[string]*template.Template, len(pageNames))
|
||||||
|
for _, name := range pageNames {
|
||||||
|
t, err := template.New("").Funcs(tmplFuncs).ParseFS(templateFS,
|
||||||
|
"templates/base.tmpl",
|
||||||
|
"templates/partials/nav.tmpl",
|
||||||
|
"templates/partials/footer.tmpl",
|
||||||
|
"templates/"+name+".tmpl",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse template %q: %w", name, err)
|
||||||
|
}
|
||||||
|
tmpls[name] = t
|
||||||
|
}
|
||||||
|
return &Renderer{tmpls: tmpls}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render executes the named template with data, writing to w.
|
||||||
|
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
|
||||||
|
t, ok := r.tmpls[name]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||||
|
slog.Error("render template", "name", name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
"fuj-management/go/internal/web/middleware"
|
"fuj-management/go/internal/web/middleware"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -15,18 +19,48 @@ type BuildInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run registers routes and starts the HTTP server on addr.
|
// Run registers routes and starts the HTTP server on addr.
|
||||||
func Run(logger *slog.Logger, addr string, build BuildInfo) error {
|
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
||||||
|
renderer, err := NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("init templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ah := &api.Handler{
|
||||||
|
BuildVersion: build.Version,
|
||||||
|
BuildCommit: build.Commit,
|
||||||
|
BuildDate: build.BuildDate,
|
||||||
|
Sources: sources,
|
||||||
|
Config: cfg,
|
||||||
|
Logger: logger,
|
||||||
|
}
|
||||||
|
hh := NewHTMLHandler(renderer, build, ah)
|
||||||
|
|
||||||
|
staticSubFS, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("static subfs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /{$}", helloHandler(build))
|
|
||||||
|
// HTML routes
|
||||||
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/adults", http.StatusFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /adults", hh.ServeAdults)
|
||||||
|
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
|
||||||
|
mux.HandleFunc("GET /payments", hh.ServePayments)
|
||||||
|
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
|
||||||
|
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
|
||||||
|
|
||||||
|
// Static files
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
|
||||||
|
|
||||||
|
// JSON API routes
|
||||||
|
mux.HandleFunc("GET /api/version", ah.ServeVersion)
|
||||||
|
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
|
||||||
|
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
|
||||||
|
mux.HandleFunc("GET /api/payments", ah.ServePayments)
|
||||||
|
|
||||||
logger.Info("starting server", "addr", addr)
|
logger.Info("starting server", "addr", addr)
|
||||||
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||||
}
|
}
|
||||||
|
|
||||||
func helloHandler(build BuildInfo) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
||||||
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
|
|
||||||
build.Version, build.Commit, build.BuildDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
478
go/internal/web/static/css/app.css
Normal file
478
go/internal/web/static/css/app.css
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
color: #cccccc;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
color: #000;
|
||||||
|
background-color: #00ff00;
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
color: #fff;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #666;
|
||||||
|
border-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a.active {
|
||||||
|
color: #ccc;
|
||||||
|
background-color: #333;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-archived a:hover {
|
||||||
|
color: #999;
|
||||||
|
border-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a {
|
||||||
|
color: #00ff00;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: right;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
th:first-child,
|
||||||
|
td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #888888;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-pos {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-neg {
|
||||||
|
color: #ff3333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-ok {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid {
|
||||||
|
color: #ff3333;
|
||||||
|
background-color: rgba(255, 51, 51, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-unpaid-current {
|
||||||
|
color: #994444;
|
||||||
|
background-color: rgba(153, 68, 68, 0.05);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-overridden {
|
||||||
|
color: #ffa500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pay-btn {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: #ff3333;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-row:hover .pay-btn {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-empty {
|
||||||
|
color: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 1px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
color: #ccc;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-val {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-row {
|
||||||
|
font-family: inherit;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 100px 200px 1fr;
|
||||||
|
gap: 15px;
|
||||||
|
color: #888;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unmatched-header {
|
||||||
|
color: #555;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
width: 250px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-input:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
color: #888;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
color: #00ff00;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
#memberModal {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
z-index: 9999;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#memberModal.active {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: #0c0c0c;
|
||||||
|
border: 1px solid #00ff00;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #00ff00;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
color: #ff3333;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section {
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-section-title {
|
||||||
|
color: #555;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-toggle {
|
||||||
|
color: #333;
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
margin-left: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.raw-toggle:hover {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th,
|
||||||
|
.modal-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px dashed #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-table th {
|
||||||
|
color: #666;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-meta {
|
||||||
|
color: #555;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-amount {
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-sender {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tx-msg {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 50px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 9px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perf-breakdown {
|
||||||
|
display: none;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR Modal styles */
|
||||||
|
#qrModal .modal-content {
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
background: white;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-image img {
|
||||||
|
display: block;
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details {
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details div {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-details span {
|
||||||
|
color: #00ff00;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
91
go/internal/web/static/js/filters.js
Normal file
91
go/internal/web/static/js/filters.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Client-side filters for the Adults (and future Juniors) dashboard table.
|
||||||
|
// Mirrors adults.html:864-1051 from the Python frontend.
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// NFD-normalize + strip diacritics + lowercase, matching Python's
|
||||||
|
// unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode().lower()
|
||||||
|
function normalize(s) {
|
||||||
|
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.getElementById('filterContainer');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const currentMonth = container.dataset.currentMonth || '';
|
||||||
|
|
||||||
|
const nameInput = document.getElementById('nameFilter');
|
||||||
|
const fromSelect = document.getElementById('fromMonth');
|
||||||
|
const toSelect = document.getElementById('toMonth');
|
||||||
|
const applyBtn = document.getElementById('applyFilter');
|
||||||
|
const clearBtn = document.getElementById('clearFilter');
|
||||||
|
|
||||||
|
// ── Month column visibility ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Hide columns whose raw month is in the future by default.
|
||||||
|
function hideFutureMonths() {
|
||||||
|
if (!currentMonth) return;
|
||||||
|
document.querySelectorAll('[data-raw-month]').forEach(el => {
|
||||||
|
if (el.dataset.rawMonth > currentMonth) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Sync toMonth select to the last non-hidden month.
|
||||||
|
const ths = [...document.querySelectorAll('thead th[data-month-idx]')];
|
||||||
|
const visibleIdxs = ths
|
||||||
|
.filter(th => !th.classList.contains('month-hidden'))
|
||||||
|
.map(th => parseInt(th.dataset.monthIdx, 10));
|
||||||
|
if (visibleIdxs.length) {
|
||||||
|
toSelect.value = String(visibleIdxs[visibleIdxs.length - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMonthFilter() {
|
||||||
|
const from = fromSelect.value !== '' ? parseInt(fromSelect.value, 10) : -Infinity;
|
||||||
|
const to = toSelect.value !== '' ? parseInt(toSelect.value, 10) : Infinity;
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(el => {
|
||||||
|
const idx = parseInt(el.dataset.monthIdx, 10);
|
||||||
|
if (idx < from || idx > to) {
|
||||||
|
el.classList.add('month-hidden');
|
||||||
|
} else {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMonthFilter() {
|
||||||
|
document.querySelectorAll('[data-month-idx]').forEach(el => {
|
||||||
|
el.classList.remove('month-hidden');
|
||||||
|
});
|
||||||
|
fromSelect.value = '';
|
||||||
|
toSelect.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name row visibility ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function applyNameFilter() {
|
||||||
|
const query = normalize(nameInput.value.trim());
|
||||||
|
document.querySelectorAll('tr.member-row').forEach(row => {
|
||||||
|
const name = normalize(row.dataset.name || '');
|
||||||
|
row.style.display = (!query || name.includes(query)) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event wiring ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
nameInput.addEventListener('input', applyNameFilter);
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', applyMonthFilter);
|
||||||
|
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
nameInput.value = '';
|
||||||
|
applyNameFilter();
|
||||||
|
clearMonthFilter();
|
||||||
|
hideFutureMonths();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initialise ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
hideFutureMonths();
|
||||||
|
}());
|
||||||
141
go/internal/web/templates/adults.tmpl
Normal file
141
go/internal/web/templates/adults.tmpl
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{{define "title"}}Adults{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Adults Dashboard</h1>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="description">Error loading data: {{.Error}}</div>
|
||||||
|
{{else}}
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against attendance fees.<br>
|
||||||
|
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
|
||||||
|
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="nameFilter">Member</label>
|
||||||
|
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="fromMonth">From</label>
|
||||||
|
<select id="fromMonth" class="filter-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<option value="{{$i}}">{{$m}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<label class="filter-label" for="toMonth">To</label>
|
||||||
|
<select id="toMonth" class="filter-select">
|
||||||
|
<option value="">All</option>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<option value="{{$i}}">{{$m}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filter-item">
|
||||||
|
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
|
||||||
|
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Data.Results}}
|
||||||
|
<div class="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{{range $i, $m := .Data.Months}}
|
||||||
|
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
|
||||||
|
{{end}}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $row := .Data.Results}}
|
||||||
|
<tr class="member-row" data-name="{{$row.Name}}">
|
||||||
|
<td class="member-name">{{$row.Name}}</td>
|
||||||
|
{{range $i, $cell := $row.Months}}
|
||||||
|
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
|
||||||
|
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
|
||||||
|
{{$cell.Text}}
|
||||||
|
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
|
||||||
|
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
|
||||||
|
{{$row.Balance}}
|
||||||
|
{{if gt $row.PayableAmount 0}}
|
||||||
|
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
|
||||||
|
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
|
||||||
|
{{range $i, $t := .Data.Totals}}
|
||||||
|
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
|
||||||
|
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
|
||||||
|
{{$t.Text}}
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="description">No members found.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Credits}}
|
||||||
|
<h2>Credits (Advance Payments / Surplus)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{{range .Data.Credits}}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{.Name}}</span>
|
||||||
|
<span class="list-item-val">{{.Amount}} CZK</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Debts}}
|
||||||
|
<h2>Debts (Missing Payments)</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
{{range .Data.Debts}}
|
||||||
|
<div class="list-item">
|
||||||
|
<span class="list-item-name">{{.Name}}</span>
|
||||||
|
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Data.Unmatched}}
|
||||||
|
<h2>Unmatched Transactions</h2>
|
||||||
|
<div class="list-container">
|
||||||
|
<div class="unmatched-row unmatched-header">
|
||||||
|
<span>Date</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
<span>Sender</span>
|
||||||
|
<span>Message</span>
|
||||||
|
</div>
|
||||||
|
{{range .Data.Unmatched}}
|
||||||
|
<div class="unmatched-row">
|
||||||
|
<span>{{.Date}}</span>
|
||||||
|
<span>{{printf "%.0f" .Amount}}</span>
|
||||||
|
<span>{{.Sender}}</span>
|
||||||
|
<span>{{.Message}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<script src="/static/js/filters.js" defer></script>
|
||||||
|
{{end}}
|
||||||
15
go/internal/web/templates/base.tmpl
Normal file
15
go/internal/web/templates/base.tmpl
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "base"}}<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ — {{template "title" .}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
{{template "content" .}}
|
||||||
|
{{template "footer" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/flush_cache.tmpl
Normal file
5
go/internal/web/templates/flush_cache.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Flush Cache{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Flush Cache</h1>
|
||||||
|
<p class="description">Coming in M6.6</p>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/juniors.tmpl
Normal file
5
go/internal/web/templates/juniors.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Juniors{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Juniors Dashboard</h1>
|
||||||
|
<p class="description">Coming in M6.3</p>
|
||||||
|
{{end}}
|
||||||
3
go/internal/web/templates/partials/footer.tmpl
Normal file
3
go/internal/web/templates/partials/footer.tmpl
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{{define "footer"}}
|
||||||
|
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
|
||||||
|
{{end}}
|
||||||
17
go/internal/web/templates/partials/nav.tmpl
Normal file
17
go/internal/web/templates/partials/nav.tmpl
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "nav"}}
|
||||||
|
<div class="nav">
|
||||||
|
<div>
|
||||||
|
<a href="/adults"{{if eq .Active "adults"}} class="active"{{end}}>[Adults]</a>
|
||||||
|
<a href="/juniors"{{if eq .Active "juniors"}} class="active"{{end}}>[Juniors]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Archived:</span>
|
||||||
|
<a href="/payments"{{if eq .Active "payments"}} class="active"{{end}}>[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
<div class="nav-archived">
|
||||||
|
<span style="color: #666; margin-right: 5px;">Tools:</span>
|
||||||
|
<a href="/sync-bank"{{if eq .Active "sync"}} class="active"{{end}}>[Sync Bank Data]</a>
|
||||||
|
<a href="/flush-cache"{{if eq .Active "flush"}} class="active"{{end}}>[Flush Cache]</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/payments.tmpl
Normal file
5
go/internal/web/templates/payments.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Payments Ledger{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Payments Ledger</h1>
|
||||||
|
<p class="description">Coming in M6.4</p>
|
||||||
|
{{end}}
|
||||||
5
go/internal/web/templates/sync.tmpl
Normal file
5
go/internal/web/templates/sync.tmpl
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{{define "title"}}Sync Bank Data{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>Sync Bank Data</h1>
|
||||||
|
<p class="description">Coming in M6.6</p>
|
||||||
|
{{end}}
|
||||||
@@ -30,8 +30,7 @@ JUNIOR_MONTHLY_RATE = {
|
|||||||
"2026-03": 250 # reduced fee for March 2026
|
"2026-03": 250 # reduced fee for March 2026
|
||||||
}
|
}
|
||||||
ADULT_MERGED_MONTHS = {
|
ADULT_MERGED_MONTHS = {
|
||||||
#"2025-12": "2026-01", # keys are merged into values
|
"2025-09": "2025-10", # keys are merged into values
|
||||||
#"2025-09": "2025-10"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
JUNIOR_MERGED_MONTHS = {
|
JUNIOR_MERGED_MONTHS = {
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
idx_sender = get_col_index("Sender")
|
idx_sender = get_col_index("Sender")
|
||||||
idx_message = get_col_index("Message")
|
idx_message = get_col_index("Message")
|
||||||
idx_bank_id = get_col_index("Bank ID")
|
idx_bank_id = get_col_index("Bank ID")
|
||||||
|
idx_vs = get_col_index("VS")
|
||||||
|
idx_sync_id = get_col_index("Sync ID")
|
||||||
|
|
||||||
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||||
missing = [name for name, idx in required.items() if idx == -1]
|
missing = [name for name, idx in required.items() if idx == -1]
|
||||||
@@ -247,16 +249,33 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
def get_val(idx):
|
def get_val(idx):
|
||||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||||
|
|
||||||
|
def get_str(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, float) and v.is_integer():
|
||||||
|
return str(int(v))
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
def get_float(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return float(v)
|
||||||
|
try:
|
||||||
|
return float(str(v).strip())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
tx = {
|
tx = {
|
||||||
"date": format_date(get_val(idx_date)),
|
"date": format_date(get_val(idx_date)),
|
||||||
"amount": get_val(idx_amount),
|
"amount": get_float(idx_amount),
|
||||||
"manual_fix": get_val(idx_manual),
|
"manual_fix": get_val(idx_manual),
|
||||||
"person": get_val(idx_person),
|
"person": get_val(idx_person),
|
||||||
"purpose": get_val(idx_purpose),
|
"purpose": get_val(idx_purpose),
|
||||||
"inferred_amount": get_val(idx_inferred_amount),
|
"inferred_amount": get_val(idx_inferred_amount),
|
||||||
"sender": get_val(idx_sender),
|
"sender": get_val(idx_sender),
|
||||||
"message": get_val(idx_message),
|
"vs": get_str(idx_vs),
|
||||||
|
"message": get_str(idx_message),
|
||||||
"bank_id": get_val(idx_bank_id),
|
"bank_id": get_val(idx_bank_id),
|
||||||
|
"sync_id": get_val(idx_sync_id),
|
||||||
}
|
}
|
||||||
transactions.append(tx)
|
transactions.append(tx)
|
||||||
|
|
||||||
|
|||||||
@@ -310,8 +310,9 @@ def build_juniors_view_model(
|
|||||||
cell_text = "-"
|
cell_text = "-"
|
||||||
amount_to_pay = 0
|
amount_to_pay = 0
|
||||||
|
|
||||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
is_unknown = original_expected == "?"
|
||||||
if expected == "?":
|
if is_unknown or (isinstance(expected, int) and expected > 0):
|
||||||
|
if is_unknown:
|
||||||
status = "empty"
|
status = "empty"
|
||||||
cell_text = f"?{count_str}"
|
cell_text = f"?{count_str}"
|
||||||
elif paid >= expected:
|
elif paid >= expected:
|
||||||
@@ -339,7 +340,7 @@ def build_juniors_view_model(
|
|||||||
status = "surplus"
|
status = "surplus"
|
||||||
cell_text = f"PAID {paid}"
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
else:
|
else:
|
||||||
tooltip = ""
|
tooltip = ""
|
||||||
|
|||||||
@@ -1044,8 +1044,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
|
||||||
fromSelect.value = defaultFrom;
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1025,8 +1025,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var defaultFrom = Math.max(0, maxMonthIdx - 4);
|
|
||||||
fromSelect.value = defaultFrom;
|
|
||||||
toSelect.value = maxMonthIdx;
|
toSelect.value = maxMonthIdx;
|
||||||
applyMonthFilter();
|
applyMonthFilter();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from app import app
|
from app import app
|
||||||
|
|
||||||
|
EXPECTED_ADULTS_KEYS = {
|
||||||
|
"months", "raw_months", "results", "totals", "member_data", "month_labels",
|
||||||
|
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
|
||||||
|
"payments_url", "bank_account", "current_month",
|
||||||
|
}
|
||||||
|
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
|
||||||
|
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
|
||||||
|
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
|
||||||
|
|
||||||
|
|
||||||
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||||
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||||
@@ -97,5 +107,83 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'500/500 CZK', response.data)
|
self.assertIn(b'500/500 CZK', response.data)
|
||||||
self.assertIn(b'?', response.data)
|
self.assertIn(b'?', response.data)
|
||||||
|
|
||||||
|
def test_api_version(self):
|
||||||
|
"""Test /api/version returns BUILD_META keys as JSON."""
|
||||||
|
response = self.client.get('/api/version')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
|
'purpose': '2026-01', 'message': 'test payment',
|
||||||
|
'sender': 'External Bank User', 'inferred_amount': 750,
|
||||||
|
'vs': '', 'sync_id': 'abc123',
|
||||||
|
}]
|
||||||
|
response = self.client.get('/api/adults')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
|
||||||
|
self.assertIsInstance(data['member_data'], dict)
|
||||||
|
self.assertIsInstance(data['month_labels'], dict)
|
||||||
|
self.assertIsInstance(data['raw_payments'], dict)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.fetch_exceptions', return_value={})
|
||||||
|
@patch('app.get_junior_members_with_fees')
|
||||||
|
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
|
||||||
|
mock_get_junior_members.return_value = (
|
||||||
|
[
|
||||||
|
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
|
||||||
|
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
|
||||||
|
],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
|
||||||
|
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
|
||||||
|
'vs': '', 'sync_id': 'def456',
|
||||||
|
}]
|
||||||
|
response = self.client.get('/api/juniors')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
|
||||||
|
self.assertIsInstance(data['member_data'], dict)
|
||||||
|
self.assertIsInstance(data['month_labels'], dict)
|
||||||
|
self.assertIsInstance(data['raw_payments'], dict)
|
||||||
|
|
||||||
|
@patch('app.get_cached_data', side_effect=_bypass_cache)
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
def test_api_payments(self, mock_fetch_sheet, mock_cache):
|
||||||
|
"""Test /api/payments returns JSON with correct top-level keys."""
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
|
||||||
|
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
|
||||||
|
'vs': '', 'sync_id': 'ghi789',
|
||||||
|
}]
|
||||||
|
response = self.client.get('/api/payments')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.is_json)
|
||||||
|
data = json.loads(response.data)
|
||||||
|
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
|
||||||
|
self.assertIsInstance(data['grouped_payments'], dict)
|
||||||
|
self.assertIsInstance(data['sorted_people'], list)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user