Files
fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md
Jan Novak 7afd12d9a5
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
chore: tick M3.1–M3.6 in progress tracker + CHANGELOG entry
Merges SHA 57518a8 (PR #12).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 23:34:00 +02:00

11 KiB
Raw Blame History

Go Rewrite — Progress Tracker

Companion to 2026-05-03-2349-go-backend-rewrite.md.

Current milestone: M3 — Fixture capture + characterization framework Started: 2026-05-04 Last updated: 2026-05-06

How to use

  • Tick a checkbox when the task's PR/commit lands. Append the SHA in the same line: [x] **M1.1** ... — abc1234``.
  • One task = one focused commit or PR. If a task balloons, split it and add sub-tasks below the parent.
  • Note decisions, surprises, or blockers under "Notes & decisions" at the bottom — that's where future-you (or a contributor) will look first.
  • Don't reorder milestones. Within a milestone, tasks can be done in any order unless explicitly noted.

M1 — Skeleton + tooling

Goal: make web-go serves a hello page on :8080 in parallel with make web-py on :5001. Lint clean.

  • M1.1 Create go/ tree skeleton + go.mod initialized to latest stable Go
  • M1.2 Add cmd/fuj/main.go with subcommand dispatcher — stdlib flag + os.Args[1] switch
  • M1.3 Wire fuj server subcommand: net/http ServeMux on :8080, plaintext hello page
  • M1.4 Add Makefile targets: go-build, go-test, go-run, go-lint
  • M1.5 Rename existing make webmake web-py; added make web-go; kept make web as alias
  • M1.6 Add go/.golangci.yml (govet, staticcheck, errcheck, gofumpt, unused) + make go-lint clean
  • M1.7 Write go/build/Dockerfile (multi-stage golang:1.26alpine:3); parallel build-go job in Gitea CI
  • M1.8 Add internal/config package mirroring scripts/config.py (same env var names + defaults)
  • M1.9 Add internal/logging (slog, level from config) + middleware/timer.go (method/path/status/ms)
  • M1.10 Gate passed: make go-build, make go-lint, make go-test, curl :8080 all green; CHANGELOG entry added

Gate: make go-build succeeds, curl localhost:8080 returns hello page, make go-lint clean.


M2 — Pure-domain helpers (port leaf-first)

Goal: every pure function from the Python backend exists in Go with a parity test against captured fixtures (M3 produces fixtures in parallel — order is M2.1 → M3.1/M3.2 → M3.3+ alongside M2.2+).

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 normalize (NFKD + combining-mark strip + lowercase) — 20ade6d
  • M2.2 domain/czech.ParseMonthReferences — port parse_month_references (45 month declensions, range wrap, year inference) — 0a8017f
  • M2.3 domain/fees.CalculateFee — port attendance.py calculate_fee (constants table) — 0fc3b6d
  • 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 parse_czk_amount (Czech locale: comma decimal, dot/space thousand separators) — d24d205
  • M2.6 domain/synch.GenerateSyncID — port 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 (auto vs review confidence, common-surname filter) — e596f00
  • M2.8 domain/matching.InferTransactionDetails — port infer_transaction_details (composes name + month parsing) — e596f00
  • M2.9 domain/matching.FormatDate — port format_date (handles Google Sheets serial-day numbers since 1899-12-30) — e596f00
  • M2.10 domain/reconcile.Reconcile — port reconcile (three-phase allocation: greedy / proportional with float-remainder absorption / even-split fallback). The single most load-bearing function; budget extra time. — c53bf5a
  • M2.11 fuj fees subcommand wired up via domain/fees + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands — 56aa230
  • M2.12 fuj reconcile subcommand similarly stubbed — 56aa230

Gate: cd go && go test -tags=parity ./tests/parity/pure/... green for every fixture in tests/fixtures/pure/.


M3 — Fixture capture + characterization framework

Goal: deterministic, PII-free fixture corpus that drives parity tests. Runs in parallel with M2 (M3.1/M3.2 unblocks M2.1).

  • M3.1 scripts/capture_fixtures.py — pure-function output dumper. Reads inputs from stdin / argv, prints {"input":..., "output":...} JSON — 57518a8
  • M3.2 scripts/scrub_fixtures.py — replaces names with Member_<8hex> (deterministic per name); scrambles sender/account/VS/bank_id with stable bijection; preserves dates, amounts, exception keys — 57518a8
  • M3.3 Capture pure-fn fixtures for M2.1M2.9 (run helper + scrubber, commit to tests/fixtures/pure/<func>/<case>.json) — 57518a8
  • M3.4 Capture ~10 reconcile fixtures spanning every code path: greedy, proportional (float remainder), even-split, out-of-window credit, exception override, other: purpose, junior "?", multi-person comma-split, multi-month range, unmatched. Commit to tests/fixtures/reconcile/57518a8
  • M3.5 Hook fixtures into Tier-1 test runner with -tags=parity build constraint — 57518a8
  • M3.6 Document fixture-refresh workflow in tests/fixtures/README.md (what to do when sheet schema changes) — 57518a8

Gate: tests/fixtures/ populated (98 files); make go-parity green; make go-lint (parity tag) clean; raw tmp/*.json confirmed gitignored. Merged as 57518a8.


M4 — IO layer behind interfaces

Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narrow Go interface with both a real and a fake implementation.

  • M4.1 Design IO interfaces (SheetsClient, DriveClient, FioClient, FileCache) + in-memory fakes seeded from M3 fixtures
  • M4.2 internal/io/sheets — Google client (read + append + batchUpdate); integration test against a separate test sheet (NOT prod)
  • M4.3 internal/io/drive — Drive modifiedTime client + integration test
  • M4.4 internal/io/fio — API JSON impl (token-based); parses by hardcoded column0..column22 indices matching fio_utils.py
  • M4.5 internal/io/fio — transparent-page HTML scraper using golang.org/x/net/html token visitor; targets the second <table class="table">
  • M4.6 internal/io/cache — FileCache with modifiedTime gating + two TTL knobs + atomic writes (os.Rename)
  • M4.7 services/banksync.SyncToSheets + fuj sync subcommand
  • M4.8 services/banksync.InferPayments + fuj infer [--dry-run] subcommand

Gate: go test -tags=integration ./internal/io/... round-trips against test sheet; default-tag tests run on fakes.


M5 — JSON-only /api/... routes

Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.

  • M5.1 Hand-author Go structs for /api/adults, /api/juniors, /api/payments, /api/version with explicit json: tags matching Python keys; emit JSON Schemas via github.com/invopop/jsonschema to tests/fixtures/api-schema/
  • M5.2 Implement Go handlers for /api/* routes composing services/* results into the JSON structs
  • M5.3 Add Python /api/X shadow endpoints in app.py: jsonify(view_model_dict) — no transformation
  • M5.4 Build cmd/parity/main.go: hits both backends' /api/X, normalizes allowlist (render_time.total, build_meta), prints cmp.Diff. Add make parity target

Gate: For each route, make parity reports zero non-allowlisted diffs across the M3 fixture corpus.


M6 — Go-native HTML frontend

Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.

  • M6.1 Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; embed.FS for templates/ + static/
  • M6.2 /adults page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to /qr
  • M6.3 /juniors page: same structure + per-month J/A attendance breakdown + "?" sentinel rendering
  • M6.4 /payments page: grouped-by-person ledger view
  • M6.5 Modal JS module (static/js/member-detail.js): fetches /api/adults (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
  • M6.6 /qr, /sync-bank, /flush-cache, /version pages
  • M6.7 Wire embed.FS into handlers; verify single-binary deployment includes all assets

Gate: Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.


M7 — Parallel-running watch period

Goal: prove parity over real time before flipping the default.

  • M7.1 Add Go service to docker-compose.yml on different port (alongside Python container)
  • M7.2 Set up parity-nightly.yml Gitea workflow: boot both, replay fixed transaction script, fail on diff
  • M7.3 Run make parity daily for 714 days, log any diffs; investigate and fix root cause (don't just allowlist)
  • M7.4 Manual feature parity check: walk through every UI feature on both sides, sign off in Notes section

Gate: Zero non-allowlisted JSON diffs over 7 consecutive days, including a sync-bank execution + flush + attendance update; user sign-off on UI feature parity.


M8 — Cutover + Python retirement

Goal: Go is the one true backend.

  • M8.1 Update bookmarks, README, CLAUDE.md to point at Go (make web aliases to make web-go)
  • M8.2 Run Go-only for 2 weeks including a month-end settlement; keep Python container available but unrouted
  • M8.3 Manual reconciliation review: produce a balance report on python-final and on Go for the same period; sign off they match
  • M8.4 Tag final Python image as python-final in registry; remove Python service from docker-compose.yml
  • M8.5 Delete app.py, scripts/, Python Dockerfile, tests/, pyproject.toml, uv.lock
  • M8.6 Update CLAUDE.md to reflect Go-only state (commands, architecture, key modules); CHANGELOG entry

Gate: Two consecutive months of Go-only operation with end-of-month settlement complete; zero rollbacks.


Notes & decisions

(Add entries as you go. Format: YYYY-MM-DD — short note.)

  • 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via make web-py / make web-go.
  • 2026-05-04 — M1 complete. Dockerfile base changed from distroless/static:nonrootalpine:3 for debuggability (can tighten later). CLI dispatcher uses stdlib flag; module path fuj-management/go. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.