Compare commits
12 Commits
0.32
...
fa853780db
| Author | SHA1 | Date | |
|---|---|---|---|
| fa853780db | |||
| 0fc3b6dd9a | |||
| 57ec817044 | |||
| 6cf83a01e3 | |||
| 98f401c149 | |||
| 0a8017fffa | |||
| 6d971b61d4 | |||
| 3460f57c62 | |||
| 6ca35e2112 | |||
| 20ade6de3e | |||
| d9a61b338c | |||
| 91ac3b37cf |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -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.
|
||||
|
||||
43
CLAUDE.md
43
CLAUDE.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
83
docs/plans/2026-05-05-2144-branch-per-feature-workflow.md
Normal file
83
docs/plans/2026-05-05-2144-branch-per-feature-workflow.md
Normal 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`.
|
||||
154
docs/plans/2026-05-05-2204-go-rewrite-m2-1-czech-normalize.md
Normal file
154
docs/plans/2026-05-05-2204-go-rewrite-m2-1-czech-normalize.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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
2
go/go.sum
Normal 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=
|
||||
26
go/internal/domain/czech/normalize.go
Normal file
26
go/internal/domain/czech/normalize.go
Normal 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())
|
||||
}
|
||||
31
go/internal/domain/czech/normalize_test.go
Normal file
31
go/internal/domain/czech/normalize_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
154
go/internal/domain/czech/parse_month_references.go
Normal file
154
go/internal/domain/czech/parse_month_references.go
Normal 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
|
||||
}
|
||||
244
go/internal/domain/czech/parse_month_references_test.go
Normal file
244
go/internal/domain/czech/parse_month_references_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
34
go/internal/domain/fees/fees.go
Normal file
34
go/internal/domain/fees/fees.go
Normal 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
|
||||
}
|
||||
37
go/internal/domain/fees/fees_test.go
Normal file
37
go/internal/domain/fees/fees_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
37
go/internal/domain/fees/junior.go
Normal file
37
go/internal/domain/fees/junior.go
Normal 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}
|
||||
}
|
||||
37
go/internal/domain/fees/junior_test.go
Normal file
37
go/internal/domain/fees/junior_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user