Compare commits

...

13 Commits

Author SHA1 Message Date
fa853780db chore: tick M2.3 + M2.4 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 09:25:45 +02:00
0fc3b6dd9a Merge pull request 'feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee' (#6) from feat/m2-3-m2-4-domain-fees into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #6
2026-05-06 07:23:02 +00:00
57ec817044 feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Ports calculate_fee and calculate_junior_fee from scripts/attendance.py
into a new go/internal/domain/fees package. Introduces the Expected type
(Value int, Unknown bool) for the junior "?" sentinel, keeping the Go
API strictly typed instead of mirroring Python's str|int return.

All 20 table-driven tests pass with -race; golangci-lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:38:09 +02:00
6cf83a01e3 docs(claude): correct stale adult fee defaults
ADULT_FEE_DEFAULT is 700 CZK, not 750. The 750 appears in
ADULT_FEE_MONTHLY_RATE for most current months but is not the fallback.
Rephrase the member-tiers bullet to point at the dict rather than a
number that drifts each season; update the fee-calc bullet to match
the junior line's style (default 700 vs default 500).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:29:19 +02:00
98f401c149 chore: tick M2.2 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:10:44 +02:00
0a8017fffa Merge pull request 'feat(go/M2.2): port czech.ParseMonthReferences' (#5) from feat/m2-2-parse-month-references into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #5
2026-05-05 22:07:15 +00:00
6d971b61d4 feat(go/M2.2): port czech.ParseMonthReferences
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Three-pass regex parser matching python/czech_utils.py parse_month_references:
1. Numeric slash notation — "11+12/2025", "01/26"; 2-digit year → +2000
2. Dot notation — "12.2025" (4-digit year only)
3. Czech month names — range walk (listopad-leden wrap logic) then
   standalone with m≥10 → defaultYear-1 heuristic; longest-match
   alternation (sorted desc by name length) handles cervenec vs cerven

35 table-driven tests, all expected outputs verified against live Python
on 2026-05-05 before locking. Plan at
docs/plans/2026-05-05-2337-go-rewrite-m2-2-parse-month-references.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:05:40 +02:00
3460f57c62 chore: tick M2.1 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
go/internal/domain/czech.Normalize merged as 20ade6d (PR #4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 23:34:00 +02:00
6ca35e2112 docs: Encourage tea CLI for opening MRs
Replaces the "do not use tea/gh/Gitea API" rule with explicit guidance to
run `tea pr create` and print the resulting PR URL. tea is already
authenticated on this machine. Merging stays a manual user action in
Gitea — neither tea nor git CLI may merge or delete branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 23:33:31 +02:00
20ade6de3e Merge pull request 'feat(go/M2.1): port czech.Normalize' (#4) from feat/m2-1-czech-normalize into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #4
2026-05-05 21:26:55 +00:00
d9a61b338c feat(go/M2.1): port czech.Normalize — NFKD + Mn strip + lowercase
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds internal/domain/czech.Normalize, the first pure-domain function in
the Go rewrite (M2 milestone). Matches Python czech_utils.normalize byte-
for-byte: NFKD decompose via golang.org/x/text/unicode/norm, drop Mn-
category combining marks (unicode.Mn, not IsMark, to match Python's
unicodedata.combining() semantics), then strings.ToLower.

Includes 13-case table-driven test; all inputs spot-checked against the
Python implementation before locking. Adds golang.org/x/text v0.36.0 as
first external dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:23:40 +02:00
91ac3b37cf docs: Add branch-per-feature + Gitea MR workflow to CLAUDE.md
Feature work now goes on feat/<slug> branches; Claude pushes and prints
the Gitea compare URL for the user to open the MR. Exceptions documented
for small fixes and typo tweaks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 21:52:25 +02:00
394da2e6b8 fix: Tolerate diacritic/case/whitespace mismatches in Person column matching
Some checks failed
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Failing after 6s
- Add canonical_member_key() in match_payments.py to normalize names via
  NFKD + lowercase + whitespace-collapse before ledger lookup; resolves
  payments attributed to e.g. "Maria Maco" to canonical "Mária Maco".
  Emits logger.info when a non-canonical cell is rescued so sheet typos
  are visible in logs without losing the payment allocation.
- Extend group_payments_by_person() in app.py to accept member_names and
  re-key raw-payment groups under the canonical attendance-sheet name so
  the modal's Raw Payments debug section also finds the row correctly.
- Add raw payments collapsible section to member detail modal in adults.html
  and juniors.html for debugging payment attribution issues.
- Remove 4 obsolete tests targeting routes /fees, /fees-juniors, /reconcile,
  /reconcile-juniors that no longer exist; add test_match_payments.py
  covering canonical key equivalence and reconcile() tolerance end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:22:54 +02:00
24 changed files with 1608 additions and 127 deletions

View File

@@ -1,5 +1,23 @@
# Changelog
## 2026-05-06 09:24 CEST — feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee
- New `go/internal/domain/fees` package with adult and junior fee calculators ported from `scripts/attendance.py`.
- `CalculateFee(count, monthKey) int``0→0`, `1→200`, `2+→AdultFeeMonthlyRate[month]` (fallback 700 CZK).
- `CalculateJuniorFee(count, monthKey) Expected``0→{0}`, `1→{Unknown:true}` (the `"?"` sentinel, now strictly typed), `2+→JuniorFeeMonthlyRate[month]` (fallback 500 CZK).
- 20 table-driven tests, all verified against live Python; `-race` clean; `golangci-lint` clean.
## 2026-05-06 00:07 CEST — feat(go/M2.2): port czech.ParseMonthReferences
- `internal/domain/czech.ParseMonthReferences`: three-pass regex (numeric slash, dot, Czech month names) with range wrap-around and `m≥10 → previousYear` heuristic, byte-equivalent to Python.
- 35 table-driven tests; all expected outputs verified against live Python before locking (addresses risk #4 from the rewrite plan).
## 2026-05-05 23:33 CEST — feat(go/M2.1): port czech.Normalize
- First M2 pure-domain task: `internal/domain/czech.Normalize` (NFKD + Mn-strip + lowercase), byte-equivalent to Python `czech_utils.normalize`.
- Adds `golang.org/x/text v0.36.0` as first external Go dependency.
- 13-case table-driven test, all spot-checked against Python before locking.
## 2026-05-04 23:08 CEST — fix: payment inference exact-match short-circuit
- `match_members()` now short-circuits on whole-word full-name hits; nickname/partial checks only run when no full name is present.

View File

@@ -64,13 +64,13 @@ Fio Bank API ──► sync_fio_to_sheets.py ──► Google Shee
### Member tiers
Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (750 CZK/month for 2+ sessions, 200 CZK for exactly 1)
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
- `X` — Excluded from junior fee calculation (coaches, etc.)
### Fee calculation
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 750 CZK)
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
@@ -92,6 +92,45 @@ Tiers are set in column B of the attendance sheet:
`/qr?account=…&amount=…&message=…` generates a Czech QR Platba PNG (SPD format).
## Branching & merge requests
The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`).
For **features**, do not commit to `main` directly. Use a branch + merge
request flow:
1. **Create a branch off `main`** before starting work:
- `feat/<slug>` for features (e.g. `feat/qr-code-overlay`)
- `fix/<slug>` for bug-fix branches the user explicitly asks for
- `<slug>` is short kebab-case
2. **Commit on the branch** following the existing commit conventions
(Co-Authored-By trailer, etc.).
3. **Push the branch** to `origin` with `-u` so it tracks.
4. **Open the MR with `tea`** rather than printing a compare URL:
```bash
tea pr create \
--title "<short title>" \
--description "<body>" \
--base main \
--head <branch>
```
`tea` is already authenticated against the Gitea instance; just run it.
Print the resulting PR URL for the user. If `tea` is unavailable for
some reason, fall back to printing the compare URL
(`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...<branch>`)
and let the user open the MR manually.
5. **Do not merge or delete the branch** from the CLI — neither via `tea`,
`gh`, nor `git push --delete`. The user does that in Gitea.
**Exceptions — when committing straight to `main` is fine:**
- Small bug fixes / hotfixes the user describes as such.
- Typo / comment / formatting tweaks.
- Edits the user explicitly says to push to `main`.
When uncertain whether something is a feature or a small fix, ask before
committing.
## Git Commits
When making git commits, always append yourself as co-author trailer to the end of the commit message to indicate AI assistance

62
app.py
View File

@@ -22,7 +22,7 @@ from config import (
BANK_ACCOUNT, CREDENTIALS_PATH,
)
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
from sync_fio_to_sheets import sync_to_sheets
from infer_payments import infer_payments
@@ -57,6 +57,25 @@ def get_month_labels(sorted_months, merged_months):
labels[m] = dt.strftime("%b %Y")
return labels
def group_payments_by_person(transactions, member_names=None):
canonical_by_key = (
{canonical_member_key(n): n for n in member_names} if member_names else {}
)
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
key = canonical_by_key.get(canonical_member_key(p), p)
grouped.setdefault(key, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
def warmup_cache():
"""Pre-fetch all cached data so first request is fast."""
logger = logging.getLogger(__name__)
@@ -304,6 +323,7 @@ def adults_view():
unmatched = result["unmatched"]
import json
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
record_step("process_data")
return render_template(
@@ -314,6 +334,7 @@ def adults_view():
totals=formatted_totals,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
raw_payments_json=json.dumps(raw_payments_by_person),
credits=credits,
debts=debts,
unmatched=unmatched,
@@ -506,6 +527,7 @@ def juniors_view():
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
unmatched = result["unmatched"]
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
import json
record_step("process_data")
@@ -518,6 +540,7 @@ def juniors_view():
totals=formatted_totals,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
raw_payments_json=json.dumps(raw_payments_by_person),
credits=credits,
debts=debts,
unmatched=unmatched,
@@ -535,29 +558,24 @@ def payments():
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
# Group transactions by person
grouped = {}
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])
grouped = group_payments_by_person(transactions, member_names)
# payments page also groups unmatched rows under a fallback key
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
person = "Unmatched / Unknown"
# Handle multiple people (comma separated)
people = [p.strip() for p in person.split(",") if p.strip()]
for p in people:
# Strip markers
clean_p = re.sub(r"\[\?\]\s*", "", p)
if clean_p not in grouped:
grouped[clean_p] = []
grouped[clean_p].append(tx)
# Sort people and their transactions
if not str(tx.get("person", "")).strip():
grouped.setdefault("Unmatched / Unknown", []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
sorted_people = sorted(grouped.keys())
for p in sorted_people:
# Sort by date descending
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
record_step("process_data")
return render_template(
"payments.html",

View File

@@ -4,7 +4,7 @@ Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-
**Current milestone:** M2 — Pure-domain helpers
**Started:** 2026-05-04
**Last updated:** 2026-05-04
**Last updated:** 2026-05-06
## How to use
@@ -44,10 +44,10 @@ Goal: every pure function from the Python backend exists in Go with a parity tes
Each task: port the function, write Go unit tests for fresh cases, hook into the Tier-1 parity runner.
- [ ] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase)
- [ ] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference)
- [ ] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table)
- [ ] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel
- [x] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase) — `20ade6d`
- [x] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference) — `0a8017f`
- [x] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table) — `0fc3b6d`
- [x] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel — `0fc3b6d`
- [ ] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators)
- [ ] **M2.6** `domain/synch.GenerateSyncID` — port [sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` (SHA-256, byte-stable hash; verify float string format against real sheet rows)
- [ ] **M2.7** `domain/matching.BuildNameVariants` + `MatchMembers` — port `_build_name_variants` and `match_members` from [match_payments.py](scripts/match_payments.py) (auto vs review confidence, common-surname filter)

View File

@@ -0,0 +1,99 @@
# Member modal — raw payments debug list
## Context
When a payer's bank message doesn't follow our convention, [`infer_payments.py`](scripts/infer_payments.py) may map the transfer to the wrong period (or none), and today the member detail modal hides this — it only shows the post-allocation, per-month splits produced by [`reconcile()`](scripts/match_payments.py:295). To diagnose these cases the user needs to see the **original sheet rows** that were attributed to a member: full `Amount`, `Inferred Amount`, `Person`, `Purpose`, `Sender`, `Message`, `Bank ID`, `manual fix`. The list should be hidden by default and revealed by a small toggle, since it is only relevant during debugging.
## Approach
Reuse the grouping logic that already exists in the [`/payments` route](app.py:540-553): group raw `tx` dicts by parsed `Person`, expose that mapping to the modal, and render it on demand under a new collapsible section.
### 1. Backend — group raw txs by member
In [`app.py`](app.py):
- Factor the existing per-person grouping in [`payments()`](app.py:530-568) into a small helper near the top of the file:
```python
def group_payments_by_person(transactions):
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue # unmatched rows are not tied to a member
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
grouped.setdefault(p, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
```
Call it from [`payments()`](app.py:530), [`adults_view()`](app.py:160) and [`juniors_view()`](app.py:326) — the existing `payments()` body collapses to one line.
- In `adults_view()` and `juniors_view()`, after `transactions = get_cached_data(...)`, build `raw_payments_by_person = group_payments_by_person(transactions)` and pass it to `render_template` as `raw_payments_json=json.dumps(raw_payments_by_person)`.
- Note: rows where `Person` is empty are skipped on purpose — those have no member to attach to and are already shown by the dashboard's `Unmatched` block.
### 2. Templates — add a collapsible raw section to the modal
In [`templates/adults.html`](templates/adults.html) and [`templates/juniors.html`](templates/juniors.html), make the same structural and JS changes (the modal markup is mirrored in both files — `adults.html:677-682` and `juniors.html:658-663`).
- Inject the new dataset alongside the existing `memberData`:
```html
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
```
(next to [`adults.html:696`](templates/adults.html#L696)).
- Add a new section directly **after** the Payment History block:
```html
<div class="modal-section">
<div class="modal-section-title">
Raw Payments
<a href="#" id="rawPaymentsToggle" class="raw-toggle"
onclick="toggleRawPayments(event)">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display: none;">
<!-- Filled by JS -->
</div>
</div>
```
Add a small CSS rule for `.raw-toggle` (muted color, smaller font, `margin-left: 8px`) — a few lines next to the existing `.modal-section-title` style. Don't restyle the whole modal.
- In `showMemberDetails(name)`:
- Reset the toggle to `[show]` and the `#modalRawList` to `display: none` on every open (so the state doesn't leak between members).
- Populate `#modalRawList` from `rawPaymentsByPerson[name] || []`. For each row render: `Date | Purpose` on the meta line, `Amount CZK` (with `Inferred: X CZK` annotation when `inferred_amount` differs from `amount`), `Sender`, `Person` (full string — useful when split between multiple people), `Message`, and a small footer with `Bank ID` and a `[manual fix]` marker if `manual_fix` is truthy. Reuse the existing `tx-item` / `tx-meta` / `tx-main` / `tx-msg` styles to match the rest of the modal.
- When the list is empty, render `<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>` (same idiom used at [`adults.html:813`](templates/adults.html#L813)).
- Add the toggle handler near `closeModal`:
```js
function toggleRawPayments(ev) {
ev.preventDefault();
const list = document.getElementById('modalRawList');
const link = document.getElementById('rawPaymentsToggle');
const hidden = list.style.display === 'none';
list.style.display = hidden ? 'block' : 'none';
link.textContent = hidden ? '[hide]' : '[show]';
}
```
### 3. Why not extend `reconcile()` instead
`reconcile()` already collapses each row into per-month allocated shares and drops `purpose`, `inferred_amount`, `bank_id`, `manual_fix`, and the gross `amount` ([trace](scripts/match_payments.py:436-469)). Carrying the raw `tx` through `reconcile()` would inflate the contract for every consumer when only the modal needs it. Grouping the already-fetched `transactions` list at the route level is one extra dict per request and reuses the cached payments data — no new sheet reads.
## Critical files
- [app.py](app.py) — add `group_payments_by_person()` helper; call it in `adults_view()`, `juniors_view()`, and `payments()`; pass `raw_payments_json` to the two dashboard templates.
- [templates/adults.html](templates/adults.html) — modal section + JS + tiny CSS for the toggle link.
- [templates/juniors.html](templates/juniors.html) — same changes as adults.html.
## Verification
1. `make web-debug` and open `http://localhost:5001/adults`.
2. Pick a member known to have multiple payments (use the existing `/payments` page as a cross-reference).
3. Click `[i]` → modal opens, raw list is hidden, link shows `[show]`. Click the link → list appears with the raw rows; click again → hides, link returns to `[show]`.
4. Switch to another member via keyboard (ArrowDown) — the toggle resets to hidden and the list updates to the new member's rows (no leaking).
5. Compare the raw rows in the modal against the `/payments` page grouping for the same person — same set of rows, same `Date`/`Amount`/`Message`.
6. Pick a row with a non-conformant message (e.g. one where `Person` was inferred to multiple people) — confirm `Person` shows the full comma-separated string and `Inferred Amount` is visible when it differs from `Amount`.
7. Repeat the click-through on `/juniors` to confirm parity.
8. `make test` — no backend behavior change is expected, but run to catch template/route smoke breakage.

View File

@@ -0,0 +1,135 @@
# Tolerate diacritic / case / whitespace mismatches between `Person` column and member names
## Context
For "Mária Maco" there is a payment row in the payments sheet with `Purpose = 2026-04`, but the modal for that member shows neither a paid 2026-04 cell **nor** a row in payment history. Both symptoms collapse to a single root cause in [`reconcile()`](scripts/match_payments.py#L295), confirmed by reading the code:
- [`scripts/match_payments.py:404`](scripts/match_payments.py#L404) — `if member_name not in ledger:` is a **byte-exact** comparison. `member_name` is the `Person` cell from the payments sheet with only `.strip()` and `[?]` markers removed ([:349-353](scripts/match_payments.py#L349-L353)). `ledger` keys are the canonical names from the attendance sheet. There is no diacritic, case, or whitespace normalization on this path. (`czech_utils.normalize` is imported and used for the `exceptions` lookup at [:282-283 / :321-322](scripts/match_payments.py#L282-L322), but **not** for member-name matching.)
- When a row falls through that check, it is appended to `unmatched` and never reaches `ledger[member_name][m]['paid']` or `['transactions']`. The dashboard's per-month "paid" cell stays unpaid, and because the modal's payment history is built from `data.months[m].transactions` ([`templates/adults.html:772-776`](templates/adults.html#L772-L776)), the row also disappears from the modal's history list.
- The new "Raw Payments" debug section ([`templates/adults.html:861`](templates/adults.html#L861)) uses `rawPaymentsByPerson[name]`. Its keys come from [`group_payments_by_person()` in `app.py:60-73`](app.py#L60-L73), which also stores the **literal** `Person` string (only `.strip()` and `[?]` stripped). So if the attendance-sheet name and the `Person` cell differ at the byte level, that section also returns an empty list — which is why the user does not see the row anywhere in the modal.
The most likely cause for "Mária Maco" specifically: the `Person` cell was typed (or pasted) without the `á` diacritic — `Maria Maco` vs `Mária Maco`. Other plausible variants the current code silently drops: case differences (`mária maco`), trailing/embedded extra whitespace, and NBSP characters.
The fix is to make the matching tolerant via the existing [`czech_utils.normalize()`](scripts/czech_utils.py#L22-L25) helper (NFKD + lowercase), with a small whitespace-collapse on top, and apply the same canonicalization in `group_payments_by_person()` so the modal's raw-payments lookup uses the canonical attendance-sheet name as the key.
## Approach
### 1. `scripts/match_payments.py` — tolerant `Person` → `ledger` resolution in `reconcile()`
- Add a small private helper at module scope:
```python
def _canonical_key(name: str) -> str:
return re.sub(r"\s+", " ", normalize(name)).strip()
```
Uses the existing `normalize()` from `czech_utils` ([:22-25](scripts/czech_utils.py#L22-L25)) and additionally collapses whitespace runs to a single space so `"Mária Maco"` and `"Mária Maco"` both reduce to `"maria maco"`.
- Inside [`reconcile()`](scripts/match_payments.py#L295), right after `member_names` is computed ([:308](scripts/match_payments.py#L308)), build a lookup dict once:
```python
canonical_by_key: dict[str, str] = {}
for name in member_names:
key = _canonical_key(name)
canonical_by_key.setdefault(key, name) # first wins; ambiguity handled below
```
- Replace the byte-exact check at [:404](scripts/match_payments.py#L404). Resolve each `member_name` from `matched_members` to the canonical attendance-sheet name before any ledger / credits access:
```python
for raw_member_name, confidence in matched_members:
member_name = canonical_by_key.get(_canonical_key(raw_member_name))
if member_name is None:
logger.warning(
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
raw_member_name, tx.get("date", "?"), tx.get("message", "?"),
)
unmatched.append(tx)
continue
if member_name != raw_member_name:
logger.info(
"Person cell %r resolved to canonical member %r — consider fixing the sheet",
raw_member_name, member_name,
)
# ... rest of the loop body unchanged: ledger[member_name], credits[member_name], …
```
The `logger.info` line lets the user see (in `make web-debug` logs) which sheet rows have a non-canonical `Person` value, so they can clean them up at their own pace — without breaking allocation in the meantime.
- Leave the rest of the function untouched. Once `member_name` is the canonical name, every downstream key (`ledger[member_name]`, `credits[member_name]`, `other_ledger[member_name]`, the `tx["person"]` echo into `transactions`) is already correct.
### 2. `app.py` — canonicalize the raw-payments grouping key
- The current [`group_payments_by_person()`](app.py#L60-L73) cannot canonicalize on its own because it does not know the attendance-sheet member list. Extend its signature to accept the member list and reuse `_canonical_key`:
```python
from match_payments import _canonical_key # or re-export via a tiny public name
def group_payments_by_person(transactions, member_names=None):
canonical_by_key = (
{_canonical_key(n): n for n in member_names} if member_names else {}
)
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
key = canonical_by_key.get(_canonical_key(p), p) # fallback: keep raw
grouped.setdefault(key, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
```
- Update the three call sites to pass `member_names`:
- `adults_view()` around [`app.py:333`](app.py#L333) — `members` is already in scope; pass `[name for name, _, _ in members]`.
- `juniors_view()` around [`app.py:539`](app.py#L539) — same.
- `payments()` around [`app.py:549`](app.py#L549) — same; needs the adult+junior member names so the `/payments` per-person grouping is consistent.
- Naming: `_canonical_key` starts with an underscore inside `match_payments.py`. To avoid leaking a private symbol, expose it as `canonical_member_key` (no underscore) in `match_payments.py` and import that name from `app.py`.
### 3. Why not also touch `infer_payments.py`
`infer_payments.py` already writes canonical attendance-sheet names into the `Person` column (it picks from `member_names`). The bug only manifests when the cell was filled in **manually** by a human (typed without diacritics, different case) or was written by an older inference that has since drifted from a renamed attendance row. Making `reconcile()` tolerant fixes the symptom for both cases without changing inference. The `logger.info` line is sufficient signal for the user to clean up the sheet on their own schedule.
### 4. Tests
**4a. Delete obsolete route tests in [tests/test_app.py](tests/test_app.py).** Four tests target Flask routes that no longer exist (the old fee/reconcile pages were merged into `/adults` and `/juniors`); they currently fail with 404. Their coverage is already provided by `test_adults_route`, `test_juniors_route`, and `test_payments_route`. Delete:
- `test_fees_route` ([tests/test_app.py:22-35](tests/test_app.py#L22-L35)) — hits `/fees`
- `test_fees_juniors_route` ([tests/test_app.py:37-55](tests/test_app.py#L37-L55)) — hits `/fees-juniors`
- `test_reconcile_route` ([tests/test_app.py:57-81](tests/test_app.py#L57-L81)) — hits `/reconcile`; also asserts a literal `OK` string the merged dashboard no longer renders
- `test_reconcile_juniors_route` ([tests/test_app.py:101-131](tests/test_app.py#L101-L131)) — hits `/reconcile-juniors`; same `OK` assertion mismatch
The two tests that reference junior-only formatting (`? / 1 (J)` and `500 CZK / 4 (1A+3J)`) are testing a retired template, not the live `/juniors` page — no need to migrate those assertions; the live `/juniors` format is already covered by `test_juniors_route`.
**4b. Add `tests/test_match_payments.py`** (new file) covering the resolution helper and `reconcile()` end-to-end for the canonicalization fix:
- `_canonical_key("Mária Maco") == _canonical_key("maria maco")`
- `reconcile()` with member `"Mária Maco"` and a tx `{person: "Maria Maco", purpose: "2026-04", amount: 750, ...}` produces:
- `result['members']['Mária Maco']['months']['2026-04']['paid'] == 750`
- the tx appears in `result['members']['Mária Maco']['months']['2026-04']['transactions']`
- `result['unmatched']` is empty
- `reconcile()` with `Person = "Někdo Neznámý"` (no match in members) still routes to `unmatched`.
## Critical files
- [scripts/match_payments.py](scripts/match_payments.py) — add `canonical_member_key()` helper; build `canonical_by_key` once in `reconcile()`; resolve `raw_member_name` → `member_name` before ledger access at [:404](scripts/match_payments.py#L404).
- [app.py](app.py) — extend `group_payments_by_person()` to accept `member_names` and key the grouped dict by canonical attendance-sheet name; update three call sites.
- [tests/test_app.py](tests/test_app.py) — delete the four obsolete route tests listed in §4a.
- [tests/test_match_payments.py](tests/test_match_payments.py) — add the cases above (create the file if missing).
- [docs/plans/](docs/plans/) — per project [CLAUDE.md](CLAUDE.md), move this plan file to `docs/plans/2026-05-05-1640-payment-person-name-canonicalization.md` once execution starts (the plan-mode harness writes to `~/.claude/plans/` by default).
## Verification
1. **Reproduce first.** Before touching code, open `/adults`, click `[i]` next to "Mária Maco", and confirm both: 2026-04 is unpaid and the payment is missing from history. Inspect the actual `Person` cell value in the payments sheet for the 2026-04 row — confirm it differs from `"Mária Maco"` (likely missing the `á`). Record the exact string for the test case.
2. `make test` — new tests pass; existing tests still green.
3. `make web-debug` and reload `/adults`. The 2026-04 cell for "Mária Maco" turns green (`cell-ok`); the modal's payment history shows the row; the "Raw Payments" section also shows the row. Server log emits `Person cell 'Maria Maco' resolved to canonical member 'Mária Maco' — consider fixing the sheet`.
4. Cross-check `/payments` — the row appears under the `Mária Maco` group (canonical key), not under a separate `Maria Maco` group.
5. Spot-check one member with the conventionally-correct `Person` value (e.g. one of the recent payers visible on the dashboard) — paid cells and history are unchanged, no spurious resolution log line.
6. Confirm a payment with a genuinely unknown `Person` (typo of a non-member) still ends up in the dashboard's `Unmatched` block and emits the existing `Payment matched to unknown member …` warning.
7. Append a `CHANGELOG.md` entry per [CLAUDE.md](CLAUDE.md) once the user confirms the fix works.

View File

@@ -0,0 +1,83 @@
# Branch-per-feature + Gitea MR workflow
## Context
Until now, Claude has been committing feature work directly to `main`
(see recent history: `feat: Lower adult monthly fee…`, `feat: Go rewrite M1…`,
all on `main`). The user wants to switch to a branch-per-feature flow with
review via a Gitea merge request, so that:
- Feature work is reviewable as a self-contained diff before it lands.
- `main` stays releasable.
- The change history shows reviewed merges, not unsupervised pushes.
The remote is Gitea (`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management.git`),
which supports the standard pull/merge-request flow.
This plan only modifies `CLAUDE.md`. No code changes.
## Scope clarification (from user)
- **MR creation method:** Claude pushes the branch and prints the Gitea
"compare" URL. The user opens / merges the MR in the browser. No `tea` CLI,
no API calls.
- **When the flow applies:** Features only. Small bug fixes and hotfixes can
still be committed straight to `main`. Claude decides feature-vs-fix based
on scope; when uncertain, ask.
- **Branch naming:** `feat/<slug>` for features, `fix/<slug>` for the
occasional bug-fix branch the user explicitly requests. `<slug>` is
kebab-case, short, descriptive.
## Change
Add a new top-level section to `CLAUDE.md` titled **"Branching & merge requests"**,
placed immediately before the existing `## Git Commits` section so the workflow
context appears before the commit-message convention.
### Proposed section content
```markdown
## Branching & merge requests
The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`).
For **features**, do not commit to `main` directly. Use a branch + merge
request flow:
1. **Create a branch off `main`** before starting work:
- `feat/<slug>` for features (e.g. `feat/qr-code-overlay`)
- `fix/<slug>` for bug-fix branches the user explicitly asks for
- `<slug>` is short kebab-case
2. **Commit on the branch** following the existing commit conventions
(Co-Authored-By trailer, etc.).
3. **Push the branch** to `origin` with `-u` so it tracks.
4. **Print the Gitea compare URL** so the user can open the MR in the
browser:
`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...<branch>`
Do **not** use `tea`, `gh`, or call the Gitea API — the user opens and
merges the MR themselves.
5. **Do not merge or delete the branch** from the CLI. The user does that
in Gitea.
**Exceptions — when committing straight to `main` is fine:**
- Small bug fixes / hotfixes the user describes as such.
- Typo / comment / formatting tweaks.
- Edits the user explicitly says to push to `main`.
When uncertain whether something is "feature" or "small fix", ask before
committing.
```
## Files to modify
- [CLAUDE.md](CLAUDE.md) — insert the new `## Branching & merge requests`
section just above the existing `## Git Commits` section (around line 95).
## Verification
- Re-read `CLAUDE.md` and confirm the new section is well-placed and the
existing structure (`## Git Commits`, `## Changelog`, `## Plans`) is intact.
- `git diff CLAUDE.md` should show only an additive change.
- No code, tests, or runtime behavior changes — nothing else to test.
- Behavior verification happens on the **next** feature request: Claude
should create a `feat/<slug>` branch, commit there, push, and print the
compare URL instead of committing on `main`.

View File

@@ -0,0 +1,154 @@
# Plan: Go rewrite — M2.1 `domain/czech.Normalize`
## Context
The Go rewrite finished M1 (skeleton, tooling, hello server) in commit
`cf0f176` on 2026-05-04. The next milestone, **M2 — Pure-domain helpers**,
is current per [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md)
but has no work landed yet (all 12 sub-tasks unchecked).
This plan covers only the **first** M2 task: porting Python's
`normalize` from [scripts/czech_utils.py](../../scripts/czech_utils.py)
to Go as `internal/domain/czech.Normalize`. It is the lowest-level helper
in the domain — `parse_month_references`, `_build_name_variants`,
`match_members`, exception keys, and `reconcile` all transitively depend
on it. Getting it byte-equivalent first removes a class of "why does my
match not fire" failures from every later M2 task.
**Decision (confirmed in plan-mode Q):** start with hand-written Go unit
tests for fresh Czech edge cases. Defer parity-fixture wiring until
M3.1/M3.2 land (separate task); add the parity test for `Normalize`
retroactively at that point.
## Scope
- New package `go/internal/domain/czech/` with `Normalize` and unit tests.
- Add `golang.org/x/text` dependency to `go/go.mod` (currently zero deps).
- **Out of scope:** `ParseMonthReferences` (M2.2), fixture tooling
(M3.1/M3.2), CLI subcommand wiring (M2.11/M2.12), parity test runner.
## Recommended approach
### Python contract to match
```python
def normalize(text: str) -> str:
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
```
Three semantic operations:
1. NFKD decompose
2. Drop characters where `unicodedata.combining(c)` is non-zero
3. Lowercase
### Go implementation
`go/internal/domain/czech/normalize.go`:
```go
package czech
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
func Normalize(s string) string {
decomposed := norm.NFKD.String(s)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
if unicode.In(r, unicode.Mn) {
continue
}
b.WriteRune(r)
}
return strings.ToLower(b.String())
}
```
**Two precision points worth flagging:**
1. **`unicode.Mn` not `unicode.IsMark`.** The plan's library-choices
table mentions `unicode.IsMark`, but that covers Mn + Mc + Me. Python
`unicodedata.combining()` returns 0 for Mc/Me (their canonical
combining class is 0), so it effectively filters only Mn. Use
`unicode.In(r, unicode.Mn)` for byte-equivalence with Python. Cite
this in a one-line code comment; it's the kind of thing a future
reader will second-guess.
2. **`strings.ToLower` vs Go's locale-aware tools.** Python's `.lower()`
on already-decomposed Latin is straight ASCII lowercase for Czech.
Stdlib `strings.ToLower` matches; do not pull in `golang.org/x/text/cases`.
### Tests
`go/internal/domain/czech/normalize_test.go` — table-driven, covers:
- ASCII passthrough: `"Honza" → "honza"`
- Czech lowercase diacritics: `"žluťoučký" → "zlutoucky"`
- Mixed case + diacritics: `"Příliš" → "prilis"`
- Czech caron + ring: `"Dvořák" → "dvorak"`, `"Růžena" → "ruzena"`
- Hard letters: `"Čeněk" → "cenek"`, `"Kačer" → "kacer"`
- Empty string: `"" → ""`
- Already-normalized: `"prilis" → "prilis"` (idempotence)
- Pre-composed vs decomposed input both produce the same output (NFC
`"é"` and `"é"` both → `"e"`)
- Whitespace preserved: `"Jan Novák" → "jan novak"`
Run a one-shot cross-check against the live Python implementation for
each test input before locking the table:
```
PYTHONPATH=scripts:. python -c \
'from czech_utils import normalize; print(repr(normalize("Dvořák")))'
```
This is the manual stand-in for the M3 parity fixtures.
### Wire-up
- `go get golang.org/x/text@latest` (run from `go/`); `go mod tidy`.
- No CLI changes — `cmd/fuj` already stubs `fees`/`reconcile` with
exit code 2; no need to touch dispatcher for this task. `Normalize`
is consumed by other domain code, not by users directly.
## Critical files
- New: [go/internal/domain/czech/normalize.go](../../go/internal/domain/czech/normalize.go)
- New: [go/internal/domain/czech/normalize_test.go](../../go/internal/domain/czech/normalize_test.go)
- Modified: [go/go.mod](../../go/go.mod), `go/go.sum` (new)
- Reference (read-only): [scripts/czech_utils.py](../../scripts/czech_utils.py) — the porting source
- Reference (read-only): [docs/plans/2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) — risk #3 (NFKD edge cases)
## Verification
End-to-end checks before marking M2.1 done:
1. `cd go && go build ./...` — clean compile.
2. `cd go && go test ./internal/domain/czech/...` — all table cases green.
3. `cd go && go test -race ./...` — race-clean.
4. `cd go && golangci-lint run` (or `make go-lint` from repo root) — clean.
5. **Spot parity** (manual, will be automated in M3): for each Go test
input, run the Python `normalize` via `PYTHONPATH=scripts:. python -c
'...'` and confirm bytes match. Capture the diff in the commit
message if anything surprises.
6. `make go-build && make go-test && make go-lint` from repo root — proves
the existing M1 gate still passes.
## Branching & follow-up
Per [CLAUDE.md](../../CLAUDE.md), this is feature work → branch + Gitea MR:
- Branch: `feat/m2-1-czech-normalize` off `main`.
- Single commit, Co-Authored-By trailer.
- Push with `-u`, print compare URL
`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...feat/m2-1-czech-normalize`
- User opens/merges the MR.
- After merge: tick `M2.1` in the progress tracker with the commit SHA;
add a one-line CHANGELOG entry; record any porting surprise in the
tracker's "Notes & decisions" section (e.g. the `Mn`-vs-`IsMark`
precision point if it bears noting).
Next task after this lands is **M2.2 `ParseMonthReferences`** — the
larger, edge-case-heavier sibling. Whether to start it before or after
M3.1/M3.2 is a separate decision the user can make then.

View File

@@ -0,0 +1,205 @@
# Plan: Go rewrite — M2.2 `domain/czech.ParseMonthReferences`
## Context
M2.1 (`domain/czech.Normalize`) merged via PR #4 (`d9a61b3`) on
2026-05-05. Per the [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md),
**M2.2** is next: port `parse_month_references` from
[scripts/czech_utils.py](../../scripts/czech_utils.py) to Go as
`internal/domain/czech.ParseMonthReferences`.
This function is the second-most-load-bearing pure helper after
`reconcile`: every payment-message → month inference goes through it.
Risk #4 in the [parent plan](2026-05-03-2349-go-backend-rewrite.md)
specifically calls out its semantics — wrap-around year inference and
the `m >= 10 → previous year` standalone heuristic — as easy to mis-port.
This plan locks the test table against the live Python implementation
*before* coding, so the Go port has a verified parity baseline even
before the M3.1/M3.2 fixture infrastructure exists.
## Scope
- New file `go/internal/domain/czech/parse_month_references.go` in the
existing `czech` package (alongside [normalize.go](../../go/internal/domain/czech/normalize.go)).
- New file `go/internal/domain/czech/parse_month_references_test.go`
with the test table below.
- **Out of scope:** parity-fixture wiring (M3.1/M3.2); CLI hook-up
(M2.11/M2.12); any consumer call-sites.
- **No new dependencies** — stdlib `regexp`, `sort`, `strconv`, `strings`
plus the existing `czech.Normalize` cover everything.
## Recommended approach
### Python contract to mirror
Three regex passes, all run on `normalize(text)`:
1. `([\d+]+)\s*/\s*(\d{2,4})` — captures `"11+12/2025"`, `"01/26"`, `"1/26"`.
Split the months part on `+`, keep digit-only tokens, validate `1..12`.
Year < 100 → year + 2000.
2. `(\d{1,2})\s*\.\s*(\d{4})` — captures `"12.2025"`. **4-digit year only**
(so `"1.26"` does not match).
3. Czech month names. First the **range** sub-pass:
`(name)\s*-\s*(name)` finds pairs; walk start→end with `m % 12 + 1`,
stopping when `m == end_m`. Wrap rule: if `start_m > end_m`, months
`>= start_m` are `defaultYear - 1`, the rest are `defaultYear`. Both
matched names go into a `foundInRanges` set.
Then the **standalone** sub-pass: `\b(name)\b`, skipping any name in
`foundInRanges`. For each remaining match, `m >= 10 → defaultYear - 1`,
else `defaultYear`.
Output: sorted, deduplicated `[]string` of `"YYYY-MM"`.
### Go signature
```go
package czech
// ParseMonthReferences extracts YYYY-MM month references from Czech
// free text. defaultYear seeds two heuristics: standalone month names
// with m >= 10 are treated as defaultYear-1 (out-of-year backfill), and
// wrap-around ranges (e.g. listopad-leden) place months >= start in
// defaultYear-1.
func ParseMonthReferences(text string, defaultYear int) []string
```
Required `defaultYear` (no default value — Go convention).
### Implementation sketch
```go
var czechMonths = map[string]int{
"leden": 1, "ledna": 1, "lednu": 1,
"unor": 2, "unora": 2, "unoru": 2,
"brezen": 3, "brezna": 3, "breznu": 3,
"duben": 4, "dubna": 4, "dubnu": 4,
"kveten": 5, "kvetna": 5, "kvetnu": 5,
"cerven": 6, "cervna": 6, "cervnu": 6,
"cervenec": 7, "cervnce": 7, "cervenci": 7,
"srpen": 8, "srpna": 8, "srpnu": 8,
"zari": 9,
"rijen": 10, "rijna": 10, "rijnu": 10,
"listopad": 11, "listopadu": 11,
"prosinec": 12, "prosince": 12, "prosinci": 12,
}
// Sorted by descending length at init, so longer alternatives win in
// the regex (e.g. "cervenec" beats "cerven"). Mirrors Python's
// sorted(..., key=len, reverse=True).
var monthNameAlt = buildMonthNameAlt()
var (
numericRe = regexp.MustCompile(`([\d+]+)\s*/\s*(\d{2,4})`)
dotRe = regexp.MustCompile(`(\d{1,2})\s*\.\s*(\d{4})`)
rangeRe = regexp.MustCompile(`(` + monthNameAlt + `)\s*-\s*(` + monthNameAlt + `)`)
standRe = regexp.MustCompile(`\b(` + monthNameAlt + `)\b`)
)
```
Three Go-specific gotchas worth a code comment:
1. **RE2 alternation is leftmost-first**, same as Python `re`. Sorting
month names by descending length is therefore necessary (otherwise
`"cervenec"` matches as `"cerven"` + leftover `"ec"`). Mirror the
Python sort exactly.
2. **Map iteration is randomized in Go.** Build the alternation list
from a sorted slice of keys, not by iterating the map.
3. **`\d` and `\b`** in Go RE2 are ASCII-only, which matches the
effective behavior on `Normalize`'d input (NFKD already collapsed
any Unicode digits/letters that would matter; standalone Devanagari
digits in member messages aren't a real-world concern).
The walk loop uses a bounded counter (max 12 iterations) defensively in
Go; Python's `while True` is fine because every range terminates within
12 hops, but a future reader appreciates the bound.
### Test table (verified against live Python — `default_year=2026`)
Locked outputs from `PYTHONPATH=scripts:. python -c 'from czech_utils
import parse_month_references; print(parse_month_references(<input>, 2026))'`
on 2026-05-05.
| # | Input | Expected | Path exercised |
|---|---|---|---|
| 1 | `""` | `[]` | empty |
| 2 | `"11+12/2025"` | `["2025-11", "2025-12"]` | numeric, plus-split |
| 3 | `"1/2026"` | `["2026-01"]` | numeric, single |
| 4 | `"01/26"` | `["2026-01"]` | 2-digit year normalization |
| 5 | `"11+12/25"` | `["2025-11", "2025-12"]` | plus-split + 2-digit year |
| 6 | `"12+1+2/2026"` | `["2026-01", "2026-02", "2026-12"]` | sorting |
| 7 | `"12.2025"` | `["2025-12"]` | dot pattern |
| 8 | `"1.26"` | `[]` | dot pattern requires 4-digit year |
| 9 | `"leden"` | `["2026-01"]` | standalone, m<10 |
| 10 | `"prosinec"` | `["2025-12"]` | standalone, m≥10 → previous year |
| 11 | `"prosince"` | `["2025-12"]` | declension |
| 12 | `"lednu"` | `["2026-01"]` | declension |
| 13 | `"rijen"` | `["2025-10"]` | m≥10 boundary (10 itself) |
| 14 | `"zari"` | `["2026-09"]` | m<10 just below boundary |
| 15 | `"listopad-leden"` | `["2025-11", "2025-12", "2026-01"]` | wrap range Nov→Jan |
| 16 | `"rijen-leden"` | `["2025-10", "2025-11", "2025-12", "2026-01"]` | wrap from October |
| 17 | `"unor-kveten"` | `["2026-02", "2026-03", "2026-04", "2026-05"]` | non-wrap range |
| 18 | `"leden-leden"` | `["2026-01"]` | degenerate range |
| 19 | `"unor-listopad"` | `["2026-02", ..., "2026-11"]` (10 entries) | range spans m≥10 — heuristic does NOT fire (range exclusion) |
| 20 | `"cervenec-srpen"` | `["2026-07", "2026-08"]` | longest-match alt (`cervenec` not `cerven`+`ec`) |
| 21 | `"listopad-leden, prosinec"` | `["2025-11", "2025-12", "2026-01"]` | range + standalone, dedup |
| 22 | `"prosinec leden"` | `["2025-12", "2026-01"]` | two standalones, no range |
| 23 | `"11+12/2025, leden-brezen"` | `["2025-11", "2025-12", "2026-01", "2026-02", "2026-03"]` | numeric + range mix |
| 24 | `"11+12/25 a listopad"` | `["2025-11", "2025-12"]` | dedup across passes |
| 25 | `"prosince/2025"` | `["2025-12"]` | numeric pattern fails (no digits before `/`); standalone fires |
| 26 | `"listopad-prosinec/2025"` | `["2026-11", "2026-12"]` | range wins; numeric pattern fails |
| 27 | `"01.2026 / 02.2026"` | `["2026-01", "2026-02"]` | dot pattern only; numeric matches `(2026, 02)` but month 2026 is out of range |
| 28 | `"/12/2025"` | `["2025-12"]` | numeric matches at second `/` |
| 29 | `"PROSINEC"` | `["2025-12"]` | normalize lowercases |
| 30 | `"Žluťoučký prosinec"` | `["2025-12"]` | normalize strips diacritics |
| 31 | `"Únor - květen"` | `["2026-02", ..., "2026-05"]` | range tolerates spaces around `-`, diacritics survive normalize |
| 32 | `"platba 11/2025 a leden"` | `["2025-11", "2026-01"]` | mixed natural-language |
| 33 | `"December"` | `[]` | English month names not recognized |
| 34 | `"11+12/2025 11+12/2025"` | `["2025-11", "2025-12"]` | dedup of repeated input |
| 35 | `"leden 2026"` | `["2026-01"]` | trailing year is ignored unless dot/slash separator present |
35 cases is enough to lock semantics; the M3.x corpus will pile on
real-message fixtures later.
### Wire-up
- No `go.mod` changes (stdlib only).
- No CLI changes.
- `Normalize` is in the same package, so call it directly.
## Critical files
- New: [go/internal/domain/czech/parse_month_references.go](../../go/internal/domain/czech/parse_month_references.go)
- New: [go/internal/domain/czech/parse_month_references_test.go](../../go/internal/domain/czech/parse_month_references_test.go)
- Reference (read-only): [scripts/czech_utils.py](../../scripts/czech_utils.py) — the porting source
- Reference (read-only): [docs/plans/2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) — risk #4
- Reuses: [go/internal/domain/czech/normalize.go](../../go/internal/domain/czech/normalize.go) — `Normalize` is called once at the top of `ParseMonthReferences`
## Verification
End-to-end checks before marking M2.2 done:
1. `cd go && go build ./...` — clean compile.
2. `cd go && go test ./internal/domain/czech/...` — all 35 table cases green.
3. `cd go && go test -race ./...` — race-clean (regex compiles are global; verify no init races).
4. `cd go && golangci-lint run` (or `make go-lint` from repo root) — clean, gofumpt-formatted.
5. **Spot parity** (manual, will be automated in M3.x): each test input has its expected output captured from the live Python implementation on 2026-05-05; the test table itself is the parity record. If any case diverges during implementation, re-run Python with the exact input to confirm the truth and update either the Go code or the test entry.
6. `make go-build && make go-test && make go-lint` from repo root — proves M1/M2.1 gate still passes.
## Branching & follow-up
Per [CLAUDE.md](../../CLAUDE.md), this is feature work → branch + Gitea MR via `tea`:
- Branch: `feat/m2-2-parse-month-references` off `main`.
- Single focused commit, Co-Authored-By trailer.
- Push with `-u`.
- Open MR with `tea pr create --title "feat(go/M2.2): port czech.ParseMonthReferences" --description ... --base main --head feat/m2-2-parse-month-references`. Print the MR URL for the user.
- User merges/deletes the branch in Gitea — never from the CLI.
After merge (small doc edits land straight on `main` per CLAUDE.md exception):
- Tick `M2.2` in the [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA.
- Add a one-line `CHANGELOG.md` entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
- Record any porting surprise (e.g. an unexpected diff between Go RE2 and Python `re`) in the tracker's "Notes & decisions" section.
Next task is **M2.3 `domain/fees.CalculateFee`** — straightforward constants table; no parser semantics to debate.

View File

@@ -1,3 +1,5 @@
module fuj-management/go
go 1.26.1
require golang.org/x/text v0.36.0

2
go/go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=

View File

@@ -0,0 +1,26 @@
package czech
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// Normalize strips diacritics and lowercases s.
//
// Matches Python: unicodedata.normalize("NFKD", s) then filter out
// combining characters (unicode.Mn only — not Mc/Me, which have
// combining class 0 in Python's unicodedata.combining()).
func Normalize(s string) string {
decomposed := norm.NFKD.String(s)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
if unicode.In(r, unicode.Mn) {
continue
}
b.WriteRune(r)
}
return strings.ToLower(b.String())
}

View File

@@ -0,0 +1,31 @@
package czech
import "testing"
func TestNormalize(t *testing.T) {
cases := []struct {
in string
want string
}{
{"Honza", "honza"},
{"žluťoučký", "zlutoucky"},
{"Příliš", "prilis"},
{"Dvořák", "dvorak"},
{"Růžena", "ruzena"},
{"Čeněk", "cenek"},
{"Kačer", "kacer"},
{"", ""},
{"prilis", "prilis"}, // idempotent
{"Jan Novák", "jan novak"}, // whitespace preserved
{"é", "e"}, // precomposed é (NFC)
{"é", "e"}, // decomposed e + combining acute
{"Ondřej Procházka", "ondrej prochazka"}, // realistic full name
}
for _, tc := range cases {
got := Normalize(tc.in)
if got != tc.want {
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -0,0 +1,154 @@
package czech
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
var czechMonths = map[string]int{
"leden": 1, "ledna": 1, "lednu": 1,
"unor": 2, "unora": 2, "unoru": 2,
"brezen": 3, "brezna": 3, "breznu": 3,
"duben": 4, "dubna": 4, "dubnu": 4,
"kveten": 5, "kvetna": 5, "kvetnu": 5,
"cerven": 6, "cervna": 6, "cervnu": 6,
"cervenec": 7, "cervnce": 7, "cervenci": 7,
"srpen": 8, "srpna": 8, "srpnu": 8,
"zari": 9,
"rijen": 10, "rijna": 10, "rijnu": 10,
"listopad": 11, "listopadu": 11,
"prosinec": 12, "prosince": 12, "prosinci": 12,
}
var (
numericRe *regexp.Regexp
dotRe *regexp.Regexp
rangeRe *regexp.Regexp
standRe *regexp.Regexp
)
func init() {
// Sort by descending length so longer alternatives win in RE2 leftmost-first
// matching (e.g. "cervenec" is tried before "cerven").
names := make([]string, 0, len(czechMonths))
for name := range czechMonths {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
if len(names[i]) != len(names[j]) {
return len(names[i]) > len(names[j])
}
return names[i] < names[j]
})
alt := strings.Join(names, "|")
numericRe = regexp.MustCompile(`([\d+]+)\s*/\s*(\d{2,4})`)
dotRe = regexp.MustCompile(`(\d{1,2})\s*\.\s*(\d{4})`)
rangeRe = regexp.MustCompile(`(` + alt + `)\s*-\s*(` + alt + `)`)
standRe = regexp.MustCompile(`\b(` + alt + `)\b`)
}
// ParseMonthReferences extracts YYYY-MM month references from Czech free text.
//
// defaultYear seeds two heuristics: standalone month names with m >= 10 are
// treated as defaultYear-1 (out-of-year backfill), and wrap-around ranges
// (e.g. listopad-leden) place months >= start_m in defaultYear-1.
//
// Returns a sorted, deduplicated slice of "YYYY-MM" strings.
func ParseMonthReferences(text string, defaultYear int) []string {
normalized := Normalize(text)
seen := map[string]struct{}{}
add := func(year, m int) {
if m >= 1 && m <= 12 {
seen[fmt.Sprintf("%04d-%02d", year, m)] = struct{}{}
}
}
// Pass 1: numeric months — "11+12/2025", "01/26", "1/2026"
for _, groups := range numericRe.FindAllStringSubmatch(normalized, -1) {
monthsPart, yearStr := groups[1], groups[2]
year, err := strconv.Atoi(yearStr)
if err != nil {
continue
}
if year < 100 {
year += 2000
}
for mStr := range strings.SplitSeq(monthsPart, "+") {
mStr = strings.TrimSpace(mStr)
if mStr == "" {
continue
}
allDigits := true
for _, c := range mStr {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if !allDigits {
continue
}
m, err := strconv.Atoi(mStr)
if err != nil {
continue
}
add(year, m)
}
}
// Pass 2: dot-separated month.year — "12.2025" (4-digit year only)
for _, groups := range dotRe.FindAllStringSubmatch(normalized, -1) {
m, _ := strconv.Atoi(groups[1])
year, _ := strconv.Atoi(groups[2])
add(year, m)
}
// Pass 3a: Czech month name ranges — "listopad-leden"
foundInRanges := map[string]struct{}{}
for _, groups := range rangeRe.FindAllStringSubmatch(normalized, -1) {
startName, endName := groups[1], groups[2]
foundInRanges[startName] = struct{}{}
foundInRanges[endName] = struct{}{}
startM := czechMonths[startName]
endM := czechMonths[endName]
wraps := startM > endM
m := startM
for range 12 {
year := defaultYear
if wraps && m >= startM {
year = defaultYear - 1
}
add(year, m)
if m == endM {
break
}
m = m%12 + 1
}
}
// Pass 3b: standalone Czech month names (not part of a range)
for _, groups := range standRe.FindAllStringSubmatch(normalized, -1) {
name := groups[1]
if _, inRange := foundInRanges[name]; inRange {
continue
}
m := czechMonths[name]
year := defaultYear
if m >= 10 {
year = defaultYear - 1
}
add(year, m)
}
result := make([]string, 0, len(seen))
for k := range seen {
result = append(result, k)
}
sort.Strings(result)
return result
}

View File

@@ -0,0 +1,244 @@
package czech
import (
"reflect"
"testing"
)
func TestParseMonthReferences(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-05:
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
tests := []struct {
name string
input string
defaultYear int
want []string
}{
{
name: "empty",
input: "",
defaultYear: 2026,
want: []string{},
},
{
name: "numeric plus-split two months full year",
input: "11+12/2025",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "numeric single month full year",
input: "1/2026",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "numeric 2-digit year",
input: "01/26",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "numeric plus-split with 2-digit year",
input: "11+12/25",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "numeric three months sorted",
input: "12+1+2/2026",
defaultYear: 2026,
want: []string{"2026-01", "2026-02", "2026-12"},
},
{
name: "dot pattern",
input: "12.2025",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "dot pattern requires 4-digit year",
input: "1.26",
defaultYear: 2026,
want: []string{},
},
{
name: "standalone month below m10 threshold",
input: "leden",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "standalone month m10 heuristic",
input: "prosinec",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "declension prosince",
input: "prosince",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "declension lednu",
input: "lednu",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "standalone m10 boundary (rijen = October)",
input: "rijen",
defaultYear: 2026,
want: []string{"2025-10"},
},
{
name: "standalone m9 just below boundary (zari = September)",
input: "zari",
defaultYear: 2026,
want: []string{"2026-09"},
},
{
name: "range wrap Nov-Jan",
input: "listopad-leden",
defaultYear: 2026,
want: []string{"2025-11", "2025-12", "2026-01"},
},
{
name: "range wrap starting at October",
input: "rijen-leden",
defaultYear: 2026,
want: []string{"2025-10", "2025-11", "2025-12", "2026-01"},
},
{
name: "range no wrap",
input: "unor-kveten",
defaultYear: 2026,
want: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
},
{
name: "degenerate range same month",
input: "leden-leden",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "range spanning m10 — heuristic does NOT fire for range members",
input: "unor-listopad",
defaultYear: 2026,
want: []string{"2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10", "2026-11"},
},
{
name: "longest-match alternation cervenec beats cerven",
input: "cervenec-srpen",
defaultYear: 2026,
want: []string{"2026-07", "2026-08"},
},
{
name: "range plus standalone — range excludes, dedup",
input: "listopad-leden, prosinec",
defaultYear: 2026,
want: []string{"2025-11", "2025-12", "2026-01"},
},
{
name: "two standalones no range",
input: "prosinec leden",
defaultYear: 2026,
want: []string{"2025-12", "2026-01"},
},
{
name: "numeric plus range mix",
input: "11+12/2025, leden-brezen",
defaultYear: 2026,
want: []string{"2025-11", "2025-12", "2026-01", "2026-02", "2026-03"},
},
{
name: "dedup across numeric and standalone passes",
input: "11+12/25 a listopad",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "no digits before slash — standalone fires instead",
input: "prosince/2025",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "range with trailing slash-year — numeric fails, range wins",
input: "listopad-prosinec/2025",
defaultYear: 2026,
want: []string{"2026-11", "2026-12"},
},
{
name: "dot pattern only — numeric matches but month out of 1-12 range",
input: "01.2026 / 02.2026",
defaultYear: 2026,
want: []string{"2026-01", "2026-02"},
},
{
name: "leading slash — numeric matches at second slash",
input: "/12/2025",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "uppercase input normalized",
input: "PROSINEC",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "diacritics stripped by Normalize",
input: "Žluťoučký prosinec",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "diacritics in range with spaces around dash",
input: "Únor - květen",
defaultYear: 2026,
want: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
},
{
name: "natural language mixed with numeric and standalone",
input: "platba 11/2025 a leden",
defaultYear: 2026,
want: []string{"2025-11", "2026-01"},
},
{
name: "English month name not recognized",
input: "December",
defaultYear: 2026,
want: []string{},
},
{
name: "duplicate input deduped",
input: "11+12/2025 11+12/2025",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "trailing year without separator ignored",
input: "leden 2026",
defaultYear: 2026,
want: []string{"2026-01"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := ParseMonthReferences(tc.input, tc.defaultYear)
if got == nil {
got = []string{}
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ParseMonthReferences(%q, %d)\n got %v\n want %v",
tc.input, tc.defaultYear, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,34 @@
// Package fees ports fee calculation from scripts/attendance.py.
package fees
const (
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
AdultFeeSingle = 200 // CZK for exactly 1 practice
)
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
// Months absent from this map fall back to AdultFeeDefault.
var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
"2026-01": 750, "2026-02": 750, "2026-03": 350,
"2026-04": 700, "2026-05": 700,
}
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
// the given monthKey (format "YYYY-MM").
//
// 0 practices → 0
// 1 practice → AdultFeeSingle (200)
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
func CalculateFee(attendanceCount int, monthKey string) int {
if attendanceCount == 0 {
return 0
}
if attendanceCount == 1 {
return AdultFeeSingle
}
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
return rate
}
return AdultFeeDefault
}

View File

@@ -0,0 +1,37 @@
package fees
import "testing"
func TestCalculateFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
tests := []struct {
name string
count int
month string
want int
}{
{"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0},
{"single practice", 1, "2026-05", 200},
{"single ignores monthKey", 1, "unknown", 200},
{"two practices configured month", 2, "2026-05", 700},
{"two practices reduced march", 2, "2026-03", 350},
{"two practices early season", 2, "2025-09", 750},
{"high count same as two", 5, "2026-05", 700},
{"unknown future month falls back", 2, "2027-01", 700},
{"empty month falls back", 2, "", 700},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := CalculateFee(tc.count, tc.month)
if got != tc.want {
t.Errorf("CalculateFee(%d, %q) = %d, want %d", tc.count, tc.month, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,37 @@
package fees
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
// Months absent from this map fall back to JuniorFeeDefault.
var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250,
"2026-03": 250,
}
// Expected is the result of a junior fee calculation.
// When Unknown is true the fee requires manual review (Python returns "?");
// in that case Value is meaningless — always check Unknown first.
type Expected struct {
Value int
Unknown bool
}
// CalculateJuniorFee returns the junior fee for attendanceCount practices in
// the given monthKey (format "YYYY-MM").
//
// 0 practices → Expected{Value: 0}
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
if attendanceCount == 0 {
return Expected{Value: 0}
}
if attendanceCount == 1 {
return Expected{Unknown: true}
}
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
return Expected{Value: rate}
}
return Expected{Value: JuniorFeeDefault}
}

View File

@@ -0,0 +1,37 @@
package fees
import "testing"
func TestCalculateJuniorFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
tests := []struct {
name string
count int
month string
want Expected
}{
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
{"zero empty month", 0, "", Expected{Value: 0}},
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
{"empty month falls back", 2, "", Expected{Value: 500}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := CalculateJuniorFee(tc.count, tc.month)
if got != tc.want {
t.Errorf("CalculateJuniorFee(%d, %q) = %+v, want %+v", tc.count, tc.month, got, tc.want)
}
})
}
}

View File

@@ -17,6 +17,15 @@ from czech_utils import normalize, parse_month_references
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
def canonical_member_key(name: str) -> str:
"""Diacritic-, case-, and whitespace-insensitive key for member-name matching.
Used to resolve `Person`-column values from the payments sheet to canonical
attendance-sheet names, tolerating cells like "Maria Maco" vs "Mária Maco".
"""
return re.sub(r"\s+", " ", normalize(name)).strip()
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
@@ -309,6 +318,12 @@ def reconcile(
member_tiers = {name: tier for name, tier, _ in members}
member_fees = {name: fees for name, _, fees in members}
# Map canonical key → first attendance-sheet name with that key, so a
# `Person` cell that drifts in diacritics/case/whitespace still resolves.
canonical_by_key: dict[str, str] = {}
for name in member_names:
canonical_by_key.setdefault(canonical_member_key(name), name)
# Initialize ledger
ledger: dict[str, dict[str, dict]] = {}
other_ledger: dict[str, list] = {}
@@ -386,8 +401,9 @@ def reconcile(
if is_other:
num_allocations = len(matched_members)
per_allocation = amount / num_allocations if num_allocations > 0 else 0
for member_name, confidence in matched_members:
if member_name in other_ledger:
for raw_member_name, confidence in matched_members:
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
if member_name is not None:
other_ledger[member_name].append({
"amount": per_allocation,
"date": tx["date"],
@@ -400,14 +416,20 @@ def reconcile(
member_share = amount / len(matched_members) if matched_members else 0
for member_name, confidence in matched_members:
if member_name not in ledger:
for raw_member_name, confidence in matched_members:
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
if member_name is None:
logger.warning(
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
member_name, tx.get("date", "?"), tx.get("message", "?"),
raw_member_name, tx.get("date", "?"), tx.get("message", "?"),
)
unmatched.append(tx)
continue
if member_name != raw_member_name:
logger.info(
"Person cell %r resolved to canonical member %r — consider fixing the sheet",
raw_member_name, member_name,
)
in_window = [(m, ledger[member_name][m]["expected"]) for m in matched_months if m in ledger[member_name]]
out_of_window = [m for m in matched_months if m not in ledger[member_name]]

View File

@@ -365,6 +365,19 @@
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;
@@ -680,6 +693,16 @@
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments
<a href="#" id="rawPaymentsToggle" class="raw-toggle" onclick="toggleRawPayments(event)">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display: none;">
<!-- Filled by JS -->
</div>
</div>
</div>
</div>
@@ -696,6 +719,7 @@
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
const monthLabels = {{ month_labels_json| safe }};
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
let currentMemberName = null;
function showMemberDetails(name) {
@@ -828,9 +852,49 @@
});
}
// Raw payments (debug) — hidden by default, reset toggle on each open
const rawList = document.getElementById('modalRawList');
const rawToggle = document.getElementById('rawPaymentsToggle');
rawList.style.display = 'none';
rawToggle.textContent = '[show]';
rawList.innerHTML = '';
const rawRows = rawPaymentsByPerson[name] || [];
if (rawRows.length === 0) {
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
} else {
rawRows.forEach(tx => {
const inferredNote = tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount != tx.amount
? ` <span style="color:#888;">(inferred: ${tx.inferred_amount})</span>`
: '';
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
const bankIdNote = tx.bank_id ? `<span style="color:#444;"> · bank_id: ${tx.bank_id}</span>` : '';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | purpose: ${tx.purpose || '—'}${manualNote}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK${inferredNote}</span>
<span class="tx-sender">${tx.sender || ''}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
<div class="tx-meta">${tx.person || ''}${bankIdNote}</div>
`;
rawList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
function toggleRawPayments(ev) {
ev.preventDefault();
const list = document.getElementById('modalRawList');
const link = document.getElementById('rawPaymentsToggle');
const hidden = list.style.display === 'none';
list.style.display = hidden ? 'block' : 'none';
link.textContent = hidden ? '[hide]' : '[show]';
}
function closeModal(id) {
if (id) {
document.getElementById(id).style.display = 'none';

View File

@@ -365,6 +365,19 @@
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;
@@ -661,6 +674,16 @@
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments
<a href="#" id="rawPaymentsToggle" class="raw-toggle" onclick="toggleRawPayments(event)">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display: none;">
<!-- Filled by JS -->
</div>
</div>
</div>
</div>
@@ -677,6 +700,7 @@
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
const monthLabels = {{ month_labels_json| safe }};
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
let currentMemberName = null;
function showMemberDetails(name) {
@@ -809,9 +833,49 @@
});
}
// Raw payments (debug) — hidden by default, reset toggle on each open
const rawList = document.getElementById('modalRawList');
const rawToggle = document.getElementById('rawPaymentsToggle');
rawList.style.display = 'none';
rawToggle.textContent = '[show]';
rawList.innerHTML = '';
const rawRows = rawPaymentsByPerson[name] || [];
if (rawRows.length === 0) {
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
} else {
rawRows.forEach(tx => {
const inferredNote = tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount != tx.amount
? ` <span style="color:#888;">(inferred: ${tx.inferred_amount})</span>`
: '';
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
const bankIdNote = tx.bank_id ? `<span style="color:#444;"> · bank_id: ${tx.bank_id}</span>` : '';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | purpose: ${tx.purpose || '—'}${manualNote}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK${inferredNote}</span>
<span class="tx-sender">${tx.sender || ''}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
<div class="tx-meta">${tx.person || ''}${bankIdNote}</div>
`;
rawList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
function toggleRawPayments(ev) {
ev.preventDefault();
const list = document.getElementById('modalRawList');
const link = document.getElementById('rawPaymentsToggle');
const hidden = list.style.display === 'none';
list.style.display = hidden ? 'block' : 'none';
link.textContent = hidden ? '[hide]' : '[show]';
}
function closeModal(id) {
if (id) {
document.getElementById(id).style.display = 'none';

View File

@@ -19,67 +19,6 @@ class TestWebApp(unittest.TestCase):
self.assertEqual(response.status_code, 200)
self.assertIn(b'url=/adults', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.get_members_with_fees')
@patch('app.fetch_exceptions', return_value={})
def test_fees_route(self, mock_exceptions, mock_get_members, mock_cache):
"""Test that /fees returns 200 and renders the dashboard"""
mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01']
)
response = self.client.get('/fees')
self.assertEqual(response.status_code, 200)
self.assertIn(b'FUJ Fees Dashboard', response.data)
self.assertIn(b'Test Member', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.get_junior_members_with_fees')
@patch('app.fetch_exceptions', return_value={})
def test_fees_juniors_route(self, mock_exceptions, mock_get_junior_members, mock_cache):
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
mock_get_junior_members.return_value = (
[
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
('Test Junior 2', 'J', {'2026-01': (500, 4, 1, 3)})
],
['2026-01']
)
response = self.client.get('/fees-juniors')
self.assertEqual(response.status_code, 200)
self.assertIn(b'FUJ Junior Fees Dashboard', response.data)
self.assertIn(b'Test Junior 1', response.data)
self.assertIn(b'? / 1 (J)', response.data)
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
@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_reconcile_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test that /reconcile returns 200 and shows matches"""
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
}]
response = self.client.get('/reconcile')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Payment Reconciliation', response.data)
self.assertIn(b'Test Member', response.data)
self.assertIn(b'OK', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
def test_payments_route(self, mock_fetch_sheet, mock_cache):
@@ -98,38 +37,6 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'Test Member', response.data)
self.assertIn(b'Direct Member Payment', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions')
@patch('app.get_junior_members_with_fees')
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions, mock_cache):
"""Test that /reconcile-juniors correctly computes balances for juniors."""
mock_get_junior.return_value = (
[
('Junior One', 'J', {'2026-01': (500, 4, 2, 2)}),
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
],
['2026-01']
)
mock_exceptions.return_value = {}
mock_transactions.return_value = [{
'date': '2026-01-15',
'amount': 500,
'person': 'Junior One',
'purpose': '2026-01',
'message': '',
'sender': 'Parent',
'inferred_amount': 500
}]
response = self.client.get('/reconcile-juniors')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Junior Payment Reconciliation', response.data)
self.assertIn(b'Junior One', response.data)
self.assertIn(b'Junior Two', response.data)
self.assertIn(b'OK', response.data)
self.assertIn(b'?', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})

View File

@@ -0,0 +1,69 @@
import unittest
from scripts.match_payments import canonical_member_key, reconcile
class TestCanonicalMemberKey(unittest.TestCase):
def test_diacritics_and_case_collapse(self):
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco")
self.assertEqual(canonical_member_key("maria maco"), "maria maco")
def test_whitespace_runs_collapse(self):
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco")
def test_unknown_name_passes_through_normalized(self):
# Two genuinely different names must not collide.
self.assertNotEqual(
canonical_member_key("Mária Maco"),
canonical_member_key("Marek Maco"),
)
class TestReconcileTolerantPersonMatching(unittest.TestCase):
def _members(self):
return [("Mária Maco", "A", {"2026-04": (750, 4)})]
def _tx(self, person):
return {
"date": "2026-04-15",
"amount": 750,
"person": person,
"purpose": "2026-04",
"inferred_amount": 750,
"sender": "Maco Family",
"message": "fee",
}
def test_person_without_diacritics_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {})
member = result["members"]["Mária Maco"]
self.assertEqual(member["months"]["2026-04"]["paid"], 750)
self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1)
self.assertEqual(result["unmatched"], [])
def test_person_with_extra_whitespace_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {})
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
self.assertEqual(result["unmatched"], [])
def test_person_lowercase_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {})
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
self.assertEqual(result["unmatched"], [])
def test_truly_unknown_person_still_unmatched(self):
result = reconcile(
self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {}
)
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0)
self.assertEqual(len(result["unmatched"]), 1)
if __name__ == "__main__":
unittest.main()