Compare commits

..

51 Commits

Author SHA1 Message Date
7f87e63b7c chore: tick M6.3 in progress tracker — SHA 9564103
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:19:13 +02:00
95641036cc Merge pull request 'feat(go): M6.3 — Go-native /juniors page' (#29) from feat/go-m6-3-juniors-page into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #29
2026-05-08 10:18:40 +00:00
505d635c66 chore: CHANGELOG entry for M6.3 Go juniors page
Some checks failed
Deploy to K8s / deploy (push) Failing after 11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:26:08 +02:00
9f0e4b0ac3 feat(go): M6.3 — juniors page (table, filters, credits/debts, Pay buttons)
- Extract AssembleJuniors(ctx) from ServeJuniors JSON handler so HTML
  and JSON share the same view-model path (mirrors AssembleAdults pattern)
- Add JuniorsPageData wrapper in render.go
- Wire HTMLHandler.ServeJuniors to AssembleJuniors + render template
- Replace 4-line placeholder juniors.tmpl with full template:
  member table, name filter, month-range filter, totals row,
  Credits + Debts sections, Pay / Pay All buttons via /qr links
  (no Unmatched section — matches Python juniors.html parity)
- J/A attendance breakdown ("3/500 CZK (4:2J,1A)") and "?" sentinel
  rendered via MonthCell.Text from buildJuniorMemberRow, no extra
  template logic needed
- All tests pass; make parity reports 3/3 routes OK

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:25:50 +02:00
2cbd98df1a Merge pull request 'fix: restore Sep+Oct adult merge and stop auto-truncating period selector' (#28) from fix/period-selector-restore into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #28
2026-05-08 09:13:14 +00:00
e618e906ef fix: restore Sep+Oct adult merge and stop auto-truncating period selector
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Two regressions made older periods invisible on the adults dashboard:

- 1257f0d (Mar 9) commented out ADULT_MERGED_MONTHS, removing the
  Sep+Oct 2025 merged label. Restored only the 2025-09 → 2025-10
  mapping (Dec and Jan are billed separately for adults; the
  Dec → Jan mapping stays disabled per product decision). Mirrored
  on the Go side. Test fixtures in sources_test.go now assert Sep
  dates land in merged 2025-10 instead of 2025-09.

- 7774301 (Apr 9) added a JS onload default that set the From
  selector to maxMonthIdx − 4 and immediately filtered the table,
  hiding everything older than 5 months on first load. Dropped that
  default in templates/adults.html and templates/juniors.html so
  the From-selector starts at the oldest available month. Future
  months are still removed from the dropdowns and hidden in the
  table — only the past-month truncation is gone.

Note: the live adults attendance sheet had also been pruned to
start at 02.12.2025; restoring Sep/Oct/Nov 2025 columns from
Sheets version history is required to actually see those periods.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 11:12:26 +02:00
96e574e6c7 chore: tick M6.2 in progress tracker — SHA c85748b
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:38:28 +02:00
6a8aa37198 Merge pull request 'feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)' (#27) from feat/go-m6-2-adults-page into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #27
2026-05-08 08:35:20 +00:00
aa0c17f521 fix(go): align adults cell class names with Python; un-underline Pay buttons
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
- Map unpaid|partial → cell-unpaid (or cell-unpaid-current for current
  month) and surplus → cell-overridden, matching Python's Jinja logic;
  avoids emitting non-existent cell-partial/cell-surplus classes that
  caused Pay buttons to escape the table.
- Add text-decoration: none to .pay-btn so anchor-based Pay links don't
  show the default underline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:33:30 +02:00
464eeeb2b1 fix(go): wrap adults table in <div class="table-container">
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
The lifted CSS defines .table-container with border: 1px solid #333 and
max-width: 1200px — without the wrapper the table stretched to full
viewport width and showed no border. Mirrors templates/adults.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:20:49 +02:00
daac5d7392 fix(go): adults template — emit markup that the lifted CSS expects
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
The lifted-from-Python app.css already styles .list-container/.list-item,
.unmatched-row, .balance-pos/.balance-neg, and cell-{status}; the M6.2
template invented .credits-list / .unmatched-table / .balance-cell that
had no rules, so those sections rendered unstyled.

- Credits / Debts: <ul><li> → <div class="list-container"><div class="list-item">
  <span class="list-item-name"> + <span class="list-item-val"> (debts red inline).
- Unmatched: <table> → <div class="list-container"> + <div class="unmatched-row">.
- Balance cell: balance-pos / balance-neg with style="position: relative;";
  Pay-All button now lives inside it (no separate trailing column).
- Total row: cell-{status} + caption span "received / expected" + bold/dark inline styles.
- Drop redundant .cell wrapper class; balance value drops trailing "CZK".
- Section headings: "Credits (Advance Payments / Surplus)" + "Debts (Missing Payments)".
- Source links: <div class="description"> block under h1 (was at page bottom).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:17:03 +02:00
c85748b3aa feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
- Extract AssembleAdults(ctx) from ServeAdults so HTML and JSON API share one reconcile path.
- HTMLHandler gains *api.Handler; ServeAdults loads real data and renders adults.tmpl.
- AdultsPageData view model + qrHref/qrHrefAll funcMap (URL-encode /qr params, YYYY-MM→MM/YYYY).
- adults.tmpl: full reconcile table, per-cell status classes + cell-unpaid-current, Pay button hrefs,
  totals row, credits/debts/unmatched sections, filter controls, sheet links.
- static/js/filters.js: NFD-normalize name filter + month-range column hiding; future months hidden by default.
- TestAdultsPage asserts member name and cell text against fixture data.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 01:09:47 +02:00
216b5b437a Merge pull request 'feat(go): M6.1 — template skeleton, embed.FS, HTML routes' (#26) from feat/go-m6-1-template-skeleton into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #26
2026-05-07 22:48:22 +00:00
78e5059759 feat(go): M6.1 — template skeleton, embed.FS, HTML routes
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Stand up the Go-native HTML frontend foundation:
- base.tmpl layout + nav/footer partials (three-tier nav, active-link highlighting)
- terminal-green-on-black theme extracted to static/css/app.css (served via embed.FS)
- HTMLHandler with stub pages for all five routes; / redirects to /adults
- NewRenderer parses per-page template sets at startup so parse failures abort boot
- Smoke test: each route returns 200 text/html with exactly one class="active" link

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 00:45:22 +02:00
2f635db2b4 chore: CHANGELOG for M5.4 parity coercions (PR #25)
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:27:34 +02:00
709a2f2335 Merge pull request 'fix(py): parity coercions — amount/message types + junior '?' sticky' (#25) from fix/py-parity-coercions into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Reviewed-on: #25
2026-05-07 22:26:39 +00:00
58973473c9 fix(py): make junior '?' cell text sticky across exception overrides
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Go's build_juniors sets cellText = "?" + countStr whenever
md.IsUnknown is true, regardless of whether an exception overrides the
expected amount. Python was checking expected == "?" for this branch,
but reconcile replaces expected with the exception amount (e.g. 0)
before the view builder runs, so the "?" was silently dropped to "-".

Fix: derive is_unknown from original_expected == "?" (set before
exception substitution) instead of expected == "?". Also align the
tooltip guard: Go only shows Received/Expected tooltip for non-unknown
months (or when paid > 0), matching the same is_unknown flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:26:04 +02:00
b68d95d217 fix(py): coerce amount to float and message to string in tx projection
Two remaining make parity diffs vs Go:

- amount: non-numeric sheet values like "---" passed through as strings;
  Go's parseFloat silently returns 0.0 for unparseable values. Add
  get_float helper that matches that behaviour.
- message: numeric cell values (e.g. a bank reference in the message
  column) passed through as float64; Go's getVal uses fmt.Sprint and
  always emits a string. Apply get_str to the message field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 00:26:04 +02:00
07ca1cd9e1 fix(py): coerce VS column to string in payments tx projection
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
The Sheets API returns VS (variabilní symbol) cells as float64 when
the column is number-formatted, so Python was emitting vs: 0 (a JSON
number) while Go's getVal uses fmt.Sprint and always emits vs: "0"
(a string). Add get_str helper that converts whole-number floats via
int() first (matching Go's %g formatting), applied to the vs field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:59:35 +02:00
5dcac25c13 chore: CHANGELOG entry for M5.4 fix #2 (py vs/sync_id)
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:52:04 +02:00
fc47606b1c Merge pull request 'feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection' (#23) from fix/py-payments-add-vs-syncid into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #23
2026-05-07 21:51:12 +00:00
65694ad378 feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Python's fetch_sheet_data read 9 sheet columns but skipped VS and
Sync ID, causing make parity to report extra fields on every raw
payment row emitted by the Go backend. Both columns are already on
the sheet; add idx_vs / idx_sync_id lookups and the matching keys
to the tx dict so the Python /api/* wire shape matches Go's
RawTransaction.

Update /api/* test fixtures to include vs/sync_id keys for realism.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 23:50:33 +02:00
092dff25a5 Merge pull request 'fix(go): accept single-digit day/month in attendance date headers' (#22) from fix/go-attendance-date-parser into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #22
2026-05-07 21:39:02 +00:00
56c21bcf03 fix(go): accept single-digit day/month in attendance date headers
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
parseDates was using "02.01.2006" / "01/02/2006" which require
zero-padded fields. The Czech attendance sheet headers contain dates
like "1.6.2026", "23.3.2026", "6.4.2026" — Go silently dropped those
columns while Python's strptime accepted them. Effect was a missing
2026-06 month on /api/juniors plus undercounted attendance in any month
with single-digit columns; surfaced via make parity.

Use the unpadded reference forms "2.1.2006" / "1/2/2006" instead — Go's
time.Parse accepts both padded and unpadded inputs against them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:38:06 +02:00
208f762c18 Merge pull request 'feat(go): M5.4 — parity diff binary + make parity' (#19) from feat/go-m5-4-parity-binary into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #19
2026-05-07 21:25:23 +00:00
4d035213b5 Merge pull request 'fix(go): pass raw value to FormatDate so numeric dates format' (#21) from fix/go-date-format into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #21
2026-05-07 21:24:42 +00:00
2b15280d03 fix(go): exclude /api/version from parity diff — identity, not contract
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
/api/version returns each binary's own tag/commit/build_date, which
differs by design between independently built backends. Diffing it
always produces a false positive. Drop it from allRoutes; the route
remains reachable via `make parity ARGS="-route /api/version"`.

Also remove the vestigial `build_meta` allowlist entry (Python returns
the build dict as the top-level response body, not nested under
build_meta, so the scrubber never matched anything).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:23:38 +02:00
723152cdad fix(go): pass raw value to FormatDate so numeric serial-day dates format
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
The transaction-row parser in services/membership/sources.go used a
helper (`getVal`) that did `fmt.Sprint(row[i])` before passing to
`matching.FormatDate`.  The Sheets API returns date-formatted cells
as `float64` (Sheets serial-day numbers); pre-stringifying defeated
`FormatDate`'s `case float64:` dispatch, so values like 46147 leaked
through unchanged as the string "46147" instead of being converted
to "2026-05-05".

Surfaced by `make parity` (M5.4) — every `transactions[].date` on
/api/adults and /api/juniors differed between Python and Go.  Python
side passes the raw value through directly (`isinstance(val, (int,
float))` in scripts/match_payments.py format_date), so it was always
correct.

Added a `getRaw` helper that returns row[i] without stringifying;
only the date column needs it.  Extended TestLoadTransactions with
a numeric-serial-day row to lock in the regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:17:45 +02:00
fe0e49a134 feat(go): M5.4 — parity diff binary + make parity
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds cmd/parity/main.go: a standalone Go binary that GETs
/api/version, /api/adults, /api/juniors, /api/payments from both
the Python (:5001) and Go (:8080) backends, scrubs an allowlist
(render_time.total, build_meta), and prints cmp.Diff for any
remaining differences.  Exits 0 on full match, 1 on diffs, 2 on
fetch/parse errors — CI-friendly for M7.2.

- go/cmd/parity/main.go: flags (-py, -go, -route, -timeout), fetch
  helper, allowlist scrubber (dotted-path aware), exit-code logic.
- go/cmd/parity/scrub_test.go: 4 unit tests for the scrubber.
- go/go.mod: promote github.com/google/go-cmp to direct dep.
- Makefile: parity target + help entry.
- Progress tracker: M5.4 ticked; milestone updated to M5 complete.
- Plan archived to docs/plans/2026-05-07-2254-m5-4-parity-binary.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:08:42 +02:00
e5a272b682 Merge pull request 'fix(go): default CacheDir to tmp/go to avoid Python collision' (#20) from fix/cache-collision into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #20
2026-05-07 21:07:18 +00:00
8b3064ffab fix(go): default CacheDir to tmp/go to avoid Python collision
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Previously both backends defaulted to `CacheDir=tmp` and used the
same cache keys (`attendance_regular`, `attendance_juniors`,
`payments_transactions`, `exceptions_dict`) but stored different
shapes: Python caches post-processed view-model tuples
(e.g. `(members, sorted_months)`), Go caches raw sheet rows.
Whichever backend wrote last poisoned the cache for the other,
producing `ValueError: too many values to unpack (expected 2,
got 68)` on Python's /adults after the Go side populated the
file with 68 raw CSV rows.

This breaks the M5.4 `make parity` workflow that requires both
backends running side-by-side.

Fix: change Go's default to `tmp/go` so the two cache trees
never overlap.  `CACHE_DIR` env var override still works.
`os.MkdirAll` already handles creating the new subdirectory on
first write.

Recovery for users with poisoned `tmp/`: hit /flush-cache on
the Python side once after pulling, then restart the Go server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:06:34 +02:00
423c3e2a4b Merge pull request 'feat(py): M5.3 — Python /api/* shadow endpoints' (#18) from feat/go-m5-3-python-api-shadow into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #18
2026-05-07 20:42:54 +00:00
f4c497681f chore: CHANGELOG and progress tracker for M5.3
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:37:52 +02:00
40e4a9e45e feat(py): M5.3 — add Python /api/* shadow endpoints
Four new JSON routes mirror the Go /api/* handlers so the M5.4 parity
tool can diff them: /api/version, /api/adults, /api/juniors,
/api/payments. A small _unwrap_view_model_for_api() helper in app.py
expands the three pre-serialised JSON strings in the view-model dicts
and renames month_labels_json → month_labels and
raw_payments_json → raw_payments to match the Go wire contract.

Tests in test_app.py assert top-level key sets match the Go API schema
and that member_data, month_labels, raw_payments are objects not strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:37:14 +02:00
68810369bd Merge pull request 'feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version' (#17) from feat/go-m5-2-api-handlers into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #17
2026-05-07 19:08:01 +00:00
2b7eff14c4 chore: CHANGELOG and progress tracker for M5.2
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:02:54 +02:00
7d48e8f607 feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
- Add web/api/handler.go: Handler struct wiring Sources+Config into ServeAdults,
  ServeJuniors, ServePayments, ServeVersion
- Add web/api/build_common.go: getMonthLabels, groupRawPaymentsByPerson, settledBalance,
  domain-to-wire converters, ensureSlice generic helper
- Add web/api/build_adults.go: buildAdultsResponse + buildAdultMemberRow mirroring
  scripts/views.py:build_adults_view_model
- Add web/api/build_juniors.go: buildJuniorsResponse + buildJuniorMemberRow mirroring
  scripts/views.py:build_juniors_view_model, including "?" sentinel and :NJ,MA breakdown
- Add web/api/build_payments.go: buildPaymentsResponse with Unmatched/Unknown bucket
- Extend reconcile.FeeData/MonthData with IsUnknown, JuniorAttendance, AdultAttendance
- Extend reconcile.Transaction with ManualFix, VS, BankID, SyncID for raw_payments wire field
- Export membership.AdultMergedMonths and JuniorMergedMonths
- Update sources.go to propagate new FeeData fields and parse extra transaction columns
- Wire sources+cfg into web.Run; register /api/* routes via Go 1.22 method+path patterns
- Fix pre-existing gofumpt formatting in fio_test.go and fio_table.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:13:38 +02:00
be4ecef20f Merge pull request 'feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas' (#16) from feat/go-m5-1-api-structs into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #16
2026-05-07 17:50:55 +00:00
da5b82fcdb chore: CHANGELOG and progress tracker for M5.1
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:38:00 +02:00
f253e3fcb1 feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Add internal/web/api package with Go structs for every /api/X route:
AdultsResponse, JuniorsResponse, PaymentsResponse, VersionResponse.
All fields carry explicit json: tags matching the Python view-model keys.

Key design choices:
- member_data / month_labels / raw_payments are nested objects (not
  the pre-serialised JSON strings used in Jinja templates)
- Expected{Value int; Unknown bool} with custom MarshalJSON emits int
  or the string "?" for junior single-attendance months
- RawTransaction covers the full 11-column payments sheet row

schemagen_test.go reflects all four response types via
github.com/invopop/jsonschema and golden-compares against committed
schemas in tests/fixtures/api-schema/. The JSONSchema() method on
Expected lives in the test file so the prod binary has no jsonschema
dependency.

Closes M5.1 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:36:46 +02:00
59223c0da4 chore: CHANGELOG for Python view-model extraction
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:27:17 +02:00
32a16ff50d Merge pull request 'refactor(app): extract view-model builders into scripts/views.py' (#15) from feat/m5-python-views-extraction into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 5s
Reviewed-on: #15
2026-05-07 13:26:42 +00:00
2eec51bb34 fix(app): restore missing import re needed by qr_code route
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Accidentally removed when moving group_payments_by_person to views.py;
re.match in qr_code caused a 500 on every QR request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:24:40 +02:00
b562ce3201 refactor(app): extract view-model builders into scripts/views.py
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Pull 350+ lines of inline per-row computation out of adults_view,
juniors_view, and payments into three pure builder functions with no
Flask globals or IO dependencies. Route handlers now contain only
cache/IO calls and a single render_template. No behaviour change —
all 27 tests pass.

Also moves get_month_labels, group_payments_by_person, and
adapt_junior_members out of app.py. Prep for /api/* shadow endpoints
(M5 Go parity).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:22:12 +02:00
f0de300292 chore: CHANGELOG for --print-fio-table, debug logging, and date parser fix
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:13:56 +02:00
2164e99866 Merge pull request 'feat(go): add --print-fio-table debug flag to fuj sync' (#14) from feat/fuj-sync-print-fio-table into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #14
2026-05-07 12:13:19 +00:00
b41b8ef29c fix(go/fio): accept 2-digit year format in transparent date parser
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Fio's transparent account page now serves dates as DD.MM.YY (e.g.
07.05.26) rather than the previously expected 4-digit-year format.
Extends parseCzechDate to try all eight layout variants: padded and
non-padded, dot and slash separators, 4-digit and 2-digit years.

Go maps 2-digit year 00-68 → 2000-2068, so 26 → 2026.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:12:34 +02:00
80db33945d chore: add make go-sync-debug target
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Wraps `LOG_LEVEL=DEBUG ./bin/fuj sync -dry-run -print-fio-table -days N`
behind a single make target. Default DAYS=30, override with
`make go-sync-debug DAYS=90`. Builds the Go binary first via go-build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 14:01:46 +02:00
f87adeff9f feat(go/fio): debug logging via slog at LOG_LEVEL=DEBUG
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Wires slog.SetDefault to honour LOG_LEVEL in all CLI commands and adds
debug logs on the Fio fetch path so a silent "fetched 0 transaction(s)"
can be diagnosed without code changes:

- fio.New: which client variant (api/transparent) was selected
- apiClient: GET URL (token redacted as ****), HTTP status, body bytes,
  parsed transaction count
- transparentClient: GET URL, HTTP status, body bytes, plus parser
  stats (raw rows from second table, kept, dropped_bad_date,
  dropped_nonpositive_amount)

Also suppresses the --print-fio-table block when zero transactions were
fetched, so the bare header no longer prints under that condition.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:59:22 +02:00
a7cf45fc95 feat(go): add --print-fio-table flag to fuj sync --dry-run
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Prints an aligned tabwriter table of every Fio transaction in the
look-back window, with a STATUS column showing NEW (would be appended)
or DUP (already in sheet). Only fires when --dry-run is also set, so
it can't affect real syncs. Refactors Sync ID computation into a single
pre-pass shared by both the table printer and the row builder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 13:49:42 +02:00
f0a0f79475 Merge pull request 'feat(go): IO layer behind interfaces (M4)' (#13) from feat/m4-io-layer into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #13
2026-05-07 08:48:54 +00:00
65 changed files with 5747 additions and 469 deletions

View File

@@ -1,5 +1,118 @@
# Changelog # Changelog
## 2026-05-08 11:25 CEST — feat(go): M6.3 — Go-native /juniors page
- Extracted `AssembleJuniors(ctx)` from the JSON handler so HTML and JSON share one view-model path (mirrors `AssembleAdults`).
- Added `JuniorsPageData` to `render.go`; wired `HTMLHandler.ServeJuniors` to call `AssembleJuniors` and render the new template.
- Replaced the 4-line "Coming in M6.3" placeholder in `juniors.tmpl` with the full template: member table, name/month-range filters, totals row, Credits + Debts sections, Pay / Pay All links to `/qr`. No Unmatched section (parity with Python).
- J/A attendance breakdown and `"?"` sentinel rendered via `MonthCell.Text` produced by `buildJuniorMemberRow` — no extra template logic.
- `make parity` reports 3/3 routes OK; all Go tests pass.
## 2026-05-08 11:11 CEST — fix: period selector showed only Dec 2025+ on adults
- Restored the `2025-09 → 2025-10` adult merge in `scripts/attendance.py` and `go/internal/services/membership/sources.go` (commented-out by `1257f0d`); the `2025-12 → 2026-01` mapping stays disabled per product decision (Dec and Jan are billed separately for adults).
- Dropped the `defaultFrom = maxMonthIdx 4` JS auto-default in `templates/adults.html` and `templates/juniors.html` (introduced by `7774301`); the From-selector now starts at the oldest available month so all non-future periods render on first load. Future-month removal is preserved.
- `go/internal/services/membership/sources_test.go`: `TestLoadAdults` / `TestLoadAdults_Fee` now assert that Sep dates land in the merged `2025-10` bucket.
- **Independent of these code changes**: the live adults attendance Google Sheet header had been pruned to start at `02.12.2025` (Sep/Oct/Nov 2025 columns deleted); restoring those columns from Sheets version history is required to actually see those periods on the dashboard.
## 2026-05-08 10:15 CEST — fix(go): adults template — use lifted CSS classes for visual parity
- Use existing `.balance-pos` / `.balance-neg` (drop invented `balance-cell` / `balance-negative`); Pay-All button now lives inside the balance cell with `position: relative` (matches Python; no separate trailing column).
- Credits / Debts / Unmatched sections rewritten from `<ul>` / `<table>` to `<div class="list-container"><div class="list-item">…` / `<div class="unmatched-row">…` so the lifted CSS actually applies.
- Section headings get the descriptive Python text: "Credits (Advance Payments / Surplus)", "Debts (Missing Payments)".
- Source links moved from page bottom to a `<div class="description">` block under the h1, matching Python's "Source: Attendance Sheet | Payments Ledger".
- Total row uses `cell-{{Status}}` plus the small "received / expected" caption span and Python's inline-styled bold/dark background.
- Drop the redundant `class="cell"` wrapper; debts amount turns red via inline style; balance value drops the trailing "CZK".
## 2026-05-08 01:09 CEST — feat(go): M6.2 — adults page (table, filters, Pay buttons)
- `go/internal/web/api/handler.go`: extracted `ServeAdults` body into `AssembleAdults(ctx)` — shared by the JSON API route and the new HTML handler.
- `go/internal/web/render.go`: added `AdultsPageData` view model (`PageData` + `api.AdultsResponse` + `Error`); `tmplFuncs` with `qrHref` / `qrHrefAll` (URL-encode QR Platba params, convert YYYY-MM → MM/YYYY).
- `go/internal/web/html_handler.go`: `HTMLHandler` gains `*api.Handler`; `ServeAdults` loads real reconcile data and renders the full adults page.
- `go/internal/web/templates/adults.tmpl`: full table (per-member rows, per-cell status classes, `data-month-idx`, Pay button hrefs to `/qr`), totals row, credits/debts/unmatched sections, filter controls, sheet links.
- `go/internal/web/static/js/filters.js`: name filter (NFD-normalize) + month-range hide/show by `data-month-idx`; future months hidden by default.
## 2026-05-08 00:44 CEST — feat(go): M6.1 — template skeleton + embed.FS
- `go/internal/web/templates/`: `base.tmpl` (full HTML layout), `partials/nav.tmpl` (three-tier nav with active-link highlighting), `partials/footer.tmpl` (build meta), and stub pages for each route (adults/juniors/payments/sync/flush_cache).
- `go/internal/web/static/css/app.css`: terminal-green-on-black theme extracted once from Python `templates/adults.html` — shared by all Go HTML pages via `<link>`.
- `go/internal/web/assets.go`: `//go:embed templates static` for single-binary deployment.
- `go/internal/web/render.go`: `Renderer` parses a fresh `*template.Template` per page at startup; `Render(w, name, data)` executes the "base" template block.
- `go/internal/web/html_handler.go`: `HTMLHandler` with one method per route (`ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeSync`, `ServeFlushCache`).
- `go/internal/web/server.go`: drops `helloHandler`; `GET /{$}` now redirects to `/adults`; HTML + `/static/` routes registered alongside the existing `/api/*` routes.
- `go/internal/web/html_handler_test.go`: smoke test — each route returns 200 `text/html` with exactly one `class="active"` on the matching nav link.
## 2026-05-08 00:26 CEST — fix(py): parity coercions — amount/message types + junior '?' sticky
- `scripts/match_payments.py`: added `get_float` helper — non-numeric `amount` values (e.g. `"---"` placeholder rows) now coerce to `0.0` matching Go's `parseFloat` behaviour; `message` field now goes through `get_str` so numeric cell values (bank references) are emitted as strings, matching Go's `fmt.Sprint`.
- `scripts/views.py`: junior month cell `"?"` text is now sticky across exception overrides. Previously `reconcile` replaced `expected` with the exception amount before the view builder ran, silently turning `"?"` into `"-"` when the override was 0. Fixed by deriving `is_unknown` from `original_expected == "?"` instead of `expected == "?"`. Also aligned tooltip guard: only show Received/Expected for non-unknown months (or when paid > 0), matching Go's `!md.IsUnknown` condition.
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person``_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
## 2026-05-07 22:55 CEST — feat(go): M5.4 — parity diff binary + `make parity`
- `go/cmd/parity/main.go`: new standalone binary that GETs `/api/adults`, `/api/juniors`, `/api/payments` from both Python (:5001) and Go (:8080) backends, scrubs an allowlist (`render_time.total`), and prints `cmp.Diff` for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. `/api/version` is excluded by design (returns binary identity — tag/commit/build_date — which differs between independently built backends); still accessible via `make parity ARGS="-route /api/version"`.
- `go/cmd/parity/scrub_test.go`: 4 unit tests covering top-level delete, nested delete, missing path, and non-map parent.
- `go/go.mod`: `github.com/google/go-cmp` promoted to direct dependency.
- `Makefile`: `parity` target added (`.PHONY`, help, `cd go && go run ./cmd/parity`).
- `docs/plans/2026-05-07-2254-m5-4-parity-binary.md`: plan archived.
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json``month_labels`, `raw_payments_json``raw_payments` to match Go wire contract.
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip.
- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`.
- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns.
- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes.
- PR #17.
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
- `Expected{Value int; Unknown bool}` custom `MarshalJSON` emits integer or `"?"` for junior single-attendance months.
- `schemagen_test.go` golden-tests four JSON Schemas committed to `go/tests/fixtures/api-schema/`. `JSONSchema()` on `Expected` lives in the test file — production binary has no jsonschema dep.
- PR #16.
## 2026-05-07 15:26 CEST — refactor(app): extract view-model builders into scripts/views.py
- Pulled ~350 lines of inline per-row computation out of `adults_view`, `juniors_view`, and `payments` into three pure functions in `scripts/views.py`: `build_adults_view_model`, `build_juniors_view_model`, `build_payments_view_model`.
- Moved `get_month_labels`, `group_payments_by_person`, `adapt_junior_members` from `app.py` to `scripts/views.py`. Route handlers now ~25 lines each.
- Hotfixed missing `import re` that caused 500 on `/qr` after the refactor.
- No behaviour change; all 27 tests pass. Prep for `/api/*` shadow endpoints (M5).
## 2026-05-07 14:13 CEST — feat(go): --print-fio-table + Fio debug logging + date parser fix
- Added `--print-fio-table` flag to `fuj sync --dry-run`: prints an aligned table of every Fio transaction in the window with `STATUS=NEW/DUP`, using `text/tabwriter`. Key files: `go/internal/services/banksync/fio_table.go`, `sync.go`, `cmd/fuj/main.go`.
- Added `LOG_LEVEL=DEBUG` debug logging on the Fio fetch path: client variant selected, full GET URL (token redacted on API path), HTTP status, body bytes, and per-parse drop-reason counters (`raw_rows`, `kept`, `dropped_bad_date`, `dropped_nonpositive_amount`). Key files: `go/internal/io/fio/{client,api,transparent}.go`.
- Fixed `parseCzechDate` to accept `DD.MM.YY` (2-digit year) in addition to the 4-digit variant — Fio's transparent page now serves this format. Key file: `go/internal/io/fio/transparent.go`.
- Added `make go-sync-debug [DAYS=N]` Makefile target (default 30 days).
## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync ## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync
- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`. - `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`.

View File

@@ -1,4 +1,4 @@
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-lint capture-fixtures image run sync sync-2026 test test-v docs .PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
export PYTHONPATH := scripts:$(PYTHONPATH) export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv VENV := .venv
@@ -27,7 +27,9 @@ help:
@echo " make go-parity - Run Go parity tests (requires -tags=parity fixture corpus)" @echo " make go-parity - Run Go parity tests (requires -tags=parity fixture corpus)"
@echo " make go-test-all - Run both unit and parity tests" @echo " make go-test-all - Run both unit and parity tests"
@echo " make go-lint - Run golangci-lint on Go code" @echo " make go-lint - Run golangci-lint on Go code"
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python" @echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
@echo " make image - Build Python OCI container image" @echo " make image - Build Python OCI container image"
@echo " make run - Run the built Python Docker image locally" @echo " make run - Run the built Python Docker image locally"
@echo " make sync - Sync Fio transactions to Google Sheets" @echo " make sync - Sync Fio transactions to Google Sheets"
@@ -91,12 +93,19 @@ capture-fixtures: $(PYTHON)
go-run: go-build go-run: go-build
./$(GO_BIN) $(ARGS) ./$(GO_BIN) $(ARGS)
DAYS ?= 30
go-sync-debug: go-build
LOG_LEVEL=DEBUG ./$(GO_BIN) sync -dry-run -print-fio-table -days $(DAYS)
go-lint: go-lint:
cd $(GO_SRC) && golangci-lint run ./... cd $(GO_SRC) && golangci-lint run ./...
web-go: go-build web-go: go-build
./$(GO_BIN) server ./$(GO_BIN) server
parity:
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
image: image:
docker build -t fuj-management:latest \ docker build -t fuj-management:latest \
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \ --build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \

505
app.py
View File

@@ -7,7 +7,7 @@ import os
import io import io
import qrcode import qrcode
import logging import logging
from flask import Flask, render_template, g, send_file, request from flask import Flask, render_template, g, send_file, request, jsonify
# Configure logging, allowing override via LOG_LEVEL environment variable # Configure logging, allowing override via LOG_LEVEL environment variable
log_level = os.environ.get("LOG_LEVEL", "INFO").upper() log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
@@ -21,8 +21,14 @@ from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID, ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH, BANK_ACCOUNT, CREDENTIALS_PATH,
) )
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS from attendance import get_members_with_fees, get_junior_members_with_fees
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
from views import (
build_adults_view_model,
build_juniors_view_model,
build_payments_view_model,
adapt_junior_members,
)
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache 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 sync_fio_to_sheets import sync_to_sheets
from infer_payments import infer_payments from infer_payments import infer_payments
@@ -38,44 +44,6 @@ def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, dese
write_cache(cache_key, mod_time, serialize(data) if serialize else data) write_cache(cache_key, mod_time, serialize(data) if serialize else data)
return data return data
def get_month_labels(sorted_months, merged_months):
labels = {}
for m in sorted_months:
dt = datetime.strptime(m, "%Y-%m")
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
merged_in = sorted([k for k, v in merged_months.items() if v == m])
if merged_in:
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
years = {d.year for d in all_dts}
if len(years) > 1:
parts = [d.strftime("%b %Y") for d in all_dts]
labels[m] = "+".join(parts)
else:
parts = [d.strftime("%b") for d in all_dts]
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
else:
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(): def warmup_cache():
"""Pre-fetch all cached data so first request is fast.""" """Pre-fetch all cached data so first request is fast."""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -100,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
"tag": "dev", "commit": "local", "build_date": "" "tag": "dev", "commit": "local", "build_date": ""
} }
def _unwrap_view_model_for_api(vm: dict) -> dict:
"""Expand pre-stringified JSON fields and rename to match Go API contract."""
out = dict(vm)
out["member_data"] = _json.loads(out.pop("member_data"))
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
return out
warmup_cache() warmup_cache()
@app.before_request @app.before_request
@@ -176,386 +154,151 @@ def sync_bank():
def version(): def version():
return BUILD_META return BUILD_META
@app.route("/api/version")
def api_version():
return jsonify(BUILD_META)
@app.route("/api/adults")
def api_adults():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
if not members_data:
return jsonify({"error": "no data"}), 503
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
result = reconcile(members, sorted_months, transactions, exceptions)
vm = build_adults_view_model(
members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
)
return jsonify(_unwrap_view_model_for_api(vm))
@app.route("/api/juniors")
def api_juniors():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
if not junior_members_data:
return jsonify({"error": "no data"}), 503
junior_members, sorted_months = junior_members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
adapted_members = adapt_junior_members(junior_members)
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
vm = build_juniors_view_model(
junior_members, adapted_members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
)
return jsonify(_unwrap_view_model_for_api(vm))
@app.route("/api/payments")
def api_payments():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
member_names = []
if adults_data:
member_names.extend(name for name, _, _ in adults_data[0])
if juniors_data:
member_names.extend(name for name, _, _ in juniors_data[0])
vm = build_payments_view_model(
transactions, member_names,
attendance_url=attendance_url, payments_url=payments_url,
)
return jsonify(vm)
@app.route("/adults") @app.route("/adults")
def adults_view(): def adults_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH credentials_path = CREDENTIALS_PATH
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
record_step("fetch_members") record_step("fetch_members")
if not members_data: if not members_data:
return "No data." return "No data."
members, sorted_months = members_data members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments") record_step("fetch_payments")
exceptions = get_cached_data( exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path, PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()], serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c}, deserialize=lambda c: {tuple(k): v for k, v in c},
) )
record_step("fetch_exceptions") record_step("fetch_exceptions")
result = reconcile(members, sorted_months, transactions, exceptions) result = reconcile(members, sorted_months, transactions, exceptions)
record_step("reconcile") record_step("reconcile")
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
current_month = datetime.now().strftime("%Y-%m")
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} vm = build_adults_view_model(
formatted_results = [] members, sorted_months, result, transactions,
for name in adult_names: datetime.now().strftime("%Y-%m"),
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
else:
is_overridden = False
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected > 0:
amount_to_pay = max(0, expected - paid)
if paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
else:
cell_text = "-"
amount_to_pay = 0
if expected > 0 or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status
})
def settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
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(
"adults.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
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,
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url, payments_url=payments_url,
bank_account=BANK_ACCOUNT, bank_account=BANK_ACCOUNT,
current_month=current_month
) )
record_step("process_data")
return render_template("adults.html", **vm)
@app.route("/juniors") @app.route("/juniors")
def juniors_view(): def juniors_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH credentials_path = CREDENTIALS_PATH
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
record_step("fetch_junior_members") record_step("fetch_junior_members")
if not junior_members_data: if not junior_members_data:
return "No data." return "No data."
junior_members, sorted_months = junior_members_data junior_members, sorted_months = junior_members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments") record_step("fetch_payments")
exceptions = get_cached_data( exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path, PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()], serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c}, deserialize=lambda c: {tuple(k): v for k, v in c},
) )
record_step("fetch_exceptions") record_step("fetch_exceptions")
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)}) adapted_members = adapt_junior_members(junior_members)
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
adapted_members = []
for name, tier, fees_dict in junior_members:
adapted_fees = {}
for m, fee_data in fees_dict.items():
if len(fee_data) == 4:
fee, total_count, _, _ = fee_data
adapted_fees[m] = (fee, total_count)
else:
fee, count = fee_data
adapted_fees[m] = (fee, count)
adapted_members.append((name, tier, adapted_fees))
result = reconcile(adapted_members, sorted_months, transactions, exceptions) result = reconcile(adapted_members, sorted_months, transactions, exceptions)
record_step("reconcile") record_step("reconcile")
# Format month labels
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
junior_names = sorted([name for name, tier, _ in adapted_members])
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
current_month = datetime.now().strftime("%Y-%m")
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} vm = build_juniors_view_model(
formatted_results = [] junior_members, adapted_members, sorted_months, result, transactions,
for name in junior_names: datetime.now().strftime("%Y-%m"),
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
if expected != "?" and isinstance(expected, int):
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
orig_fee_data = junior_members_dict.get(name, {}).get(m)
adult_count = 0
junior_count = 0
if orig_fee_data and len(orig_fee_data) == 4:
_, _, adult_count, junior_count = orig_fee_data
breakdown = ""
if adult_count > 0 and junior_count > 0:
breakdown = f":{junior_count}J,{adult_count}A"
elif junior_count > 0:
breakdown = f":{junior_count}J"
elif adult_count > 0:
breakdown = f":{adult_count}A"
count_str = f" ({count}{breakdown})" if count > 0 else ""
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
else:
is_overridden = False
fee_display = f"{expected} CZK{count_str}"
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?":
status = "empty"
cell_text = f"?{count_str}"
elif paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
amount_to_pay = expected - paid
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
amount_to_pay = expected
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
if (isinstance(expected, int) and expected > 0) or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status
})
# Format credits and debts
def junior_settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
junior_all_names = [name for name, _, _ in adapted_members]
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")
return render_template(
"juniors.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
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,
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url, payments_url=payments_url,
bank_account=BANK_ACCOUNT, bank_account=BANK_ACCOUNT,
current_month=current_month
) )
record_step("process_data")
return render_template("juniors.html", **vm)
@app.route("/payments") @app.route("/payments")
def payments(): def payments():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH credentials_path = CREDENTIALS_PATH
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments") record_step("fetch_payments")
@@ -567,23 +310,13 @@ def payments():
if juniors_data: if juniors_data:
member_names.extend(name for name, _, _ in juniors_data[0]) member_names.extend(name for name, _, _ in juniors_data[0])
grouped = group_payments_by_person(transactions, member_names) vm = build_payments_view_model(
# payments page also groups unmatched rows under a fallback key transactions, member_names,
for tx in 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())
record_step("process_data")
return render_template(
"payments.html",
grouped_payments=grouped,
sorted_people=sorted_people,
attendance_url=attendance_url, attendance_url=attendance_url,
payments_url=payments_url payments_url=payments_url,
) )
record_step("process_data")
return render_template("payments.html", **vm)
@app.route("/qr") @app.route("/qr")
def qr_code(): def qr_code():

View File

@@ -2,9 +2,9 @@
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md). Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
**Current milestone:** M4IO layer behind interfaces ✅ **Current milestone:** M6Go-native HTML frontend
**Started:** 2026-05-04 **Started:** 2026-05-04
**Last updated:** 2026-05-07 **Last updated:** 2026-05-08 (M6.2 merged)
## How to use ## How to use
@@ -97,10 +97,10 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract. Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
- [ ] **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/` - [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs - [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation - [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target - [x] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus. **Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
@@ -110,9 +110,9 @@ Goal: byte-equal JSON between Python and Go for every route. This is the parity
Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port. Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port.
- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` - [x] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` — `78e5059`
- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` - [x] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` — `c85748b`
- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering - [x] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering — `9564103`
- [ ] **M6.4** `/payments` page: grouped-by-person ledger view - [ ] **M6.4** `/payments` page: grouped-by-person ledger view
- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) - [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages - [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages
@@ -154,6 +154,7 @@ Goal: Go is the one true backend.
(Add entries as you go. Format: `YYYY-MM-DD — short note`.) (Add entries as you go. Format: `YYYY-MM-DD — short note`.)
- 2026-05-07 — `/api/version` excluded from parity diff by design. Each binary's tag/commit/build_date is identity, not a data contract — diffing it would always flag a diff between independently built backends. Route remains reachable via `make parity ARGS="-route /api/version"` for manual inspection.
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`. - 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting. - 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style. - 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.

View File

@@ -0,0 +1,29 @@
# Add `--print-fio-table` debug flag to `fuj sync`
## Context
The Go port of `fuj sync --dry-run` currently prints only the **new**
transactions — i.e. rows that will be appended to the payments sheet after
deduping against existing Sync IDs (see [sync.go:125-129](../../go/internal/services/banksync/sync.go#L125-L129)).
When debugging Fio sync issues ("why isn't transaction X showing up?",
"is the dedup working?"), there's no way to see what Fio actually
returned versus what got filtered as a duplicate.
This change adds a `--print-fio-table` flag that, **only when combined
with `--dry-run`**, prints an aligned table of every Fio transaction in
the window with each row marked `NEW` (would be appended) or `DUP`
(already in sheet, skipped). The flag is silently ignored without
`--dry-run`, so it can't accidentally fire during a real sync.
## Decisions
- Flag name: `--print-fio-table` (specific, not generic `--verbose`).
- Columns: `DATE | AMOUNT | SENDER | VS | MESSAGE | BANKID | STATUS`,
with MESSAGE truncated and STATUS = `NEW` / `DUP`.
- Scope: only effective when `--dry-run` is also set.
## Files modified
- [go/cmd/fuj/main.go](../../go/cmd/fuj/main.go) — new flag + SyncOpts field
- [go/internal/services/banksync/sync.go](../../go/internal/services/banksync/sync.go) — SyncOpts struct + refactored step 4
- [go/internal/services/banksync/debug.go](../../go/internal/services/banksync/debug.go) — printFioTable helper (new)

View File

@@ -0,0 +1,185 @@
# Python view-model cleanup (M5 prep — Python side only)
Scoped-down precursor to M5 of the Go rewrite. See:
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md)
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md)
## Context
The Python app is still production. Before adding `/api/X` shadow
routes, JSON DTOs, parity tooling, and Go handlers, get the Python
side into a clean shape. Today the `/adults` and `/juniors` routes
each carry 150200 lines of inline view-model construction inside
the Flask handler:
- [adults_view](app.py#L179) — 167 LOC, computes per-row
status/cell_text/balance/credits/debts inline.
- [juniors_view](app.py#L347) — 205 LOC, same plus `"?"` sentinel
branching and `:NJ,MA` breakdown.
- [payments](app.py#L553) — 35 LOC, lighter but still mixes IO and
grouping.
Pulling that into pure builder functions:
- shrinks `app.py` and makes each route handler do only what a route
handler should (IO + cache + step timing + render);
- gives us **already-tested** pure functions that future M5 work can
call from a `/api/X` shadow endpoint with one line of `jsonify(...)`;
- has zero behavioural change (existing tests in
[test_app.py](tests/test_app.py) act as the regression guard).
This is a Python-only change. No Go work, no JSON contract, no shadow
routes — those come after.
## Approach
Three pure builder functions in a new `scripts/views.py` module. Each
takes already-loaded, deserialized inputs (no Flask globals, no IO,
no cache calls) and returns the exact dict that today's route passes
to `render_template`.
```python
def build_adults_view_model(
members, sorted_months, transactions, exceptions, current_month,
*, attendance_url, payments_url, bank_account,
) -> dict: ...
def build_juniors_view_model(
junior_members, sorted_months, transactions, exceptions, current_month,
*, attendance_url, payments_url, bank_account,
) -> dict: ...
def build_payments_view_model(
transactions, member_names,
*, attendance_url, payments_url,
) -> dict: ...
```
The route handlers shrink to: load data (with `get_cached_data` and
`record_step` calls staying in `app.py`), call the builder, render.
```python
@app.route("/adults")
def adults_view():
members, sorted_months = ... # cached loads + record_step calls
transactions = ...
exceptions = ...
result_meta = ...
record_step("process_data")
vm = build_adults_view_model(
members, sorted_months, transactions, exceptions,
datetime.now().strftime("%Y-%m"),
attendance_url=..., payments_url=..., bank_account=BANK_ACCOUNT,
)
return render_template("adults.html", **vm)
```
## Decisions
1. **Pure builders, no Flask state.** No `record_step`, no `g.*`, no
`get_cached_data` inside builders. They take plain args, return
plain dicts. This is what makes them trivially unit-testable and,
later, trivially reusable from `/api/X`.
2. **Preserve byte-equal behaviour.** The dicts returned must match
today's `render_template(...)` kwargs key-for-key, value-for-value
— including the existing `json.dumps(member_data)` /
`json.dumps(month_labels_json)` / `json.dumps(raw_payments_json)`
wrappers. Those wrappers exist for inline JS in templates; the
refactor doesn't touch them. (When `/api/X` lands later, that route
will produce a sibling dict with the wrappers stripped, but that's
future work.)
3. **`reconcile()` stays inside the route, not the builder.** It
crosses the IO/cache boundary in spirit (its inputs come from
cache); but it's also pure-domain and called by both adults and
juniors. Keep the call site in the route so `record_step("reconcile")`
timing isn't lost. Builder takes `result` as an argument.
4. **Shared helpers stay in `app.py`** for now —
[get_month_labels](app.py#L41) and
[group_payments_by_person](app.py#L60) are already module-level
pure functions, used by routes and now by builders. Either leave
them in `app.py` and import into `scripts/views.py`, or move them
into `scripts/views.py`. **Choose: move into `scripts/views.py`**
— they're view-model concerns, not Flask concerns, and `app.py`
should keep shrinking.
5. **No new test file needed.** Existing
[tests/test_app.py](tests/test_app.py) tests
`test_adults_route`, `test_juniors_route`, `test_payments_route`
exercise the rendered HTML end-to-end. If they pass after the
refactor, behaviour is preserved. Adding builder-level unit tests
is a *nice-to-have* but not required for this iteration.
## Tasks
### 1. Create `scripts/views.py` with the three builders + shared helpers
- Move `get_month_labels` and `group_payments_by_person` from
[app.py:4177](app.py#L41-L77) into `scripts/views.py`. Update the
import in `app.py`.
- Implement `build_adults_view_model` by extracting
[app.py:200344](app.py#L200-L344) (everything between `result =
reconcile(...)` and `return render_template(...)`). Take `result`
as a parameter; emit the same dict that's currently passed as
`**kwargs` to `render_template`.
- Implement `build_juniors_view_model` by extracting
[app.py:370550](app.py#L370-L550). Same shape — including the
`adapted_members` adapter loop, the junior `?`-sentinel branches,
and the `:NJ,MA` breakdown.
- Implement `build_payments_view_model` by extracting
[app.py:570586](app.py#L570-L586) (the `group_payments_by_person`
call + `Unmatched / Unknown` bucket + sort).
### 2. Slim down the route handlers in `app.py`
Each handler keeps:
- `attendance_url`/`payments_url` URL building
- `get_cached_data` calls (with `record_step` between)
- `reconcile(...)` call (adults/juniors) with `record_step("reconcile")`
- `record_step("process_data")` after the builder call
- `return render_template("X.html", **view_model)`
Each handler **drops** all the per-row computation, totals
formatting, credits/debts sorting, `raw_payments_by_person` building,
and `import json` lines.
Target post-refactor LOC for each route handler: ~2530 lines
(currently 167 / 205 / 35).
### 3. Run the existing test suite + manual smoke
```
make test # all tests green
make web-py # browse /adults /juniors /payments visually
```
## Critical files
- [app.py](app.py) — routes shrink dramatically; `get_month_labels`
and `group_payments_by_person` get moved out.
- New: `scripts/views.py` — three builders + the two helpers.
- [tests/test_app.py](tests/test_app.py) — unchanged; serves as the
regression guard.
## Verification
1. `make test` — all four `TestWebApp` tests pass unchanged.
2. `make web-py` and visit `/adults`, `/juniors`, `/payments` — each
renders identically to before (same table contents, same totals,
same credits/debts, same `?` rendering on juniors).
3. `git diff app.py` shows substantial deletions (route bodies
shrink) and only thin glue calling the new builders.
4. Optional sanity check: temporarily add `print(repr(view_model))`
in the route before `render_template` on `main` and on the branch,
diff for one fixture run — should be byte-identical dicts.
## Out of scope (future iterations, not this plan)
- `/api/<route>` shadow endpoints in Flask
- Go `internal/web/api/` DTOs and JSON Schemas
- Go `internal/services/membership/views.go` aggregators
- Go HTTP handlers for `/api/X`
- `cmd/parity/main.go` and `make parity` target
- Junior breakdown sidecar work in
`go/internal/services/membership/sources.go`
These are the original M5 tasks (M5.1M5.4 in the progress tracker).
They become much easier once today's refactor lands, because the
shadow `/api/X` routes will be one-liners over the new builders.

View File

@@ -0,0 +1,240 @@
# M5.1 — Hand-author Go API structs + emit JSON Schemas
Companion to:
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (progress tracker — M5.1 row)
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep — already merged as `b562ce3` / `32a16ff` / `59223c0`)
## Context
M4 (IO layer behind interfaces) just landed. M5 is the JSON-parity contract phase — byte-equal JSON between Python and Go for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version`. Within M5, the work splits four ways:
- **M5.1 — this plan.** Define the wire contract: hand-authored Go structs with explicit `json:` tags matching Python keys, plus committed JSON Schemas generated by `github.com/invopop/jsonschema`. **Schemas only — no handlers, no Python `/api/X` routes, no parity tool.**
- M5.2 — Implement Go handlers that compose `services/*` results into these structs.
- M5.3 — Add Python `/api/X` shadow endpoints in [app.py](app.py).
- M5.4 — `cmd/parity/main.go` + `make parity` target.
The recent Python view-model extraction (`scripts/views.py`) lays the groundwork: every Python builder now returns a plain dict that an `/api/X` shadow can `jsonify` (with one minor unwrap step — see decision #1 below). M5.1 is the matching Go side: types and schemas that pin down the contract before any code writes JSON to a wire.
## Key design decisions
1. **Wire format is nested objects, not strings-of-JSON.** The Python view-model dicts contain three template-only fields that are pre-serialized JSON strings: `member_data`, `month_labels_json`, `raw_payments_json`. Those exist purely to feed inline `<script>` blocks in Jinja templates ([scripts/views.py:227-237](scripts/views.py#L227-L237)). The `/api/X` JSON contract uses the **un-stringified** nested form. Rationale:
- The master design doc references `total_balance`, `original_expected`, `attendance_count` as struct fields ([2026-05-03-2349-go-backend-rewrite.md:223-225](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L223-L225)), which are *inside* `member_data` — only meaningful if it's a nested object.
- The `Expected` `MarshalJSON` design (emit `42` or `"?"`) ([same doc:233-238](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L233-L238)) only fires inside `member_data`; pointless if that field is a string.
- Strings-of-JSON make `invopop/jsonschema` schemas useless for those fields (`{"type": "string"}` describes nothing).
- The Python prep plan ([2026-05-07-1431-m5-json-api-parity.md:87-89](docs/plans/2026-05-07-1431-m5-json-api-parity.md#L87-L89)) already anticipates this: "that route will produce a sibling dict with the wrappers stripped".
This refines (does not contradict) M5.3's "no transformation" wording. M5.3's `/api/X` will be `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
2. **New package: `internal/web/api/`.** The Go side has no `api/` package today — only [internal/web/server.go](go/internal/web/server.go) with one hello route. The api package owns wire types and (in M5.2) handlers. Sub-files per route keep diffs small.
3. **Wire types are separate from `domain/reconcile.Result`.** [domain/reconcile/reconcile.go:88-92](go/internal/domain/reconcile/reconcile.go#L88-L92) defines `Result`, `MemberResult`, `MonthData`, `TxEntry`, etc. — none have `json:` tags. **Don't tag the domain types**: that bleeds wire concerns into pure logic and locks the JSON contract to internal field names. Define wire types in `internal/web/api/` and convert in M5.2's handlers.
4. **`Expected{Value int; Unknown bool}` with custom `MarshalJSON`/`UnmarshalJSON`** for junior `expected` and `original_expected`. Already prescribed by the master design.
5. **Transaction `amount` / `inferred_amount` may be `int` or `""`** (Sheets `UNFORMATTED_VALUE` returns empty string for blank cells per `scripts/match_payments.py` rows 250-260 in the views.py exploration). Use a custom `SheetsNumber` type with `MarshalJSON`/`UnmarshalJSON` that emits `0`/`null` for empty and the number otherwise. Document in code comment with a one-liner. Verify exact behavior by inspecting one or two existing scrubbed reconcile fixtures before coding.
6. **Schema generation lives in `internal/web/api/schemagen_test.go`** with `-update` flag, à la golden tests. Default test run (`go test ./internal/web/api/...`) re-generates schemas in memory and asserts byte-equality vs the committed files in `tests/fixtures/api-schema/`. `go test -update ./internal/web/api/...` rewrites the committed files. Avoids a separate `cmd/gen-api-schema` binary; CI catches drift automatically.
7. **One schema per route**, named to match the Go type:
```
go/tests/fixtures/api-schema/adults.schema.json
go/tests/fixtures/api-schema/juniors.schema.json
go/tests/fixtures/api-schema/payments.schema.json
go/tests/fixtures/api-schema/version.schema.json
```
8. **Adults and Juniors are *not* the same type.** Their outer shapes match (same keys), but `MonthCell.Text` semantics differ (juniors render `"?"`, `"?(3)"`, `:NJ,MA` breakdowns) and `member_data` semantics differ (juniors carry `Expected` sentinel). Define `AdultsResponse` and `JuniorsResponse` separately even if they share most field types — clearer schemas and easier to evolve independently. Share scalar types (`Transaction`, `Exception`, `MonthCell`, `TotalCell`) in `types.go`.
9. **Money is integer CZK** ([master design:55-56](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L55-L56)). All amount fields use `int`. The one exception: `member_data[name].months[YYYY-MM].paid` is a `float64` in the Python output (proportional allocation produces fractional CZK like `33.333333`). Use `float64` only there; document the why in a one-line comment.
## Files to create
```
go/internal/web/api/
├── types.go # SheetsNumber, Expected, Exception, Transaction, MonthCell, TotalCell, MemberRow
├── adults.go # AdultsResponse + adults-specific MemberData
├── juniors.go # JuniorsResponse + juniors-specific MemberData (with Expected fields)
├── payments.go # PaymentsResponse
├── version.go # VersionResponse
└── schemagen_test.go # generates + golden-asserts schemas
go/tests/fixtures/api-schema/
├── adults.schema.json
├── juniors.schema.json
├── payments.schema.json
└── version.schema.json
```
## Struct skeleton (illustrative, not final wording)
```go
// internal/web/api/types.go
// SheetsNumber wraps a value that may arrive from Google Sheets as either
// a JSON number or "" (empty string for blank cells). Marshals as 0 when
// missing; the consumer treats Missing+Value=0 as "no data".
type SheetsNumber struct {
Value float64
Missing bool
}
// Expected carries a junior's expected fee or the "?" sentinel
// (single-attendance month requires manual review).
type Expected struct {
Value int
Unknown bool
}
type Exception struct {
Amount int `json:"amount"`
Note string `json:"note"`
}
type Transaction struct {
Date string `json:"date"`
Amount SheetsNumber `json:"amount"`
ManualFix string `json:"manual_fix"`
Person string `json:"person"`
Purpose string `json:"purpose"`
InferredAmount SheetsNumber `json:"inferred_amount"`
Sender string `json:"sender"`
Message string `json:"message"`
BankID string `json:"bank_id"`
}
type MonthCell struct {
Text string `json:"text"`
Overridden bool `json:"overridden"`
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
Amount int `json:"amount"`
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
RawMonth string `json:"raw_month"` // YYYY-MM
Tooltip string `json:"tooltip"`
}
type TotalCell struct {
Text string `json:"text"`
Status string `json:"status"`
}
type MemberRow struct {
Name string `json:"name"`
Months []MonthCell `json:"months"`
Balance int `json:"balance"`
UnpaidPeriods string `json:"unpaid_periods"`
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
PayableAmount int `json:"payable_amount"`
}
type Credit struct {
Name string `json:"name"`
Amount int `json:"amount"`
}
```
```go
// internal/web/api/adults.go
type AdultsMonthData struct {
Status string `json:"status"`
Expected int `json:"expected"`
Paid float64 `json:"paid"` // float — proportional allocator can produce 33.333…
Exception *Exception `json:"exception"`
AmountToPay int `json:"amount_to_pay"`
// ... transactions, others, etc — match exact keys from result["members"][name]["months"][YYYY-MM]
}
type AdultsMemberData struct {
TotalBalance int `json:"total_balance"`
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM key
Transactions []Transaction `json:"transactions"`
}
type AdultsResponse struct {
Months []string `json:"months"`
RawMonths []string `json:"raw_months"`
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]AdultsMemberData `json:"member_data"`
MonthLabels map[string]string `json:"month_labels"` // was month_labels_json (string)
RawPayments map[string][]Transaction `json:"raw_payments"` // was raw_payments_json (string)
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []Transaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
}
```
`JuniorsResponse` mirrors `AdultsResponse` but the inner `MonthData` carries `Expected` and `OriginalExpected` (both `Expected` type), and adds the `:NJ,MA` breakdown fields produced by [scripts/views.py:290-298](scripts/views.py#L290-L298).
`PaymentsResponse`:
```go
type PaymentsResponse struct {
GroupedPayments map[string][]Transaction `json:"grouped_payments"`
SortedPeople []string `json:"sorted_people"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
}
```
`VersionResponse` mirrors Python's `BUILD_META` ([app.py:67](app.py#L67)):
```go
type VersionResponse struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
}
```
Exact fields inside the per-month `MonthData`/`MemberData` will be finalized by inspecting **one** scrubbed `member_data` JSON dump from a current `/adults` and `/juniors` call (see Verification step 1) — names and types have to be identical.
## Reusable existing code
- `domain/reconcile.Result` ([go/internal/domain/reconcile/reconcile.go:88](go/internal/domain/reconcile/reconcile.go#L88)) — source data for adults/juniors. M5.2 maps it to wire types; M5.1 only needs to know the field set.
- `web.BuildInfo` ([go/internal/web/server.go:11-15](go/internal/web/server.go#L11-L15)) — already wired through `cmd/fuj/main.go:79`. The `VersionResponse` type is a thin renaming of this with json tags. Decide in M5.2 whether to *also* tag `BuildInfo` directly (it's not really domain) or keep `VersionResponse` separate. M5.1 just defines the wire struct.
- `scripts/views.py` builders — the spec for every key/type. Treat as authoritative.
## Tasks
1. **Add `github.com/invopop/jsonschema` dependency.** `cd go && go get github.com/invopop/jsonschema && go mod tidy`. Single commit.
2. **Capture one fresh `member_data` dump for adults and juniors** to pin down inner-dict field names and types precisely (especially `paid` precision, `transactions[]` shape, `others[]` if present). Run `make web-py` on a known-good cache, hit `/adults` and `/juniors`, dump `view_model["member_data"]` to a scratch file (gitignored), and inspect. **Do not commit raw dumps** — PII rule. This is for shape inspection only.
3. **Author `internal/web/api/types.go`** with `SheetsNumber`, `Expected`, `Exception`, `Transaction`, `MonthCell`, `TotalCell`, `MemberRow`, `Credit`. Implement `MarshalJSON`/`UnmarshalJSON` for `SheetsNumber` and `Expected`. Use struct tags `json:"snake_case_key"` matching Python exactly.
4. **Author `internal/web/api/adults.go`** with `AdultsMonthData`, `AdultsMemberData`, `AdultsResponse`. Cross-check every key against the dump from step 2.
5. **Author `internal/web/api/juniors.go`** similarly, with `Expected` fields and the J/A breakdown.
6. **Author `internal/web/api/payments.go` and `version.go`** (small).
7. **Author `internal/web/api/schemagen_test.go`** that:
- Imports `github.com/invopop/jsonschema`.
- Defines a `schemaCases` slice: `{name: "adults", typ: AdultsResponse{}, ...}` etc.
- For each case, generates a schema, marshals indented JSON, compares against committed file at `../../tests/fixtures/api-schema/<name>.schema.json`.
- Honors a `-update` flag (`flag.Bool`) that rewrites the committed file instead of asserting.
- One `t.Run(case.name, ...)` per route for clear failure output.
8. **Run with `-update` once to populate** `tests/fixtures/api-schema/*.schema.json`. Eyeball each schema: required fields list, oneOf for `Expected`, additionalProperties on map types. Commit.
9. **Lint + test:** `cd go && go vet ./... && make go-test && make go-lint`. Fix any issues. (Expect zero — this is read-only data structures.)
10. **CHANGELOG entry** + tick `M5.1` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:100](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L100) with the merge SHA.
11. **Branch + MR per CLAUDE.md workflow:** branch `feat/go-m5-1-api-structs`, push with `-u`, open MR via `tea pr create`. Do not merge from CLI.
## Verification
1. `cd go && go test ./internal/web/api/...` — **schemas regenerate identically** to committed files.
2. `cd go && go test ./internal/web/api/... -update` then `git diff go/tests/fixtures/api-schema/` — diff is empty (idempotent generation).
3. `cd go && make go-lint` — clean.
4. `cd go && go vet ./...` — clean.
5. Manual schema inspection: open `adults.schema.json`, confirm:
- Top-level `required` list contains every Python key.
- `member_data` is `additionalProperties: { ... AdultsMemberData ... }` (a map keyed by name).
- `expected` and `original_expected` (juniors only) are `oneOf: [{type: integer}, {const: "?"}]`.
- `amount` and `inferred_amount` on `Transaction` accept number or empty/null.
6. **No production code paths exercised yet** — handlers come in M5.2. Compile-time success + schema golden-test = M5.1 done.
## Out of scope (later M5 tasks)
- Wiring Go HTTP handlers for `/api/X` (M5.2).
- Adding Python `/api/X` shadow endpoints (M5.3) — including the `unwrap_json_strings(view_model)` shim noted in design decision #1.
- `cmd/parity/main.go` and `make parity` target (M5.4).
- Tagging `domain/reconcile.Result` with `json:` tags — explicitly avoided.
- Refactoring the strings-of-JSON fields out of the Python view-model — they stay in `views.py` for the template path, the `/api/X` shadow unwraps them.

View File

@@ -0,0 +1,113 @@
# M5.3 — Python `/api/X` shadow endpoints
Companion to:
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (M5.3 row)
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
## Context
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
## Approach
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
### Decisions
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
### The shim
```python
def _unwrap_view_model_for_api(vm: dict) -> dict:
out = dict(vm)
out["member_data"] = _json.loads(out.pop("member_data"))
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
return out
```
Note the **rename**: `month_labels_json``month_labels`, `raw_payments_json``raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
### Route skeleton (`/api/adults`)
```python
@app.route("/api/adults")
def api_adults():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
if not members_data:
return jsonify({"error": "no data"}), 503
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, CREDENTIALS_PATH,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
result = reconcile(members, sorted_months, transactions, exceptions)
vm = build_adults_view_model(
members, sorted_months, result, transactions,
datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
)
return jsonify(_unwrap_view_model_for_api(vm))
```
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
## Files to modify
- [app.py](app.py)
- L10: add `jsonify` to `from flask import ...`.
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
- [tests/test_app.py](tests/test_app.py)
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
## Reusable existing code
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
## Verification
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
2. `python -c "import app"` — catches the missing `jsonify` import.
3. `make web-py`, then:
- `curl -s localhost:5001/api/version | jq .``{tag, commit, build_date}`.
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
- Same checks on `/api/juniors` and `/api/payments`.
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
## Out of scope (M5.4 will surface and resolve)
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
- **`unmatched[]`**: same divergence (same raw row dicts).
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
- **`Decimal` / float precision** in `grouped_payments` amounts.
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
## Branch + MR
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.

View File

@@ -0,0 +1,152 @@
# M5.4 — Parity diff binary (`cmd/parity`) + `make parity`
## Context
Per [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:103](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L103), M5.4 is the next milestone in the Go rewrite. M5.1M5.3 already landed:
- M5.1: hand-authored Go response structs at [go/internal/web/api/](go/internal/web/api/) + JSON Schemas in [go/tests/fixtures/api-schema/](go/tests/fixtures/api-schema/).
- M5.2: Go `/api/version|adults|juniors|payments` handlers in [go/internal/web/api/handler.go](go/internal/web/api/handler.go).
- M5.3: Python shadow endpoints in [app.py:157-224](app.py#L157-L224) using `_unwrap_view_model_for_api` for the same JSON shape.
What's missing: a tool that proves the two backends actually agree on the wire. The M5 gate says "byte-equal JSON between Python and Go for every route." Without a diffing tool, drift between the two implementations slips in silently and we can't gate further milestones (M6 frontend, M7 watch period) on parity.
This task delivers the **parity contract enforcer**: a Go binary that fetches `/api/*` from both backends, scrubs an allowlist of expected diffs, and prints `cmp.Diff` for everything else. Both backends read the same live Google Sheets — that shared state is what makes parity meaningful, no scenario fixtures needed.
## Scope
In scope:
- New binary `go/cmd/parity/main.go` plus a small support package for the allowlist scrubber.
- A unit test for the scrubber.
- New `make parity` target.
- `go-cmp` promoted to a direct dependency.
Out of scope (explicitly):
- CI integration / nightly job — that's M7.2.
- Fixture-driven offline parity — pure-fn parity already runs via `make go-parity` (M3).
- Hooking `make parity` into `make go-test-all` — leave it manual since it requires two live servers.
## Approach
### 1. New binary: `go/cmd/parity/main.go`
Standalone binary (mirrors task spec — not a subcommand of `fuj`). Stdlib `flag`, no third-party CLI lib.
**Flags:**
- `-py` — Python base URL, default `http://localhost:5001`
- `-go` — Go base URL, default `http://localhost:8080`
- `-route` — optional single route to diff (e.g. `/api/adults`); empty means iterate all four
- `-timeout` — per-request timeout, default `30s` (sheet fetches are slow on cold cache)
**Routes**, hard-coded:
```go
var routes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
```
All `GET`, no query params (verified in [app.py:157-224](app.py#L157-L224) and [go/internal/web/api/handler.go:27-68](go/internal/web/api/handler.go#L27-L68)).
**Per route, the binary:**
1. `GET py+route` and `GET go+route` (sequential — keep it simple; total wall time ~12s on warm cache).
2. Verify both return HTTP 200; on non-200, print body and mark route as ERROR.
3. Decode each body into `map[string]any` (NOT into `api.AdultsResponse` — using the typed struct would silently drop unknown Python-side keys, defeating the diff. Map gives `cmp.Diff` clean dotted-path field names for free.).
4. Run `scrub(m, allowlist)` on both decoded maps.
5. `diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())`.
6. If `diff != ""`, print `=== /api/X ===` header followed by the diff; track for exit code.
**Exit codes:**
- `0` — all routes match (or the only diffs were under the allowlist).
- `1` — at least one route had a non-allowlisted diff.
- `2` — at least one route failed to fetch / parse (HTTP error, timeout, non-JSON body).
This makes the binary CI-friendly when M7.2 lands.
**Output**: human-readable to stdout — header per route, then "OK" or the diff. Final summary line: `parity: 4/4 routes match` or `parity: 2/4 match, 1 diff, 1 error`.
### 2. Allowlist scrubber
Live in same package (`main`). Keep it tiny — no need for a separate sub-package.
```go
var defaultAllowlist = []string{"render_time.total", "build_meta"}
// scrub walks m and deletes any key whose dotted path matches an allowlist entry.
// "render_time.total" deletes m["render_time"]["total"] only (preserves render_time.breakdown).
// "build_meta" (no dot) deletes m["build_meta"] entirely.
func scrub(m map[string]any, paths []string) { ... }
```
Today these fields **don't appear in the JSON** — they're Jinja template-only ([app.py:91-110](app.py#L91-L110)) and the Go side only logs render time via [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go). So today the scrub is a no-op. The implementation is forward-compatible insurance: if someone later adds either field to a JSON response on one side only, the parity binary already tolerates it.
The implementation note in [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go) ("the elapsed value maps to render_time.total in the M5 JSON allowlist") confirms this is the intended design.
### 3. Unit test
Add `go/cmd/parity/scrub_test.go` with a couple of cases:
- Scrubbing top-level key (`build_meta`) — key removed; siblings untouched.
- Scrubbing nested key (`render_time.total`) — only `total` removed; `breakdown` preserved.
- Path that doesn't exist — no-op, no error.
- Map without the parent key — no-op (don't panic).
Runs as part of normal `make go-test` (no `-tags` needed).
### 4. Makefile target
Add to [Makefile](Makefile):
```make
parity:
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
```
Add `parity` to the `.PHONY` line at [Makefile:1](Makefile#L1) and a `help` entry like:
```
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
```
Don't depend on `go-build``go run` compiles ad-hoc, and parity is interactive enough that the slight rebuild cost doesn't matter.
### 5. Dependency
`github.com/google/go-cmp v0.7.0` is already a transitive dep ([go/go.sum:24-25](go/go.sum#L24-L25)) but not in `go.mod`'s `require` block. After adding the import, run `go mod tidy` inside `go/` to promote it to direct. No new external deps.
## Files to add / modify
**Add:**
- [go/cmd/parity/main.go](go/cmd/parity/main.go) — flags, route loop, fetch+scrub+diff, exit codes
- [go/cmd/parity/scrub_test.go](go/cmd/parity/scrub_test.go) — unit tests for the scrubber
**Modify:**
- [Makefile](Makefile) — `.PHONY`, help block, `parity` target
- [go/go.mod](go/go.mod) — promote `go-cmp` to direct (via `go mod tidy`)
- [go/go.sum](go/go.sum) — likely unchanged (already pinned)
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M5.4, add commit SHA
- [CHANGELOG.md](CHANGELOG.md) — entry per project convention
**Per [CLAUDE.md](CLAUDE.md) plans convention:** copy this plan to `docs/plans/2026-05-07-HHMM-m5-4-parity-binary.md` (timestamp via `date "+%Y-%m-%d-%H%M"`) before opening the MR, since plan files are committed for posterity.
## Existing utilities to reuse
- `cmp.Diff` + `cmpopts.EquateEmpty()` from `github.com/google/go-cmp/cmp` (already in go.sum).
- Stdlib `net/http`, `encoding/json`, `flag` — sufficient; no need to pull a CLI framework.
- No existing scrubber to reuse — the one in [scripts/scrub_fixtures.py](scripts/scrub_fixtures.py) operates on the *capture* path and renames PII; the parity scrubber is a different concern (path-based deletion of expected diffs).
## Verification
Manual smoke test (the binary is meant to be run against live servers):
1. **Sanity / build:** `cd go && go build ./cmd/parity && go test ./cmd/parity/...` — compiles + scrubber unit test passes.
2. **Both backends up:**
- Terminal A: `make web-py`
- Terminal B: `make web-go`
- Terminal C: `make parity`
Expected: `parity: 4/4 routes match`, exit 0.
3. **Negative test:** add a literal extra field to one Go handler temporarily (e.g. `"diagnostic": "test"` in `VersionResponse`), rebuild, re-run `make parity`. Expected: non-zero exit, diff shown for `/api/version`. Revert.
4. **Allowlist test:** in a unit test (or by manually constructing a payload with `render_time.total` injected), confirm the scrubber removes it before the diff stage.
5. **CHANGELOG** entry added; progress tracker ticked with the merge commit SHA.
Per [CLAUDE.md](CLAUDE.md) branching policy: this is a feature, so work happens on `feat/go-m5-4-parity-binary`, push with `-u`, open MR with `tea pr create --base main --head feat/go-m5-4-parity-binary`. User merges in Gitea.
## Notes & risks
- **Cache-warmth dependency:** Both backends cache aggressively. A cold-cache fetch on Python can take 30s+ — hence the configurable timeout. If parity is run immediately after `flush-cache`, expect the first run to be slow.
- **Order-sensitive lists:** `cmp.Diff` on `map[string]any` treats slices as ordered. If either backend ever returns members/transactions in a different order, the diff will flag it — that's a real parity bug, not a false positive, so this is correct behavior. If we hit ordering instability later, fix the source, don't add `cmpopts.SortSlices`.
- **Float formatting:** Both sides go through `encoding/json` (Go) and `jsonify` (Python `json.dumps`). Floats in totals/balances may format differently (`123` vs `123.0`). If this surfaces, the Go side already uses typed structs with `int` where appropriate — investigate at the source rather than allowlisting.

View File

@@ -0,0 +1,88 @@
# Plan: Go rewrite — M6.1 Template skeleton
## Context
M5 finished — Go and Python now produce byte-equal JSON across `/api/{adults,juniors,payments,version}` and `cmd/parity` enforces it. See [progress tracker §M5](2026-05-03-2349-go-backend-rewrite-progress.md#L96-L106).
M6 begins the Go-native HTML frontend that lets us actually retire the Python web UI. **M6.1 is the foundation step**: stand up the base HTML layout, shared nav, terminal-green-on-black theme, and the `embed.FS` plumbing that every subsequent M6 page (adults, juniors, payments, sync, flush, qr) will compose into. After M6.1, opening :8080 shows chrome (header, nav, footer) on every route with placeholder bodies — no real data wiring; that lands in M6.2+.
This is **not a Jinja port**. The Python templates are reference for nav structure and CSS values; the Go layout is designed natively (per the [master plan](2026-05-03-2349-go-backend-rewrite.md#L24-L27): "frontends are allowed to diverge").
## Current Go web state (what we're building on)
- [go/internal/web/server.go](../../go/internal/web/server.go) — `Run()` registers `GET /{$}` (text-plain hello) + four `/api/*` routes. `BuildInfo{Version,Commit,BuildDate}` already flows in.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — central handler struct holding `Sources`, `Config`, `Logger`, build fields.
- No `templates/`, no `static/`, no `embed` directives, no `html/template` import anywhere in `go/`. We're starting from a blank slate.
## Approach
1. **Create `templates/` and `static/` under `go/internal/web/`**, embedded via `//go:embed`.
2. **Single base template** (`base.tmpl`) using `html/template` `block`s for `title` and `content`. Each page template defines those blocks; the renderer executes via `ExecuteTemplate(w, "base", data)`.
3. **Nav partial** parameterized by a `pageData.Active` string so each page highlights its own link with `class="active"`. Three tiers preserved (Primary / Archived / Tools) — matches Python UX in [templates/adults.html:491-505](../../templates/adults.html#L491-L505).
4. **CSS** lifted verbatim from [templates/adults.html:8-487](../../templates/adults.html#L8-L487) into `static/css/app.css`. Page templates `<link>` it from the base layout. M6.2+ reuses the same class names (`.cell-ok`, `.modal-content`, …) without re-extracting.
5. **HTML handlers** in a new `go/internal/web/html/` package (sibling of `api/`). One `Handler` struct holding `*Renderer` + `BuildInfo` + `Config`. Routes for `/`, `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache` registered in `server.go`. Each route renders a placeholder body ("Coming in M6.X") inside the base shell — the goal is to prove the chrome works, not to populate data.
6. **Replace the existing `helloHandler`**: `GET /{$}``http.Redirect` to `/adults` (mirrors Python's meta-refresh in [app.py:112-115](../../app.py#L112-L115)).
7. **Static file route**: `GET /static/` served from `staticFS` via `http.StripPrefix("/static/", http.FileServerFS(staticFS))`.
8. **Footer keeps it simple**: `{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}`. The Python per-request render-time breakdown is deferred — `middleware/timer.go` only logs to slog today; threading a timer into request context can come back in a later milestone if we miss it.
## Files to create / modify
```
go/internal/web/
├── assets.go NEW — //go:embed templates/* static/*
├── render.go NEW — Renderer wraps parsed *template.Template
├── server.go MODIFY — drop helloHandler; mount HTML routes + /static/
├── html/
│ ├── handler.go NEW — html.Handler + page methods
│ └── handler_test.go NEW — httptest smoke per route
├── templates/
│ ├── base.tmpl NEW — <html>/<head>/<body>; nav + content + footer blocks
│ ├── partials/
│ │ ├── nav.tmpl NEW — three-tier nav, active highlighting
│ │ └── footer.tmpl NEW — build meta line
│ ├── adults.tmpl NEW — placeholder "Coming in M6.2"
│ ├── juniors.tmpl NEW — placeholder "Coming in M6.3"
│ ├── payments.tmpl NEW — placeholder "Coming in M6.4"
│ ├── sync.tmpl NEW — placeholder "Coming in M6.6"
│ └── flush_cache.tmpl NEW — placeholder "Coming in M6.6"
└── static/
└── css/
└── app.css NEW — verbatim from templates/adults.html:8-487
```
## Key design notes
- **Parse once at startup**: `NewRenderer(fs embed.FS)` calls `template.ParseFS(fs, "templates/*.tmpl", "templates/partials/*.tmpl")`. Parse failure aborts boot — template syntax errors surface immediately, not at first request.
- **Per-page cloned templates** to avoid `define "content"` collisions across pages: `Renderer` stores `map[string]*template.Template` keyed by page name; each is `base+partials` clone with that page's `content`/`title` overlaid.
- **View model** — small `pageData` struct: `Active` (nav key), `Build` (BuildInfo), `Body` (per-page payload, `any` until M6.2 widens it).
- **Active-link logic** in nav partial: `{{ if eq .Active "adults" }}class="active"{{ end }}` — markup-side, no template funcs needed.
- **No JS in M6.1.** Member-detail and QR modals + `static/js/*.js` come in M6.5. M6.1 only sets up the embedded-asset machinery.
- **Nav anchor labels are literal text** (`[Adults]`, `[Juniors]`, etc.) — keep the brackets in the template strings, they're part of the look.
- **`/qr` is not in the nav** — it's an image endpoint, M6.6 territory.
## Critical files to read
- [go/internal/web/server.go](../../go/internal/web/server.go) — mux registration; `Run()` signature stays unchanged.
- [go/internal/web/api/handler.go](../../go/internal/web/api/handler.go) — pattern to mirror for `html.Handler` (same field layout, most fields unused in M6.1).
- [templates/adults.html:8-487](../../templates/adults.html#L8-L487) — CSS source, copy verbatim.
- [templates/adults.html:491-505](../../templates/adults.html#L491-L505) — nav markup source (three tiers).
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md:88-101](2026-05-03-2349-go-backend-rewrite.md#L88-L101) — original layout intent for `web/handlers/`, `web/templates/`, `web/static/`.
## Verification
End-to-end smoke from a fresh `make go-build`:
1. `make web-go &` — server boots; no template-parse errors in slog output.
2. `curl -i localhost:8080/``302` to `/adults`.
3. `curl -s localhost:8080/adults | grep -F 'class="active"'` matches the Adults anchor only.
4. `curl -sI localhost:8080/static/css/app.css``200`, `Content-Type: text/css`.
5. Browser at `http://localhost:8080/adults`: terminal-green-on-black theme, three-tier nav at top (Adults active), "Coming in M6.2" placeholder, footer `{tag}@{commit} | built {date}`.
6. Click each nav link in turn (`/juniors`, `/payments`, `/sync-bank`, `/flush-cache`) — same chrome, the clicked link is highlighted, per-page placeholder body shows.
7. `make go-test``web/html/handler_test.go` passes: each route returns 200, `text/html`, `class="active"` only on the matching anchor.
8. `make go-lint` clean.
9. `make parity` still green (regression check — M6.1 doesn't touch `/api/*`).
10. CHANGELOG entry per CLAUDE.md; M6.1 ticked in [progress tracker §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113) with commit SHA.
## Branch & MR
`feat/go-m6-1-template-skeleton` per CLAUDE.md branch-per-feature workflow. Open MR via `tea pr create --base main --head feat/go-m6-1-template-skeleton`. User merges in the Gitea browser UI.

View File

@@ -0,0 +1,131 @@
# Plan: Go rewrite — M6.2 Adults page
## Context
M6.1 landed the template skeleton, embed.FS, and HTML routes ([progress §M6.1](2026-05-03-2349-go-backend-rewrite-progress.md#L113), commit `78e5059`). Every page renders chrome + a "Coming in M6.X" placeholder body. **M6.2 fills in `/adults`** — the most data-rich page and the template that drives the rest of the M6 work (juniors mostly mirrors it).
Acceptance criteria, verbatim from [progress §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114):
> `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr`
The Go side has done excellent prep: `buildAdultsResponse` in [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) already produces the full view model (currently consumed only by `/api/adults`), the wire types in [go/internal/web/api/types.go](../../go/internal/web/api/types.go) match the Python view-model 1:1, and the CSS is already lifted into [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css). M6.2 is therefore mostly **template authoring + handler wiring + a small filters script**, not a fresh port.
## Current Go web state (what we're building on)
- [go/internal/web/server.go:49](../../go/internal/web/server.go#L49) — `mux.HandleFunc("GET /adults", hh.ServeAdults)` already wired.
- [go/internal/web/html_handler.go:16-18](../../go/internal/web/html_handler.go#L16-L18) — placeholder handler renders a `PageData{Active, Build}` shell only. Needs to gain access to the data layer.
- [go/internal/web/render.go:11](../../go/internal/web/render.go#L11) — `PageData` struct is currently `{Active, Build}` only. Needs to extend or be wrapped by a typed adults view model.
- [go/internal/web/api/handler.go:36-45](../../go/internal/web/api/handler.go#L36-L45) — `ServeAdults` already does `loadAll``Reconcile``buildAdultsResponse`. We will extract that body into an exported method so the HTML handler can reuse it without duplication.
- [go/internal/web/api/build_adults.go:17](../../go/internal/web/api/build_adults.go#L17) — `buildAdultsResponse(...) AdultsResponse` returns everything the template needs (`Months`, `Results`, `Totals`, `Credits`, `Debts`, `Unmatched`, `BankAccount`, `CurrentMonth`, …). We pass this struct straight into the template.
- [go/internal/web/templates/adults.tmpl](../../go/internal/web/templates/adults.tmpl) — currently a 5-line placeholder. Replace contents.
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — all selectors needed (`.cell-ok`, `.cell-unpaid`, `.cell-unpaid-current`, `.cell-overridden`, `.unmatched-row`, `.filter-container`, `.pay-btn`, `.member-row`, …) already present from M6.1.
Reference for parity: [templates/adults.html](../../templates/adults.html) (Python source). Sections to mirror in markup terms:
- Reconcile table — [adults.html:534-585](../../templates/adults.html#L534-L585)
- Totals row — [adults.html:571-582](../../templates/adults.html#L571-L582)
- Credits / Debts — [adults.html:587-609](../../templates/adults.html#L587-L609)
- Unmatched — [adults.html:611-629](../../templates/adults.html#L611-L629)
- Filter controls — [adults.html:515-532](../../templates/adults.html#L515-L532)
- Filter JS (name + month range) — [adults.html:1019-1051](../../templates/adults.html#L1019-L1051)
The member-detail modal, Pay-preview modal, and JSON-hydrated `memberData`/`rawPaymentsByPerson` globals are explicitly **out of scope for M6.2** — they belong to M6.5 (modal JS).
## Approach
1. **Share data assembly between HTML and JSON** — extract `ServeAdults`'s body into `(*api.Handler).AssembleAdults(ctx) (AdultsResponse, error)` and have `ServeAdults` call it. The HTML handler then calls the same method, keeping `/adults` and `/api/adults` byte-identical in semantics (same loaded data, same reconcile, same view model).
2. **Wire HTMLHandler to data** — extend `HTMLHandler` with an `apiHandler *api.Handler` field and pass it from `Run()`. `ServeAdults` becomes: `AssembleAdults` → on error render an error body (or 500) → render `adults.tmpl` with a typed view model wrapping `PageData` + `api.AdultsResponse`.
3. **Per-page typed view model** — add `AdultsPageData{ PageData; Data api.AdultsResponse }` in [render.go](../../go/internal/web/render.go) (or a new `view.go`). Template references `.Active`, `.Build` (chrome) and `.Data.Results`, `.Data.Totals`, `.Data.Months`, etc. Keeps the chrome contract for nav/footer untouched.
4. **Author `adults.tmpl`** — port markup from [templates/adults.html:489-629](../../templates/adults.html#L489-L629). Notable mechanics:
- Filter controls block with `<input id="nameFilter">`, `<select id="fromMonth">`, `<select id="toMonth">`, `Apply` / `All` buttons. Month dropdown options rendered server-side from `.Data.Months` (no client-side hydration needed for filters).
- Reconcile table iterates `.Data.Results`; each row's months iterate `row.Months`. Cell `<td>` gets class `cell-{{.Status}}`, plus `cell-unpaid-current` when `.RawMonth >= .Data.CurrentMonth`, plus `cell-overridden` when `.Overridden`. Cells carry `data-month-idx="{{$i}}"` so the filter script can hide columns.
- Per-cell Pay button visible on hover when `(unpaid|partial)` and `RawMonth < CurrentMonth``<a class="pay-btn" href="/qr?...">Pay</a>` with the QR query string built server-side via a template helper (see Key design notes).
- Per-row "Pay All" button when `.PayableAmount > 0`, same href construction using `.UnpaidPeriods` for display and `.RawUnpaidPeriods` for the QR message.
- Totals `<tr>` iterates `.Data.Totals`, classes `total-cell-{{.Status}}`.
- Credits / Debts / Unmatched sections rendered conditionally on non-empty slices.
5. **Tiny filter script** — new [go/internal/web/static/js/filters.js](../../go/internal/web/static/js/filters.js):
- `nameFilter` `input` event: NFD-normalize + lowercase + substring match against `.member-row [data-name]` (or row's first cell text); toggle `display:none` on non-matches.
- `fromMonth` / `toMonth` `change` event + `Apply` button: read selected `data-month-idx` range, toggle `month-hidden` class on `[data-month-idx]` `<th>`/`<td>` outside the range.
- `All` button: clear filters, restore all rows/cells.
- Match Python's behaviour byte-for-byte from [adults.html:864-1051](../../templates/adults.html#L864-L1051) but trimmed of modal/`memberData` calls.
- Loaded via `<script src="/static/js/filters.js" defer></script>` from the adults template (or base, if juniors will reuse it — yes, it should be in base or a content-block include).
6. **`<a href="/qr?…">` Pay buttons now, modal in M6.5** — the M6.6 milestone adds `/qr`. Until then, hrefs return 404. M6.5 will layer modal preview behaviour on top by wrapping clicks; the markup stays the same. This is the simplest staged rollout.
7. **Template helper for QR href** — add a `funcMap` to `Renderer` with `qrHref(account, amount, name, month string) string` (and a periods-list variant) that builds `/qr?account=…&amount=…&message=…` with proper URL encoding. Implementation: `net/url.Values{}.Encode()`. This keeps URL-construction logic out of the template syntax (`html/template` URL escaping isn't enough — query-param building deserves a Go helper).
8. **Active-link key**`Active: "adults"` already set; nav highlighting works as-is (verified in M6.1 smoke test).
## Files to create / modify
```
go/internal/web/
├── api/
│ └── handler.go MODIFY — extract ServeAdults body to AssembleAdults(ctx)
├── html_handler.go MODIFY — hold *api.Handler; ServeAdults loads + renders
├── render.go MODIFY — add AdultsPageData type; add funcMap with qrHref()
├── server.go MODIFY — pass *api.Handler into NewHTMLHandler
├── html_handler_test.go MODIFY — add adults markup-level assertions w/ stub Sources
├── templates/
│ └── adults.tmpl MODIFY — replace placeholder w/ full table + filters + sections
└── static/
└── js/
└── filters.js NEW — name + month-range client-side filtering
```
No new packages; no domain or wire-type changes.
## Key design notes
- **Reuse `buildAdultsResponse` verbatim** — no parallel "view model" needed. `AdultsResponse` already has every field the template wants. `/adults` and `/api/adults` consume the same struct.
- **Extracting `AssembleAdults`** preserves the parity contract: anything that changes the JSON also changes the HTML, by construction. (It also sets up the same pattern for M6.3 juniors and M6.4 payments.)
- **Filter UX is DOM-driven**, no JSON hydration. `member_data` / `raw_payments` JSON payloads stay deferred to M6.5 (modal needs them; filters do not).
- **`current_month` boundary is server-side** — `AdultsResponse.CurrentMonth` (set from `time.Now().Format("2006-01")` in `loadAll`) is what the template compares `RawMonth` against for `cell-unpaid-current` styling and Pay-button visibility. Same value Python passes through `vm["current_month"]`.
- **`html/template` autoescaping is sufficient** for member names, sender, message, etc. — but Pay-button URLs need explicit `url.Values` encoding (Czech names have diacritics and spaces). Hence the `qrHref` funcMap helper.
- **Error rendering**: if `AssembleAdults` fails, render the base shell with an error banner inside `content` rather than `http.Error`. Keeps nav visible so the user can navigate away. Match Python's `"No data."` fallback at [app.py:233](../../app.py#L233) for empty results.
- **No JS framework, no bundler** — filter script is plain ES2020, ~80 lines, inline-readable. Matches M6.1's "no JS in M6.1" follow-through (we add JS in M6.2, but kept minimal).
- **NFD-normalize** in JS via `s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()` to match Python's `unicodedata.normalize('NFD', ...)`. Python ref: [adults.html:868-873](../../templates/adults.html#L868-L873).
- **Filter persistence is out of scope** — Python's filters are session-only (no localStorage). Same here.
## Critical files to read
- [go/internal/web/api/build_adults.go](../../go/internal/web/api/build_adults.go) — full view model already built; the template just iterates over `AdultsResponse`.
- [go/internal/web/api/types.go:91-122](../../go/internal/web/api/types.go#L91-L122) — `MonthCell`, `TotalCell`, `MemberRow`, `Credit` shapes.
- [go/internal/web/api/handler.go:36-96](../../go/internal/web/api/handler.go#L36-L96) — pattern for `loadAll` + `Reconcile` + `buildAdultsResponse`; the bit to refactor into `AssembleAdults`.
- [go/internal/web/static/css/app.css](../../go/internal/web/static/css/app.css) — class names available; no edits needed.
- [templates/adults.html:489-629](../../templates/adults.html#L489-L629) — markup reference for filter controls, table, totals, credits/debts, unmatched.
- [templates/adults.html:864-1051](../../templates/adults.html#L864-L1051) — JS reference for filter behaviour (NFD-normalize, month range hiding, Apply/All).
- [tests/test_app.py:50-75](../../tests/test_app.py#L50-L75) — `test_adults_route` in Python, mirror its assertions in Go: status 200, body contains member name + `750/750 CZK (4)` + `Adults Dashboard`, does not contain `OK`.
## Verification
End-to-end smoke after `make go-build`:
1. `make web-go &` — server boots, no template-parse errors.
2. `curl -i localhost:8080/adults``200`, `Content-Type: text/html`.
3. Browser at `http://localhost:8080/adults` against real data:
- Table renders with one row per adult member, one column per month, totals row at bottom.
- Cell colors match `/api/adults` JSON — pick a `Credits` member from JSON, confirm green-ish status; pick a `Debts` member, confirm red-ish.
- Credits / Debts / Unmatched sections render with content matching the JSON arrays for those keys.
- Per-cell `Pay` buttons appear on hover for past-month unpaid/partial cells; href contains `/qr?account=...&amount=...&message=`.
- Per-row "Pay All" button shows for members with `payable_amount > 0`.
4. Filters:
- Type a partial member name in `#nameFilter` → only matching rows visible. Test with diacritics (e.g. `nemec` matches `Němec`).
- Pick `fromMonth=2026-02`, `toMonth=2026-04`, click `Apply` → only those columns visible (table + totals row).
- Click `All` → everything restored.
5. `curl -s localhost:8080/adults | grep -F 'Adults Dashboard'` matches; `grep -F 'Coming in M6.2'` does **not** match.
6. `make go-test``html_handler_test.go` adults assertions pass with stub Sources fixture (replicating `test_adults_route` from Python).
7. `make go-lint` clean.
8. `make parity` still green — `/api/adults` JSON unchanged because `AssembleAdults` extraction is a pure refactor.
9. CHANGELOG entry per CLAUDE.md (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
10. Tick M6.2 in [progress tracker §M6.2](2026-05-03-2349-go-backend-rewrite-progress.md#L114) with the merge commit SHA.
## Branch & MR
Branch `feat/go-m6-2-adults-page` per CLAUDE.md branch-per-feature workflow. Commit, push with `-u`, then:
```bash
tea pr create \
--title "feat(go): M6.2 — adults page (table, filters, credits/debts/unmatched, Pay buttons)" \
--description "..." \
--base main \
--head feat/go-m6-2-adults-page
```
Print the PR URL for the user. User merges in Gitea browser.

View File

@@ -0,0 +1,142 @@
# Period selector missing older months — diagnosis
## Context
User reports the "From / To" period selector on the **adults** dashboard now
shows **Dec 2025** as the oldest available period. The older production
deployment shows Sep+Oct 2025, Nov 2025, Dec 2025+Jan 2026 (merged labels) —
i.e. data going back to September 2025. The user wants to know what went
sideways. Confirmed: the dropdown options on both Python and Go genuinely
start at Dec 2025, not just the default selection — Sep/Oct/Nov 2025 are not
in the list at all.
## Root cause — the live adults sheet header is missing those columns
The fresh cache files at `tmp/go/attendance_regular_cache.json` (raw rows from
Google Sheets, modifiedTime `2026-05-06T22:30:02Z`, cached `2026-05-08T00:26`)
contain the actual header row for the adults tab (gid=0):
```text
['FUJ tréninky úterý 20:30-22:00', '', '', '02.12.2025', '09.12.2025',
'16.12.2025', '06.01.2026', '13.01.2026', '20.01.2026', '27.01.2026',
'03.02.2026', '10.02.2026', '17.02.2026', '24.02.2026', '03.03.2026',
'10.03.2026', '17.03.2026', '23.03.2026', '31.03.2026', '13.04.2026',
'20.04.2026', '27.04.2026', '04.05.2026', '', '', '']
```
**The first date column in the live adults sheet is `02.12.2025` (Dec 2 2025).**
There are no September, October, or November 2025 columns in the header at
all. Both backends parse this faithfully (no slicing, no cutoff anywhere) and
correctly produce `sortedMonths = ["2025-12", "2026-01", …, "2026-05"]`.
The juniors sheet (different tab, `JUNIOR_SHEET_GID`) is **fine** — its header
still contains `['', 'tier', '', '15.09.2025', '13.10.2025', '20.10.2025',
'03.11.2025', '24.11.2025', '10.11.2025', '17.11.2025', '01.12.2025', …]`. So
the juniors page still shows Sep+Oct / Nov / Dec+Jan correctly.
So this is a **data issue in the adults attendance Google Sheet**: at some
point between when production's cache was last warmed (showing SepNov) and
2026-05-06, somebody (or some action) removed the columns for September,
October, and November 2025 from the adults tab header.
The code is doing exactly what it should. There is no parser regression.
## What to do
### 1. Restore the missing date columns in the adults attendance sheet
The fix lives in Google Sheets, not in the codebase. Options, in order of
preference:
- **(a) Use Sheets version history.** File → Version history → See version
history; find a version from before the columns were dropped (anything
before about Mar 2026 should still have them). Copy the Sep/Oct/Nov 2025
date column headers and the `TRUE/FALSE` cells underneath them back into
the current sheet. Only restore the 11 missing date columns; do not
full-revert (you'd lose every change since then).
- **(b) Pull from the production server's cache.** The production deployment
evidently still has the older cache, since its dashboard renders those
months. SSH there, copy `tmp/attendance_regular_cache.json`, and you can
reconstruct the per-member Sep/Oct/Nov attendance counts from the
`data[*][2]` map (keys `"2025-09"`, `"2025-10"`, `"2025-11"`). Re-enter
those into the sheet manually as date columns + `TRUE` cells — tedious but
deterministic.
- **(c) Accept the loss.** If the older columns aren't recoverable, the
dashboard correctly reflects what the sheet contains; nothing more to do.
Which to pick depends on whether those months still need to be billed /
reconciled.
### 2. Restore `ADULT_MERGED_MONTHS` (user confirmed this was unintentional)
Independent of the sheet issue: commit `1257f0d` (Mar 9 2026) commented out
the adult merge mappings. Once the Sep/Oct/Nov columns are back in the sheet,
the dashboard would still show them as separate periods instead of the
production-style "Sep+Oct 2025" and "Dec 2025+Jan 2026" merged labels.
User confirmed this was unintentional. Two files to update:
- [scripts/attendance.py:32-35](scripts/attendance.py#L32-L35) — uncomment
the two mappings:
```python
ADULT_MERGED_MONTHS = {
"2025-12": "2026-01", # keys are merged into values
"2025-09": "2025-10",
}
```
- [go/internal/services/membership/sources.go:30](go/internal/services/membership/sources.go#L30) — mirror the same:
```go
var AdultMergedMonths = map[string]string{
"2025-12": "2026-01",
"2025-09": "2025-10",
}
```
After this change, hit `POST /flush-cache` on each backend so the in-process
post-processed adults cache is rebuilt with the new mapping.
### 3. (Optional, separate) Fix the JS auto-default that hides older months
This is **not** the cause of the user's current symptom (that's the sheet
issue), but it will become a UX issue once Sep/Oct/Nov columns are restored:
the Python frontend's `defaultFrom = Math.max(0, maxMonthIdx - 4)` will still
default the From-selector to ~5 months before the latest column on every page
load, hiding restored older months until the user manually picks them.
- [templates/adults.html:1047](templates/adults.html#L1047) — `var defaultFrom = Math.max(0, maxMonthIdx - 4);`
- [templates/juniors.html:1028](templates/juniors.html#L1028) — same line.
Drop those four lines (`defaultFrom`, `fromSelect.value = defaultFrom`,
`toSelect.value = maxMonthIdx`, `applyMonthFilter()`) so the page loads with
all non-future months visible — matching the Go side, which only calls
`hideFutureMonths()` and leaves From at its first option.
Recommend bundling this with step 2 since they touch related UI.
## Verification
1. **After step 1** — `POST /flush-cache` on Python and Go backends; reload
`/adults` on each. Confirm the dropdown now lists Sep/Oct/Nov 2025.
2. **After step 2** — reload `/adults`. Confirm the dropdown shows
"Sep+Oct 2025" as a single period and "Dec 2025+Jan 2026" as a single
period. (Still requires the sheet columns to exist.)
3. **After step 3** — reload `/adults` and `/juniors` on Python. Confirm the
table renders all non-future months on first load (Sep 2025 through the
current month) instead of starting at Dec 2025.
4. **Parity check** — `make parity` should report zero diffs between Python
and Go on `/api/adults` and `/api/juniors`.
## Critical files referenced
- `tmp/go/attendance_regular_cache.json` — current adults sheet rows
(evidence: header starts at `02.12.2025`).
- `tmp/go/attendance_juniors_cache.json` — current juniors sheet rows
(header still has `15.09.2025`).
- [scripts/attendance.py](scripts/attendance.py) — `ADULT_MERGED_MONTHS`
empty after `1257f0d`; `parse_dates` / `group_by_month` faithful.
- [go/internal/services/membership/sources.go](go/internal/services/membership/sources.go) — Go counterpart, same shape.
- [templates/adults.html](templates/adults.html), [templates/juniors.html](templates/juniors.html) — JS onload `defaultFrom = -4` issue (step 3).
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — Go filter UI (already correct, no changes).

View File

@@ -0,0 +1,67 @@
# M6.3 — Go-native `/juniors` page
## Context
The Go rewrite is in milestone M6 (Go-native HTML frontend). M6.2 shipped the `/adults` page in commit `c85748b`; the [progress tracker](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) line 115 names M6.3 as:
> `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering
The Go side already has `buildJuniorsResponse` (M5.2) that produces the full JSON view model, and the JuniorsResponse uses the same `MemberRow`/`MonthCell`/`TotalCell` types as `AdultsResponse`. Critically, `buildJuniorMemberRow` ([go/internal/web/api/build_juniors.go:142](../../srv/personal/fuj-management/go/internal/web/api/build_juniors.go#L142)) already bakes the J/A breakdown (e.g. `"3/500 CZK (4:2J,2A)"`) and the `"?"` sentinel into `MonthCell.Text` — no template-level junior-specific logic is required for the M6.3 table.
Today `juniors.tmpl` is a 4-line "Coming in M6.3" placeholder and `HTMLHandler.ServeJuniors` ([html_handler.go:35](../../srv/personal/fuj-management/go/internal/web/html_handler.go#L35)) just renders that placeholder. Goal of M6.3: produce a working `/juniors` page that mirrors the structure of `/adults` and reaches feature parity with the Python `/juniors` route.
## Approach
Mirror the M6.2 pattern exactly:
1. **Extract `AssembleJuniors(ctx)`** from the JSON handler so HTML + JSON share one view-model assembly path (the parity contract).
2. **Add `JuniorsPageData`** wrapper type next to `AdultsPageData` in `render.go`.
3. **Replace `juniors.tmpl`** with an adults-shaped layout. Because `JuniorsResponse` shares the same field names used by `adults.tmpl` (`Months`, `RawMonths`, `Results`, `Totals`, `Credits`, `Debts`, `AttendanceURL`, `PaymentsURL`, `BankAccount`, `CurrentMonth`), the template body is essentially copy/paste with one parity tweak (see "Decisions" below).
4. **Wire `HTMLHandler.ServeJuniors`** to call `AssembleJuniors` and render the new template, parallel to `ServeAdults`.
No new CSS, no new JS — `filters.js` and the `cell-*` classes (lifted in M6.1) already cover juniors.
## Files to modify
| File | Change |
| --- | --- |
| [go/internal/web/api/handler.go](../../srv/personal/fuj-management/go/internal/web/api/handler.go) | Add `AssembleJuniors(ctx) (JuniorsResponse, error)`; refactor existing `ServeJuniors` to call it (mirrors `AssembleAdults` at line 47). |
| [go/internal/web/render.go](../../srv/personal/fuj-management/go/internal/web/render.go) | Add `JuniorsPageData` struct (PageData + `Data api.JuniorsResponse` + `Error string`). |
| [go/internal/web/html_handler.go](../../srv/personal/fuj-management/go/internal/web/html_handler.go) | Replace stub `ServeJuniors` with the same shape as `ServeAdults` (lines 2033). |
| [go/internal/web/templates/juniors.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/juniors.tmpl) | Replace placeholder with a structurally identical copy of [adults.tmpl](../../srv/personal/fuj-management/go/internal/web/templates/adults.tmpl), with the title swapped to "Juniors Dashboard" and the Unmatched section removed (parity with Python). |
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) | Tick `M6.3` once merged, append merge SHA. |
| [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) | New top-most entry per CLAUDE.md format. |
## Decisions captured
- **No "Unmatched Transactions" section on `/juniors`.** Python's [templates/juniors.html](../../srv/personal/fuj-management/templates/juniors.html) renders only Credits + Debts (no Unmatched block). Although `JuniorsResponse.Unmatched` is populated, parity says we don't render it on juniors. The data stays available for the modal in M6.5.
- **Reuse `qrHref` / `qrHrefAll` template funcs** from [render.go:3557](../../srv/personal/fuj-management/go/internal/web/render.go#L35-L57). They are name-only (not adult-specific) and already on `tmplFuncs`.
- **Reuse the existing `cell-empty / cell-unpaid / cell-unpaid-current / cell-ok / cell-overridden` cell-class branching** from `adults.tmpl` lines 6067 verbatim. The "?" sentinel is rendered as plain `{{$cell.Text}}` text — no template branch needed because `buildJuniorMemberRow` produces `"?"` or `"? CZK"` already.
- **No template helper extraction** (e.g. shared `_table_section.tmpl`). The two templates will be near-duplicates, but extracting a shared partial now is premature — wait until M6.4 or later when patterns settle. M6.2 didn't extract one either.
- **Branch**: `feat/go-m6-3-juniors-page`, MR via `tea pr create` per CLAUDE.md.
## Verification
End-to-end smoke (parallel-running both backends as in M5/M6.2):
1. `cd go && make run` (or whatever target boots the Go server on :8080).
2. Browser-load `http://localhost:8080/juniors`. Confirm:
- Member rows render with cells containing fee + attendance breakdown like `0/500 CZK (3:2J,1A)`.
- At least one `"?"` cell visible (any junior with exactly one session in some month).
- Filter input narrows rows by name; From/To month selects hide columns via `data-month-idx`.
- `Pay` button visible on past-month unpaid cells; clicking links to `/qr?...`. (The QR endpoint itself lands in M6.6 — only the link target is in scope here.)
- `Pay All` visible on rows with `Balance < 0`.
- TOTAL row sums correctly per month.
- Credits + Debts sections render when present; no Unmatched section.
3. JSON parity: `make parity` against the M3 fixture corpus must still report zero non-allowlisted diffs for `/api/juniors` (the `AssembleJuniors` extraction must not change wire output).
4. Compare side-by-side against Python `/juniors` on :5001 for the same fixture; the table cells should match cell-for-cell modulo the known M5 allowlist.
## Out of scope
- Modal (`[i]` info button + `#memberModal`) — that is M6.5.
- QR endpoint, `/sync-bank`, `/flush-cache`, `/version` pages — M6.6.
- Embed.FS deploy verification — M6.7.
## Plan-file relocation
Per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) "Plans" section, plan files belong in `docs/plans/YYYY-MM-DD-HHMM-<slug>.md` inside the repo. Plan mode forced this draft into `~/.claude/plans/`; first step after ExitPlanMode is to copy this file to `docs/plans/2026-05-08-HHMM-go-m6-3-juniors-page.md` (resolving the timestamp at that moment) and commit it on the feature branch alongside the implementation.

View File

@@ -11,6 +11,7 @@ import (
"fuj-management/go/internal/services/banksync" "fuj-management/go/internal/services/banksync"
"fuj-management/go/internal/services/membership" "fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web" "fuj-management/go/internal/web"
"log/slog"
"os" "os"
"time" "time"
) )
@@ -28,6 +29,9 @@ func main() {
os.Exit(2) os.Exit(2)
} }
// Honour LOG_LEVEL for slog calls in any package (e.g. internal/io/fio debug logs).
slog.SetDefault(logging.New(os.Getenv("LOG_LEVEL")))
cmd, args := os.Args[1], os.Args[2:] cmd, args := os.Args[1], os.Args[2:]
switch cmd { switch cmd {
@@ -69,10 +73,18 @@ func serverCmd(args []string) {
cfg.ServerAddr = *addr cfg.ServerAddr = *addr
} }
ctx := context.Background()
logger := logging.New(cfg.LogLevel) logger := logging.New(cfg.LogLevel)
sources, err := membership.NewSources(ctx, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
os.Exit(1)
}
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate} build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
if err := web.Run(logger, cfg.ServerAddr, build); err != nil { if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
@@ -135,8 +147,9 @@ func syncCmd(args []string) {
toStr := fs.String("to", "", "end date YYYY-MM-DD") toStr := fs.String("to", "", "end date YYYY-MM-DD")
sort := fs.Bool("sort", true, "sort sheet by date after appending") sort := fs.Bool("sort", true, "sort sheet by date after appending")
dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet") dryRun := fs.Bool("dry-run", false, "print planned writes without modifying the sheet")
printFioTable := fs.Bool("print-fio-table", false, "with --dry-run: print aligned table of every Fio transaction with NEW/DUP status")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run]") fmt.Fprintln(os.Stderr, "usage: fuj sync [--days N] [--from YYYY-MM-DD --to YYYY-MM-DD] [--sort] [--dry-run] [--print-fio-table]")
fs.PrintDefaults() fs.PrintDefaults()
} }
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@@ -154,7 +167,7 @@ func syncCmd(args []string) {
} }
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil) fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun} opts := banksync.SyncOpts{Days: *days, Sort: *sort, DryRun: *dryRun, PrintFioTable: *printFioTable}
if *fromStr != "" && *toStr != "" { if *fromStr != "" && *toStr != "" {
opts.From, err = time.Parse("2006-01-02", *fromStr) opts.From, err = time.Parse("2006-01-02", *fromStr)
if err != nil { if err != nil {

133
go/cmd/parity/main.go Normal file
View File

@@ -0,0 +1,133 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)
// /api/version is intentionally excluded — it returns each binary's own build
// identity (tag/commit/build_date), which differs by design between independently
// built backends. Pass -route /api/version to inspect it manually.
var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
// defaultAllowlist holds dotted key paths to strip before diffing.
// render_time.total is forward-compatible insurance: today it lives in the Jinja
// template context only (app.py inject_render_time) and is logged via
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
// side ever surfaces it under a render_time envelope, the scrubber handles it.
var defaultAllowlist = []string{"render_time.total"}
func main() {
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
flag.Parse()
client := &http.Client{Timeout: *timeout}
targets := allRoutes
if *route != "" {
targets = []string{*route}
}
matched, diffs, errs := 0, 0, 0
for _, r := range targets {
pyMap, err1 := fetch(client, *pyURL+r)
goMap, err2 := fetch(client, *goURL+r)
if err1 != nil || err2 != nil {
fmt.Printf("=== %s ===\n", r)
if err1 != nil {
fmt.Printf("ERROR (py): %v\n", err1)
}
if err2 != nil {
fmt.Printf("ERROR (go): %v\n", err2)
}
fmt.Println()
errs++
continue
}
scrub(pyMap, defaultAllowlist)
scrub(goMap, defaultAllowlist)
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
if diff == "" {
fmt.Printf("=== %s ===\nOK\n\n", r)
matched++
} else {
fmt.Printf("=== %s ===\n%s\n", r, diff)
diffs++
}
}
total := len(targets)
fmt.Printf("parity: %d/%d routes match", matched, total)
if diffs > 0 {
fmt.Printf(", %d diff", diffs)
if diffs > 1 {
fmt.Print("s")
}
}
if errs > 0 {
fmt.Printf(", %d error", errs)
if errs > 1 {
fmt.Print("s")
}
}
fmt.Println()
if errs > 0 {
os.Exit(2)
}
if diffs > 0 {
os.Exit(1)
}
}
func fetch(client *http.Client, url string) (map[string]any, error) {
resp, err := client.Get(url) //nolint:noctx
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
return nil, fmt.Errorf("decode JSON: %w", err)
}
return m, nil
}
// scrub removes keys from m whose dotted paths appear in paths.
// A bare segment (no dot) deletes a top-level key.
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
func scrub(m map[string]any, paths []string) {
for _, path := range paths {
parts := strings.SplitN(path, ".", 2)
if len(parts) == 1 {
delete(m, parts[0])
} else {
if child, ok := m[parts[0]].(map[string]any); ok {
delete(child, parts[1])
}
}
}
}

View File

@@ -0,0 +1,57 @@
package main
import "testing"
func TestScrubTopLevel(t *testing.T) {
m := map[string]any{
"build_meta": map[string]any{"tag": "v1"},
"other": "keep",
}
scrub(m, []string{"build_meta"})
if _, ok := m["build_meta"]; ok {
t.Error("expected build_meta to be removed")
}
if m["other"] != "keep" {
t.Error("expected other to be preserved")
}
}
func TestScrubNested(t *testing.T) {
m := map[string]any{
"render_time": map[string]any{
"total": "0.123",
"breakdown": "fetch:0.1s",
},
"other": "keep",
}
scrub(m, []string{"render_time.total"})
rt, ok := m["render_time"].(map[string]any)
if !ok {
t.Fatal("render_time should still be present")
}
if _, ok := rt["total"]; ok {
t.Error("expected render_time.total to be removed")
}
if rt["breakdown"] != "fetch:0.1s" {
t.Error("expected render_time.breakdown to be preserved")
}
if m["other"] != "keep" {
t.Error("expected other to be preserved")
}
}
func TestScrubMissingPath(t *testing.T) {
m := map[string]any{"foo": "bar"}
scrub(m, []string{"nonexistent", "render_time.total"})
if m["foo"] != "bar" {
t.Error("expected foo to be preserved")
}
}
func TestScrubNestedParentNotMap(t *testing.T) {
m := map[string]any{"render_time": "not-a-map"}
scrub(m, []string{"render_time.total"})
if m["render_time"] != "not-a-map" {
t.Error("expected render_time to be unchanged when it is not a map")
}
}

View File

@@ -3,6 +3,8 @@ module fuj-management/go
go 1.26.1 go 1.26.1
require ( require (
github.com/google/go-cmp v0.7.0
github.com/invopop/jsonschema v0.14.0
golang.org/x/net v0.53.0 golang.org/x/net v0.53.0
golang.org/x/text v0.36.0 golang.org/x/text v0.36.0
google.golang.org/api v0.278.0 google.golang.org/api v0.278.0
@@ -12,6 +14,8 @@ require (
cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
@@ -20,11 +24,13 @@ require (
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.50.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect

View File

@@ -4,6 +4,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -27,6 +31,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5Ugt
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -45,6 +53,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=

View File

@@ -50,7 +50,7 @@ func Load() Config {
return Config{ return Config{
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"), CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"), BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
CacheDir: env("CACHE_DIR", "tmp"), CacheDir: env("CACHE_DIR", "tmp/go"),
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300), CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300), CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10), DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),

View File

@@ -20,10 +20,13 @@ type Exception struct {
Note string Note string
} }
// FeeData holds the expected fee and attendance count for one member in one month. // FeeData holds the expected fee and attendance data for one member in one month.
type FeeData struct { type FeeData struct {
Expected int Expected int
Attendance int IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
Attendance int
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
} }
// Member is one row from the attendance sheet. // Member is one row from the attendance sheet.
@@ -39,11 +42,15 @@ type Member struct {
type Transaction struct { type Transaction struct {
Date string Date string
Amount float64 Amount float64
ManualFix string // "manual fix" column; non-empty disables re-inference
Person string // comma-separated canonical names (empty → use inference) Person string // comma-separated canonical names (empty → use inference)
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference) Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
InferredAmount *float64 // nil → fall back to Amount InferredAmount *float64 // nil → fall back to Amount
Sender string Sender string
VS string // Variabilní symbol (Czech variable payment symbol)
Message string Message string
BankID string
SyncID string
UserID string UserID string
} }
@@ -69,8 +76,11 @@ type OtherEntry struct {
// MonthData is the ledger state for one member in one month. // MonthData is the ledger state for one member in one month.
type MonthData struct { type MonthData struct {
Expected int Expected int
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
OriginalExpected int OriginalExpected int
AttendanceCount int AttendanceCount int
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
Exception *Exception Exception *Exception
Paid float64 Paid float64
Transactions []TxEntry Transactions []TxEntry
@@ -173,8 +183,11 @@ func Reconcile(
ledger[name][m] = MonthData{ ledger[name][m] = MonthData{
Expected: expected, Expected: expected,
IsUnknown: fd.IsUnknown,
OriginalExpected: originalExpected, OriginalExpected: originalExpected,
AttendanceCount: attendanceCount, AttendanceCount: attendanceCount,
JuniorAttendance: fd.JuniorAttendance,
AdultAttendance: fd.AdultAttendance,
Exception: exInfo, Exception: exInfo,
Paid: 0, Paid: 0,
Transactions: []TxEntry{}, Transactions: []TxEntry{},

View File

@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
func TestReconcileExceptionOverride(t *testing.T) { func TestReconcileExceptionOverride(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
exceptions := map[ExceptionKey]Exception{ exceptions := map[ExceptionKey]Exception{
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"}, {Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
} }
@@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) {
func TestReconcileFallbackToAttendance(t *testing.T) { func TestReconcileFallbackToAttendance(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
members := []Member{{ members := []Member{{
Name: "Alice", Tier: "A", Name: "Alice", Tier: "A",
Fees: map[string]FeeData{ Fees: map[string]FeeData{
"2026-02": {750, 3}, "2026-02": {Expected: 750, Attendance: 3},
"2026-03": {350, 3}, "2026-03": {Expected: 350, Attendance: 3},
"2026-04": {150, 2}, "2026-04": {Expected: 150, Attendance: 2},
}, },
}} }}
sortedMonths := []string{"2026-02", "2026-03", "2026-04"} sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{ members := []Member{{
Name: "Alice", Tier: "A", Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}}, Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}},
}} }}
sortedMonths := []string{"2026-01", "2026-02"} sortedMonths := []string{"2026-01", "2026-02"}
@@ -115,7 +115,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{ members := []Member{{
Name: "Alice", Tier: "A", Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}}, Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
}} }}
sortedMonths := []string{"2026-02", "2026-03", "2026-04"} sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
amount := 1250.0 amount := 1250.0
@@ -146,7 +146,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
func TestReconcileSingleMonthUnchanged(t *testing.T) { func TestReconcileSingleMonthUnchanged(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear) result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
@@ -158,8 +158,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) {
func TestReconcileTwoMembersMultiMonth(t *testing.T) { func TestReconcileTwoMembersMultiMonth(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{ members := []Member{
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}}, {Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}}, {Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
} }
sortedMonths := []string{"2026-01", "2026-02"} sortedMonths := []string{"2026-01", "2026-02"}
@@ -180,7 +180,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{ members := []Member{{
Name: "Alice", Tier: "A", Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}}, Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}},
}} }}
sortedMonths := []string{"2026-01", "2026-02"} sortedMonths := []string{"2026-01", "2026-02"}
@@ -197,7 +197,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) { func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}} members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
txFn := func(person string) Transaction { txFn := func(person string) Transaction {
return Transaction{ return Transaction{
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04", Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
@@ -232,7 +232,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) { func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}} members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
txs := []Transaction{{ txs := []Transaction{{
Date: "2026-04-15", Amount: 750, Date: "2026-04-15", Amount: 750,
Person: "Někdo Neznámý", Purpose: "2026-04", Person: "Někdo Neznámý", Purpose: "2026-04",
@@ -252,7 +252,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
// [Go] Test that [?] markers are stripped from the Person field before lookup. // [Go] Test that [?] markers are stripped from the Person field before lookup.
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) { func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{ txs := []Transaction{{
Date: "2026-01-01", Amount: 750, Date: "2026-01-01", Amount: 750,
Person: "[?] Alice", Purpose: "2026-01", Person: "[?] Alice", Purpose: "2026-01",
@@ -269,7 +269,7 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger. // [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
func TestReconcileOtherPurpose(t *testing.T) { func TestReconcileOtherPurpose(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{ txs := []Transaction{{
Date: "2026-01-01", Amount: 300, Date: "2026-01-01", Amount: 300,
Person: "Alice", Purpose: "other:shirt", Person: "Alice", Purpose: "other:shirt",
@@ -297,7 +297,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) { func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
t.Parallel() t.Parallel()
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out). // Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}}
txs := []Transaction{{ txs := []Transaction{{
Date: "2026-01-01", Amount: 1200, Date: "2026-01-01", Amount: 1200,
Person: "Alice", Purpose: "2026-01, 2026-02", Person: "Alice", Purpose: "2026-01, 2026-02",
@@ -322,7 +322,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
// [Go] No person/purpose → inference fallback resolves sender name and date month. // [Go] No person/purpose → inference fallback resolves sender name and date month.
func TestReconcileInferenceFallback(t *testing.T) { func TestReconcileInferenceFallback(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}} members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{ txs := []Transaction{{
Date: "2026-04-15", Amount: 750, Date: "2026-04-15", Amount: 750,
// Person and Purpose are empty → inference path // Person and Purpose are empty → inference path
@@ -340,7 +340,7 @@ func TestReconcileInferenceFallback(t *testing.T) {
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged. // [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) { func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
txs := []Transaction{{ txs := []Transaction{{
Date: "2026-01-01", Amount: 500, Date: "2026-01-01", Amount: 500,
// empty person+purpose and sender name not matching any member // empty person+purpose and sender name not matching any member
@@ -360,7 +360,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
// [Go] Empty transaction list leaves every month at paid=0 and balance=expected. // [Go] Empty transaction list leaves every month at paid=0 and balance=expected.
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) { func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
t.Parallel() t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}} members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear) result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)

View File

@@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"strings"
"time" "time"
) )
@@ -25,6 +27,9 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) (
const layout = "2006-01-02" const layout = "2006-01-02"
url := fmt.Sprintf("https://fioapi.fio.cz/v1/rest/periods/%s/%s/%s/transactions.json", url := fmt.Sprintf("https://fioapi.fio.cz/v1/rest/periods/%s/%s/%s/transactions.json",
c.token, from.Format(layout), to.Format(layout)) c.token, from.Format(layout), to.Format(layout))
slog.Debug("fio api: GET",
"url", strings.Replace(url, c.token, "****", 1),
"from", from.Format(layout), "to", to.Format(layout))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
@@ -35,6 +40,7 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) (
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
slog.Debug("fio api: response", "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fio api: HTTP %d", resp.StatusCode) return nil, fmt.Errorf("fio api: HTTP %d", resp.StatusCode)
} }
@@ -42,7 +48,9 @@ func (c *apiClient) FetchTransactions(ctx context.Context, from, to time.Time) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
return parseAPIResponse(body) txns, err := parseAPIResponse(body)
slog.Debug("fio api: parsed", "body_bytes", len(body), "parsed_count", len(txns))
return txns, err
} }
// fioAPIResponse is the top-level envelope from the Fio JSON API. // fioAPIResponse is the top-level envelope from the Fio JSON API.

View File

@@ -4,6 +4,7 @@ package fio
import ( import (
"context" "context"
"log/slog"
"net/http" "net/http"
"time" "time"
) )
@@ -36,7 +37,9 @@ func New(token, accountNum string, hc httpDoer) Client {
hc = http.DefaultClient hc = http.DefaultClient
} }
if token != "" { if token != "" {
slog.Debug("fio: client selected", "type", "api")
return &apiClient{token: token, hc: hc} return &apiClient{token: token, hc: hc}
} }
slog.Debug("fio: client selected", "type", "transparent", "account_num", accountNum)
return &transparentClient{accountNum: accountNum, hc: hc} return &transparentClient{accountNum: accountNum, hc: hc}
} }

View File

@@ -97,6 +97,9 @@ func TestParseCzechDate(t *testing.T) {
{"10/04/2026", "2026-04-10"}, {"10/04/2026", "2026-04-10"},
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format {"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month {"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
{"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
{"07/05/26", "2026-05-07"}, // slash variant
{"", ""}, {"", ""},
{"invalid", ""}, {"invalid", ""},
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@@ -28,6 +29,10 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time
from.Format("2.1.2006"), from.Format("2.1.2006"),
to.Format("2.1.2006"), to.Format("2.1.2006"),
) )
slog.Debug("fio transparent: GET",
"url", url,
"from", from.Format("2006-01-02"), "to", to.Format("2006-01-02"))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -37,6 +42,7 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time
return nil, err return nil, err
} }
defer resp.Body.Close() defer resp.Body.Close()
slog.Debug("fio transparent: response", "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fio transparent: HTTP %d", resp.StatusCode) return nil, fmt.Errorf("fio transparent: HTTP %d", resp.StatusCode)
} }
@@ -44,6 +50,7 @@ func (c *transparentClient) FetchTransactions(ctx context.Context, from, to time
if err != nil { if err != nil {
return nil, err return nil, err
} }
slog.Debug("fio transparent: body read", "body_bytes", len(body))
return parseTransparentHTML(body) return parseTransparentHTML(body)
} }
@@ -63,6 +70,7 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) {
rows := extractSecondTableRows(body) rows := extractSecondTableRows(body)
var txns []Transaction var txns []Transaction
var droppedBadDate, droppedNonpositive int
for _, row := range rows { for _, row := range rows {
col := func(i int) string { col := func(i int) string {
if i < len(row) { if i < len(row) {
@@ -72,7 +80,12 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) {
} }
dateStr := parseCzechDate(col(tColDate)) dateStr := parseCzechDate(col(tColDate))
amount := parseCzechAmount(col(tColAmount)) amount := parseCzechAmount(col(tColAmount))
if dateStr == "" || amount <= 0 { if dateStr == "" {
droppedBadDate++
continue
}
if amount <= 0 {
droppedNonpositive++
continue continue
} }
txns = append(txns, Transaction{ txns = append(txns, Transaction{
@@ -86,6 +99,11 @@ func parseTransparentHTML(body []byte) ([]Transaction, error) {
BankID: "", // not available on HTML path BankID: "", // not available on HTML path
}) })
} }
slog.Debug("fio transparent: parsed",
"raw_rows", len(rows),
"kept", len(txns),
"dropped_bad_date", droppedBadDate,
"dropped_nonpositive_amount", droppedNonpositive)
return txns, nil return txns, nil
} }
@@ -191,7 +209,10 @@ func hasClass(t ghtml.Token, cls string) bool {
// Returns "" on parse error. // Returns "" on parse error.
func parseCzechDate(s string) string { func parseCzechDate(s string) string {
s = strings.TrimSpace(s) s = strings.TrimSpace(s)
for _, layout := range []string{"2.1.2006", "02.01.2006", "2/1/2006", "02/01/2006"} { for _, layout := range []string{
"2.1.2006", "02.01.2006", "2/1/2006", "02/01/2006",
"2.1.06", "02.01.06", "2/1/06", "02/01/06",
} {
if t, err := time.Parse(layout, s); err == nil { if t, err := time.Parse(layout, s); err == nil {
return t.Format("2006-01-02") return t.Format("2006-01-02")
} }

View File

@@ -0,0 +1,31 @@
package banksync
import (
"fmt"
"fuj-management/go/internal/io/fio"
"io"
"text/tabwriter"
)
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "DATE\tAMOUNT\tSENDER\tVS\tMESSAGE\tBANKID\tSTATUS")
for i, tx := range txns {
status := "NEW"
if existing[syncIDs[i]] {
status = "DUP"
}
fmt.Fprintf(tw, "%s\t%.2f\t%s\t%s\t%s\t%s\t%s\n",
tx.Date, tx.Amount, tx.Sender, tx.VS,
truncRunes(tx.Message, 40), tx.BankID, status)
}
_ = tw.Flush()
}
func truncRunes(s string, n int) string {
rs := []rune(s)
if len(rs) <= n {
return s
}
return string(rs[:n-1]) + "…"
}

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"fuj-management/go/internal/domain/synch" "fuj-management/go/internal/domain/synch"
"fuj-management/go/internal/io/fio" "fuj-management/go/internal/io/fio"
"os"
"strings" "strings"
"time" "time"
) )
@@ -27,10 +28,11 @@ type sheetsWriter interface {
// SyncOpts controls the date window and sort behaviour. // SyncOpts controls the date window and sort behaviour.
type SyncOpts struct { type SyncOpts struct {
Days int // look-back window when From/To are zero Days int // look-back window when From/To are zero
From, To time.Time // explicit window (overrides Days) From, To time.Time // explicit window (overrides Days)
Sort bool // sort the sheet by Date after appending Sort bool // sort the sheet by Date after appending
DryRun bool // print planned writes without modifying the sheet DryRun bool // print planned writes without modifying the sheet
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
} }
// SyncToSheets fetches Fio transactions and appends new ones to the payments sheet. // SyncToSheets fetches Fio transactions and appends new ones to the payments sheet.
@@ -92,14 +94,14 @@ func SyncToSheets(
from.Format("2006-01-02"), to.Format("2006-01-02"), len(txns)) from.Format("2006-01-02"), to.Format("2006-01-02"), len(txns))
} }
// 4. Append new rows. // 4a. Compute Sync IDs for every fetched txn (shared by table-print and row-build).
var newRows [][]any syncIDs := make([]string, len(txns))
for _, tx := range txns { for i, tx := range txns {
currency := tx.Currency currency := tx.Currency
if currency == "" { if currency == "" {
currency = "CZK" currency = "CZK"
} }
id := synch.GenerateSyncID(synch.Transaction{ syncIDs[i] = synch.GenerateSyncID(synch.Transaction{
Date: tx.Date, Date: tx.Date,
Amount: tx.Amount, Amount: tx.Amount,
Currency: currency, Currency: currency,
@@ -108,13 +110,23 @@ func SyncToSheets(
Message: tx.Message, Message: tx.Message,
BankID: tx.BankID, BankID: tx.BankID,
}) })
if existingIDs[id] { }
// 4b. Optional debug table (dry-run only; suppress when nothing was fetched).
if opts.DryRun && opts.PrintFioTable && len(txns) > 0 {
printFioTable(os.Stdout, txns, syncIDs, existingIDs)
}
// 4c. Build new rows.
var newRows [][]any
for i, tx := range txns {
if existingIDs[syncIDs[i]] {
continue continue
} }
newRows = append(newRows, []any{ newRows = append(newRows, []any{
tx.Date, tx.Amount, tx.Date, tx.Amount,
"", "", "", "", // manual fix, Person, Purpose, Inferred Amount "", "", "", "", // manual fix, Person, Purpose, Inferred Amount
tx.Sender, tx.VS, tx.Message, tx.BankID, id, tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i],
}) })
} }

View File

@@ -25,12 +25,14 @@ const (
firstDateCol = 3 firstDateCol = 3
) )
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py. // AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
// Source month → target month (source attendance accumulated into target). // Source month → target month (source attendance accumulated into target).
var adultMergedMonths = map[string]string{} var AdultMergedMonths = map[string]string{
"2025-09": "2025-10",
}
// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py. // JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
var juniorMergedMonths = map[string]string{ var JuniorMergedMonths = map[string]string{
"2025-12": "2026-01", "2025-12": "2026-01",
"2025-09": "2025-10", "2025-09": "2025-10",
} }
@@ -142,7 +144,13 @@ func parseDates(header []string) []struct {
} }
var dt time.Time var dt time.Time
var err error var err error
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} { // Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
// accepts both single-digit and zero-padded inputs against them, so
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
// the same way; the previous "02.01.2006" form silently dropped those
// columns and undercounted attendance.
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
dt, err = time.Parse(fmt_, raw) dt, err = time.Parse(fmt_, raw)
if err == nil { if err == nil {
break break
@@ -195,7 +203,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
return nil, nil, nil return nil, nil, nil
} }
dates := parseDates(rows[0]) dates := parseDates(rows[0])
months := groupByMonth(dates, adultMergedMonths) months := groupByMonth(dates, AdultMergedMonths)
sortedMonths := sortedKeys(months) sortedMonths := sortedKeys(months)
var members []reconcile.Member var members []reconcile.Member
@@ -243,8 +251,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
mainDates := parseDates(adultRows[0]) mainDates := parseDates(adultRows[0])
juniorDates := parseDates(juniorRows[0]) juniorDates := parseDates(juniorRows[0])
mainMonths := groupByMonth(mainDates, juniorMergedMonths) mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
jrMonths := groupByMonth(juniorDates, juniorMergedMonths) jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
allMonths := make(map[string]bool) allMonths := make(map[string]bool)
for m := range mainMonths { for m := range mainMonths {
@@ -337,7 +345,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
if !exp.Unknown { if !exp.Unknown {
fee = exp.Value fee = exp.Value
} }
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total} feeMap[m] = reconcile.FeeData{
Expected: fee,
IsUnknown: exp.Unknown,
Attendance: total,
JuniorAttendance: c.junior,
AdultAttendance: c.adult,
}
} }
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap}) members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
} }
@@ -365,11 +379,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
} }
idxDate := idx("date") idxDate := idx("date")
idxAmount := idx("amount") idxAmount := idx("amount")
idxManualFix := idx("manual fix")
idxPerson := idx("person") idxPerson := idx("person")
idxPurpose := idx("purpose") idxPurpose := idx("purpose")
idxInferred := idx("inferred amount") idxInferred := idx("inferred amount")
idxSender := idx("sender") idxSender := idx("sender")
idxVS := idx("vs")
idxMessage := idx("message") idxMessage := idx("message")
idxBankID := idx("bank id")
idxSyncID := idx("sync id")
for _, label := range []string{"date", "amount", "person", "purpose"} { for _, label := range []string{"date", "amount", "person", "purpose"} {
if idx(label) == -1 { if idx(label) == -1 {
@@ -384,9 +402,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
return fmt.Sprint(row[i]) return fmt.Sprint(row[i])
} }
// getRaw returns row[i] without stringifying — needed for FormatDate to
// dispatch on the underlying numeric type (Sheets returns serial-day
// numbers as float64). Stringifying first defeats that dispatch.
getRaw := func(row []any, i int) any {
if i < 0 || i >= len(row) {
return nil
}
return row[i]
}
var txns []reconcile.Transaction var txns []reconcile.Transaction
for _, row := range rows[1:] { for _, row := range rows[1:] {
dateStr := matching.FormatDate(getVal(row, idxDate)) dateStr := matching.FormatDate(getRaw(row, idxDate))
amountRaw := row[idxAmount] amountRaw := row[idxAmount]
if idxAmount < 0 || idxAmount >= len(row) { if idxAmount < 0 || idxAmount >= len(row) {
amountRaw = "" amountRaw = ""
@@ -403,11 +431,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
txns = append(txns, reconcile.Transaction{ txns = append(txns, reconcile.Transaction{
Date: dateStr, Date: dateStr,
Amount: amount, Amount: amount,
ManualFix: getVal(row, idxManualFix),
Person: getVal(row, idxPerson), Person: getVal(row, idxPerson),
Purpose: getVal(row, idxPurpose), Purpose: getVal(row, idxPurpose),
InferredAmount: inferredAmount, InferredAmount: inferredAmount,
Sender: getVal(row, idxSender), Sender: getVal(row, idxSender),
VS: getVal(row, idxVS),
Message: getVal(row, idxMessage), Message: getVal(row, idxMessage),
BankID: getVal(row, idxBankID),
SyncID: getVal(row, idxSyncID),
}) })
} }
return txns, nil return txns, nil

View File

@@ -46,8 +46,8 @@ func TestLoadAdults(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
// adultMergedMonths is empty so 2025-09 stays as-is // AdultMergedMonths sends 2025-09 → 2025-10
if len(months) != 1 || months[0] != "2025-09" { if len(months) != 1 || months[0] != "2025-10" {
t.Errorf("unexpected months: %v", months) t.Errorf("unexpected months: %v", months)
} }
if len(members) != 2 { if len(members) != 2 {
@@ -55,7 +55,7 @@ func TestLoadAdults(t *testing.T) {
} }
byName := map[string]int{} byName := map[string]int{}
for _, m := range members { for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Attendance byName[m.Name] = m.Fees["2025-10"].Attendance
} }
if byName["Alice"] != 2 { if byName["Alice"] != 2 {
t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"]) t.Errorf("Alice: want 2 sessions, got %d", byName["Alice"])
@@ -73,9 +73,9 @@ func TestLoadAdults_Fee(t *testing.T) {
} }
byName := map[string]int{} byName := map[string]int{}
for _, m := range members { for _, m := range members {
byName[m.Name] = m.Fees["2025-09"].Expected byName[m.Name] = m.Fees["2025-10"].Expected
} }
// 2 sessions in 2025-09 → AdultFeeMonthlyRate["2025-09"] = 750 // 2 sessions land in merged 2025-10 → AdultFeeMonthlyRate["2025-10"] = 750
if byName["Alice"] != 750 { if byName["Alice"] != 750 {
t.Errorf("Alice fee: want 750, got %d", byName["Alice"]) t.Errorf("Alice fee: want 750, got %d", byName["Alice"])
} }
@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
func TestLoadTransactions(t *testing.T) { func TestLoadTransactions(t *testing.T) {
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant. // Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
// serial-day form (float64) — the API returns either depending on cell
// formatting, and FormatDate must handle both.
paymentsKey := config.PaymentsSheetID + "/A1:Z" paymentsKey := config.PaymentsSheetID + "/A1:Z"
sh := &sheets.Fake{Values: map[string][][]any{ sh := &sheets.Fake{Values: map[string][][]any{
paymentsKey: { paymentsKey: {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"}, {"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"}, {"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, {46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
}, },
}} }}
s := buildSources(t, &attendance.Fake{}, sh) s := buildSources(t, &attendance.Fake{}, sh)
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
if txns[0].Amount != 700 { if txns[0].Amount != 700 {
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount) t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
} }
if txns[0].Date != "2026-04-01" {
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
}
if txns[1].Date != "2026-05-05" {
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
}
} }
func TestLoadExceptions(t *testing.T) { func TestLoadExceptions(t *testing.T) {
@@ -165,6 +174,28 @@ func TestLoadExceptions(t *testing.T) {
} }
} }
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
// "02.01.2006" format dropped header cells written without leading zeros
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
// months on the /api/juniors response. Czech sheet authors drop the zero
// pad freely; Python's strptime tolerates it, so the parsers must match.
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
// strptime branch — month-first, day-second.
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
got := parseDates(header)
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
if len(got) != len(want) {
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
}
for i, e := range got {
if e.month != want[i] {
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
}
}
}
// TTL smoke test: second call within TTL must not call fetch again. // TTL smoke test: second call within TTL must not call fetch again.
func TestLoadAdults_CacheHit(t *testing.T) { func TestLoadAdults_CacheHit(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()

View File

@@ -0,0 +1,42 @@
package api
// AdultsMonthData is the reconciled ledger for one adult member in one month.
// Keys match Python's result["members"][name]["months"][YYYY-MM].
type AdultsMonthData struct {
Expected int `json:"expected"`
OriginalExpected int `json:"original_expected"`
AttendanceCount int `json:"attendance_count"`
Exception *ExceptionData `json:"exception"`
Paid float64 `json:"paid"` // float: proportional allocator may produce fractional CZK
Transactions []MemberTxEntry `json:"transactions"`
}
// AdultsMemberData is the reconciled ledger for one adult member.
// Keys match Python's result["members"][name].
type AdultsMemberData struct {
Tier string `json:"tier"`
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM → month data
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
TotalBalance int `json:"total_balance"`
}
// AdultsResponse is the JSON contract for GET /api/adults.
// MemberData, MonthLabels, and RawPayments correspond to the Python view-model
// fields member_data, month_labels_json, and raw_payments_json respectively,
// but as nested objects rather than pre-serialised JSON strings.
type AdultsResponse struct {
Months []string `json:"months"` // display labels
RawMonths []string `json:"raw_months"` // "YYYY-MM"
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]AdultsMemberData `json:"member_data"` // name → ledger
MonthLabels map[string]string `json:"month_labels"` // YYYY-MM → display label
RawPayments map[string][]RawTransaction `json:"raw_payments"` // name → raw sheet rows
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []RawTransaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
}

View File

@@ -0,0 +1,263 @@
package api
import (
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"sort"
"time"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
type monthSums struct{ expected, paid int }
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_adults_view_model.
func buildAdultsResponse(
members []domreconcile.Member,
sortedMonths []string,
result domreconcile.Result,
txns []domreconcile.Transaction,
cfg config.Config,
currentMonth string,
) AdultsResponse {
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
// Collect tier-A names, sorted.
var adultNames []string
allNames := make([]string, 0, len(members))
for _, m := range members {
allNames = append(allNames, m.Name)
if m.Tier == "A" {
adultNames = append(adultNames, m.Name)
}
}
sort.Strings(adultNames)
// Per-month aggregate totals (expected and paid integers).
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
for _, m := range sortedMonths {
monthlyTotals[m] = &monthSums{}
}
var results []MemberRow
for _, name := range adultNames {
mr := result.Members[name]
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
row.UnpaidPeriods = joinComma(unpaidMonths)
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
row.Balance = settledBalance(mr, currentMonth)
row.PayableAmount = max(0, -row.Balance)
results = append(results, row)
}
// Totals row.
totalsCells := make([]TotalCell, len(sortedMonths))
for i, m := range sortedMonths {
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
status := "empty"
if t.expected > 0 || t.paid > 0 {
switch {
case t.paid == t.expected:
status = "ok"
case t.paid < t.expected:
status = "unpaid"
default:
status = "surplus"
}
}
totalsCells[i] = TotalCell{
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
Status: status,
}
}
// Credits and debts (settled balance, past months only).
var credits, debts []Credit
for _, name := range adultNames {
bal := settledBalance(result.Members[name], currentMonth)
if bal > 0 {
credits = append(credits, Credit{Name: name, Amount: bal})
} else if bal < 0 {
debts = append(debts, Credit{Name: name, Amount: -bal})
}
}
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
// member_data: full reconcile output for all members (not just adults).
memberData := make(map[string]AdultsMemberData, len(result.Members))
for name, mr := range result.Members {
months := make(map[string]AdultsMonthData, len(mr.Months))
for m, md := range mr.Months {
var exc *ExceptionData
if md.Exception != nil {
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
}
txEntries := make([]MemberTxEntry, len(md.Transactions))
for i, te := range md.Transactions {
txEntries[i] = memberTxFromDomain(te)
}
months[m] = AdultsMonthData{
Expected: md.Expected,
OriginalExpected: md.OriginalExpected,
AttendanceCount: md.AttendanceCount,
Exception: exc,
Paid: md.Paid,
Transactions: txEntries,
}
}
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
for i, oe := range mr.OtherTransactions {
otherTxs[i] = memberOtherFromDomain(oe)
}
memberData[name] = AdultsMemberData{
Tier: mr.Tier,
Months: months,
OtherTransactions: otherTxs,
TotalBalance: mr.TotalBalance,
}
}
unmatched := make([]RawTransaction, len(result.Unmatched))
for i, tx := range result.Unmatched {
unmatched[i] = rawTxFromDomain(tx)
}
return AdultsResponse{
Months: labelsForMonths(sortedMonths, monthLabels),
RawMonths: sortedMonths,
Results: ensureSlice(results),
Totals: totalsCells,
MemberData: memberData,
MonthLabels: monthLabels,
RawPayments: groupRawPaymentsByPerson(txns, allNames),
Credits: ensureSlice(credits),
Debts: ensureSlice(debts),
Unmatched: unmatched,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.BankAccount,
CurrentMonth: currentMonth,
}
}
func buildAdultMemberRow(
name string,
mr domreconcile.MemberResult,
sortedMonths []string,
monthLabels map[string]string,
currentMonth string,
monthlyTotals map[string]*monthSums,
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
row = MemberRow{Name: name}
for _, m := range sortedMonths {
md, ok := mr.Months[m]
if !ok {
md = domreconcile.MonthData{}
}
paid := int(md.Paid)
expected := md.Expected
if t := monthlyTotals[m]; t != nil {
t.expected += expected
t.paid += paid
}
var feeDisplay string
var isOverridden bool
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
isOverridden = true
if md.AttendanceCount > 0 {
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
} else {
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
}
} else {
if md.AttendanceCount > 0 {
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
} else {
feeDisplay = fmt.Sprintf("%d CZK", expected)
}
}
status := "empty"
cellText := "-"
amountToPay := 0
switch {
case expected > 0:
amountToPay = max(0, expected-paid)
switch {
case paid >= expected:
status = "ok"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
case paid > 0:
status = "partial"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
default:
status = "unpaid"
cellText = fmt.Sprintf("0/%s", feeDisplay)
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
}
case paid > 0:
status = "surplus"
cellText = fmt.Sprintf("PAID %d", paid)
}
tooltip := ""
if expected > 0 || paid > 0 {
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
}
row.Months = append(row.Months, MonthCell{
Text: cellText,
Overridden: isOverridden,
Status: status,
Amount: amountToPay,
Month: monthLabels[m],
RawMonth: m,
Tooltip: tooltip,
})
}
return row, unpaidMonths, rawUnpaidMonths
}
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
func rawMonthLabel(m string) string {
dt, err := time.Parse("2006-01", m)
if err != nil {
return m
}
return dt.Format("01/2006")
}
func joinComma(parts []string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += ", " + p
}
return result
}
func joinPlus(parts []string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for _, p := range parts[1:] {
result += "+" + p
}
return result
}

View File

@@ -0,0 +1,184 @@
package api
import (
"fuj-management/go/internal/domain/czech"
"regexp"
"sort"
"strings"
"time"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// getMonthLabels builds display labels for sortedMonths, merging month names
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
// Mirrors scripts/views.py:get_month_labels.
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
labels := make(map[string]string, len(sortedMonths))
for _, m := range sortedMonths {
dt, err := time.Parse("2006-01", m)
if err != nil {
labels[m] = m
continue
}
var mergedIn []string
for src, dst := range mergedMonths {
if dst == m {
mergedIn = append(mergedIn, src)
}
}
sort.Strings(mergedIn)
if len(mergedIn) == 0 {
labels[m] = dt.Format("Jan 2006")
continue
}
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
sort.Strings(allMonths)
years := map[int]bool{}
for _, x := range allMonths {
if d, err2 := time.Parse("2006-01", x); err2 == nil {
years[d.Year()] = true
}
}
parts := make([]string, 0, len(allMonths))
if len(years) > 1 {
for _, x := range allMonths {
if d, err2 := time.Parse("2006-01", x); err2 == nil {
parts = append(parts, d.Format("Jan 2006"))
}
}
labels[m] = strings.Join(parts, "+")
} else {
for _, x := range allMonths {
if d, err2 := time.Parse("2006-01", x); err2 == nil {
parts = append(parts, d.Format("Jan"))
}
}
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
}
}
return labels
}
// labelsForMonths returns the display labels for sortedMonths in slice order.
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
out := make([]string, len(sortedMonths))
for i, m := range sortedMonths {
out[i] = labels[m]
}
return out
}
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalKey returns a normalized form of a person name used for deduplication.
// Mirrors scripts/match_payments.py:canonical_member_key.
func canonicalKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
}
// groupRawPaymentsByPerson groups transactions by the "person" column,
// canonicalizing names against memberNames where possible.
// Mirrors scripts/views.py:group_payments_by_person (without the
// "Unmatched / Unknown" bucket that is payments-view-specific).
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
canonicalByKey := make(map[string]string, len(memberNames))
for _, n := range memberNames {
k := canonicalKey(n)
if _, exists := canonicalByKey[k]; !exists {
canonicalByKey[k] = n
}
}
grouped := make(map[string][]RawTransaction)
for _, tx := range txns {
person := strings.TrimSpace(tx.Person)
if person == "" {
continue
}
for _, p := range strings.Split(person, ",") {
p = questionMarkRe.ReplaceAllString(p, "")
p = strings.TrimSpace(p)
if p == "" {
continue
}
key := p
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
key = canonical
}
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
}
}
for k := range grouped {
sort.Slice(grouped[k], func(i, j int) bool {
return grouped[k][i].Date > grouped[k][j].Date
})
}
return grouped
}
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
inferredAmount := 0.0
if tx.InferredAmount != nil {
inferredAmount = *tx.InferredAmount
}
return RawTransaction{
Date: tx.Date,
Amount: tx.Amount,
ManualFix: tx.ManualFix,
Person: tx.Person,
Purpose: tx.Purpose,
InferredAmount: inferredAmount,
Sender: tx.Sender,
VS: tx.VS,
Message: tx.Message,
BankID: tx.BankID,
SyncID: tx.SyncID,
}
}
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
return MemberTxEntry{
Amount: te.Amount,
Date: te.Date,
Sender: te.Sender,
Message: te.Message,
Confidence: te.Confidence,
}
}
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
return MemberOtherEntry{
Amount: oe.Amount,
Date: oe.Date,
Sender: oe.Sender,
Message: oe.Message,
Purpose: oe.Purpose,
Confidence: oe.Confidence,
}
}
// settledBalance computes the settled balance: sum of (paid expected) for months
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
// Python's isinstance(exp, int) guard (skips "?" months).
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
total := 0
for m, md := range mr.Months {
if m >= currentMonth || md.IsUnknown {
continue
}
total += int(md.Paid) - md.Expected
}
return total
}
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
// json.Marshal emits [] instead of null.
func ensureSlice[T any](s []T) []T {
if s == nil {
return []T{}
}
return s
}

View File

@@ -0,0 +1,276 @@
package api
import (
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"sort"
"strconv"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_juniors_view_model.
func buildJuniorsResponse(
members []domreconcile.Member,
sortedMonths []string,
result domreconcile.Result,
txns []domreconcile.Transaction,
cfg config.Config,
currentMonth string,
) JuniorsResponse {
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
allNames := make([]string, 0, len(members))
juniorNames := make([]string, 0, len(members))
for _, m := range members {
allNames = append(allNames, m.Name)
juniorNames = append(juniorNames, m.Name)
}
sort.Strings(juniorNames)
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
for _, m := range sortedMonths {
monthlyTotals[m] = &monthSums{}
}
var results []MemberRow
for _, name := range juniorNames {
mr := result.Members[name]
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
row.UnpaidPeriods = joinComma(unpaidMonths)
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
row.Balance = settledBalance(mr, currentMonth)
row.PayableAmount = max(0, -row.Balance)
results = append(results, row)
}
// Totals row.
totalsCells := make([]TotalCell, len(sortedMonths))
for i, m := range sortedMonths {
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
status := "empty"
if t.expected > 0 || t.paid > 0 {
switch {
case t.paid == t.expected:
status = "ok"
case t.paid < t.expected:
status = "unpaid"
default:
status = "surplus"
}
}
totalsCells[i] = TotalCell{
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
Status: status,
}
}
var credits, debts []Credit
for _, name := range juniorNames {
bal := settledBalance(result.Members[name], currentMonth)
if bal > 0 {
credits = append(credits, Credit{Name: name, Amount: bal})
} else if bal < 0 {
debts = append(debts, Credit{Name: name, Amount: -bal})
}
}
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
// member_data: full reconcile output for all junior members.
memberData := make(map[string]JuniorsMemberData, len(result.Members))
for name, mr := range result.Members {
months := make(map[string]JuniorsMonthData, len(mr.Months))
for m, md := range mr.Months {
var exc *ExceptionData
if md.Exception != nil {
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
}
txEntries := make([]MemberTxEntry, len(md.Transactions))
for i, te := range md.Transactions {
txEntries[i] = memberTxFromDomain(te)
}
months[m] = JuniorsMonthData{
Expected: juniorExpected(md),
OriginalExpected: juniorOriginalExpected(md),
AttendanceCount: md.AttendanceCount,
Exception: exc,
Paid: md.Paid,
Transactions: txEntries,
}
}
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
for i, oe := range mr.OtherTransactions {
otherTxs[i] = memberOtherFromDomain(oe)
}
memberData[name] = JuniorsMemberData{
Tier: mr.Tier,
Months: months,
OtherTransactions: otherTxs,
TotalBalance: mr.TotalBalance,
}
}
unmatched := make([]RawTransaction, len(result.Unmatched))
for i, tx := range result.Unmatched {
unmatched[i] = rawTxFromDomain(tx)
}
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
"/edit#gid=" + config.JuniorSheetGID
return JuniorsResponse{
Months: labelsForMonths(sortedMonths, monthLabels),
RawMonths: sortedMonths,
Results: ensureSlice(results),
Totals: totalsCells,
MemberData: memberData,
MonthLabels: monthLabels,
RawPayments: groupRawPaymentsByPerson(txns, allNames),
Credits: ensureSlice(credits),
Debts: ensureSlice(debts),
Unmatched: unmatched,
AttendanceURL: juniorURL,
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.BankAccount,
CurrentMonth: currentMonth,
}
}
func buildJuniorMemberRow(
name string,
mr domreconcile.MemberResult,
sortedMonths []string,
monthLabels map[string]string,
currentMonth string,
monthlyTotals map[string]*monthSums,
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
row = MemberRow{Name: name}
for _, m := range sortedMonths {
md, ok := mr.Months[m]
if !ok {
md = domreconcile.MonthData{}
}
paid := int(md.Paid)
// Update monthly totals (skip "?" months for expected).
if t := monthlyTotals[m]; t != nil {
if !md.IsUnknown {
t.expected += md.Expected
}
t.paid += paid
}
// Attendance breakdown string e.g. ":3J,2A".
var breakdown string
jc, ac := md.JuniorAttendance, md.AdultAttendance
switch {
case jc > 0 && ac > 0:
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
case jc > 0:
breakdown = fmt.Sprintf(":%dJ", jc)
case ac > 0:
breakdown = fmt.Sprintf(":%dA", ac)
}
countStr := ""
if md.AttendanceCount > 0 {
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
}
// Fee display string.
var feeDisplay string
var isOverridden bool
if md.Exception != nil {
overrideAmount := md.Exception.Amount
var origStr string
if md.IsUnknown {
origStr = "?"
isOverridden = true
} else {
origStr = strconv.Itoa(md.OriginalExpected)
isOverridden = overrideAmount != md.OriginalExpected
}
if isOverridden {
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
} else {
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
}
} else {
if md.IsUnknown {
feeDisplay = "? CZK" + countStr
} else {
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
}
}
status := "empty"
cellText := "-"
amountToPay := 0
switch {
case md.IsUnknown:
cellText = "?" + countStr
case md.Expected > 0:
switch {
case paid >= md.Expected:
status = "ok"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
case paid > 0:
status = "partial"
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
amountToPay = md.Expected - paid
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
default:
status = "unpaid"
cellText = fmt.Sprintf("0/%s", feeDisplay)
amountToPay = md.Expected
if m < currentMonth {
unpaidMonths = append(unpaidMonths, monthLabels[m])
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
}
}
case paid > 0:
status = "surplus"
cellText = fmt.Sprintf("PAID %d", paid)
}
tooltip := ""
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
}
row.Months = append(row.Months, MonthCell{
Text: cellText,
Overridden: isOverridden,
Status: status,
Amount: amountToPay,
Month: monthLabels[m],
RawMonth: m,
Tooltip: tooltip,
})
}
return row, unpaidMonths, rawUnpaidMonths
}
// juniorExpected converts domain MonthData to the Expected wire type.
// When an exception exists it always produces a concrete int; otherwise
// the "?" sentinel is used when IsUnknown=true.
func juniorExpected(md domreconcile.MonthData) Expected {
if md.Exception == nil && md.IsUnknown {
return Expected{Unknown: true}
}
return Expected{Value: md.Expected}
}
// juniorOriginalExpected converts the original (pre-exception) expected fee.
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
if md.IsUnknown {
return Expected{Unknown: true}
}
return Expected{Value: md.OriginalExpected}
}

View File

@@ -0,0 +1,44 @@
package api
import (
"fuj-management/go/internal/config"
"sort"
"strings"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// buildPaymentsResponse constructs the PaymentsResponse wire type.
// Mirrors scripts/views.py:build_payments_view_model.
func buildPaymentsResponse(
txns []domreconcile.Transaction,
memberNames []string,
) PaymentsResponse {
grouped := groupRawPaymentsByPerson(txns, memberNames)
// Add unmatched/unknown bucket for transactions with no person set.
const unknownKey = "Unmatched / Unknown"
for _, tx := range txns {
if strings.TrimSpace(tx.Person) == "" {
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
}
}
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
if rows, ok := grouped[unknownKey]; ok {
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
grouped[unknownKey] = rows
}
sortedPeople := make([]string, 0, len(grouped))
for p := range grouped {
sortedPeople = append(sortedPeople, p)
}
sort.Strings(sortedPeople)
return PaymentsResponse{
GroupedPayments: grouped,
SortedPeople: sortedPeople,
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
}
}

View File

@@ -0,0 +1,143 @@
package api
import (
"context"
"encoding/json"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"log/slog"
"net/http"
"time"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// Handler holds the shared dependencies for all /api/* routes.
type Handler struct {
BuildVersion string
BuildCommit string
BuildDate string
Sources membership.Sources
Config config.Config
Logger *slog.Logger
}
// ServeVersion handles GET /api/version.
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, VersionResponse{
Tag: h.BuildVersion,
Commit: h.BuildCommit,
BuildDate: h.BuildDate,
})
}
// ServeAdults handles GET /api/adults.
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssembleAdults(r.Context())
if err != nil {
h.writeError(w, r, err)
return
}
writeJSON(w, resp)
}
// AssembleAdults loads all data and builds the adults view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssembleAdults(ctx context.Context) (AdultsResponse, error) {
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
if err != nil {
return AdultsResponse{}, err
}
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
}
// ServeJuniors handles GET /api/juniors.
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssembleJuniors(r.Context())
if err != nil {
h.writeError(w, r, err)
return
}
writeJSON(w, resp)
}
// AssembleJuniors loads all data and builds the juniors view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssembleJuniors(ctx context.Context) (JuniorsResponse, error) {
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
if err != nil {
return JuniorsResponse{}, err
}
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
}
// ServePayments handles GET /api/payments.
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil {
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
return
}
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
}
func (h *Handler) loadAll(ctx context.Context, adults bool) (
members []domreconcile.Member,
sortedMonths []string,
txns []domreconcile.Transaction,
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
err error,
) {
if adults {
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
} else {
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
}
if err != nil {
err = fmt.Errorf("load members: %w", err)
return
}
txns, err = h.Sources.LoadTransactions(ctx)
if err != nil {
err = fmt.Errorf("load transactions: %w", err)
return
}
exceptions, err = h.Sources.LoadExceptions(ctx)
if err != nil {
err = fmt.Errorf("load exceptions: %w", err)
}
return
}
func (h *Handler) allMemberNames(ctx context.Context) []string {
var names []string
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
for _, m := range adults {
names = append(names, m.Name)
}
}
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
for _, m := range juniors {
names = append(names, m.Name)
}
}
return names
}
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
if h.Logger != nil {
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v)
}

View File

@@ -0,0 +1,41 @@
package api
// JuniorsMonthData is the reconciled ledger for one junior member in one month.
// expected and original_expected may be the "?" sentinel (single-attendance month
// requiring manual review); they are carried via the Expected type.
type JuniorsMonthData struct {
Expected Expected `json:"expected"`
OriginalExpected Expected `json:"original_expected"`
AttendanceCount int `json:"attendance_count"`
Exception *ExceptionData `json:"exception"`
Paid float64 `json:"paid"`
Transactions []MemberTxEntry `json:"transactions"`
}
// JuniorsMemberData is the reconciled ledger for one junior member.
type JuniorsMemberData struct {
Tier string `json:"tier"`
Months map[string]JuniorsMonthData `json:"months"`
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
TotalBalance int `json:"total_balance"`
}
// JuniorsResponse is the JSON contract for GET /api/juniors.
// Same outer shape as AdultsResponse; differs in that member_data carries
// Expected (int or "?") for expected/original_expected fields.
type JuniorsResponse struct {
Months []string `json:"months"`
RawMonths []string `json:"raw_months"`
Results []MemberRow `json:"results"`
Totals []TotalCell `json:"totals"`
MemberData map[string]JuniorsMemberData `json:"member_data"`
MonthLabels map[string]string `json:"month_labels"`
RawPayments map[string][]RawTransaction `json:"raw_payments"`
Credits []Credit `json:"credits"`
Debts []Credit `json:"debts"`
Unmatched []RawTransaction `json:"unmatched"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
}

View File

@@ -0,0 +1,9 @@
package api
// PaymentsResponse is the JSON contract for GET /api/payments.
type PaymentsResponse struct {
GroupedPayments map[string][]RawTransaction `json:"grouped_payments"` // person name → rows
SortedPeople []string `json:"sorted_people"`
AttendanceURL string `json:"attendance_url"`
PaymentsURL string `json:"payments_url"`
}

View File

@@ -0,0 +1,81 @@
package api
// schemagen_test.go generates and golden-compares JSON Schema files for every
// /api/X response type.
//
// Normal run (CI): go test ./internal/web/api/... — asserts schemas match committed files.
// Regenerate: go test -run TestGenerateSchemas -update ./internal/web/api/...
import (
"encoding/json"
"flag"
"os"
"path/filepath"
"testing"
"github.com/invopop/jsonschema"
)
var updateFlag = flag.Bool("update", false, "overwrite api-schema fixture files with freshly generated schemas")
// JSONSchema makes Expected self-describing for the reflector at test time.
// The method is in a test file and is not compiled into production binaries.
// It emits oneOf [integer, "?"] to match the custom MarshalJSON behaviour.
func (Expected) JSONSchema() *jsonschema.Schema {
return &jsonschema.Schema{
OneOf: []*jsonschema.Schema{
{Type: "integer"},
{Enum: []any{"?"}},
},
}
}
func TestGenerateSchemas(t *testing.T) {
r := &jsonschema.Reflector{
AllowAdditionalProperties: false,
}
cases := []struct {
name string
val any
}{
{"adults", &AdultsResponse{}},
{"juniors", &JuniorsResponse{}},
{"payments", &PaymentsResponse{}},
{"version", &VersionResponse{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
schema := r.Reflect(tc.val)
got, err := json.MarshalIndent(schema, "", " ")
if err != nil {
t.Fatalf("marshal schema: %v", err)
}
got = append(got, '\n')
// Path: go/internal/web/api/ → ../../.. → go/ → tests/fixtures/api-schema/
path := filepath.Join("..", "..", "..", "tests", "fixtures", "api-schema", tc.name+".schema.json")
if *updateFlag {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatalf("write schema: %v", err)
}
t.Logf("wrote %s", path)
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read fixture %s: %v (re-run with -update to generate)", path, err)
}
if string(got) != string(want) {
t.Errorf("schema mismatch for %s; re-run with -update to regenerate", tc.name)
}
})
}
}

View File

@@ -0,0 +1,122 @@
// Package api defines wire types for the JSON API contract (/api/...).
// These structs have explicit json: tags matching the Python view-model dict
// keys so that M5 parity tests can do byte-equal comparison between backends.
//
// The three Python template-only JSON-string fields (member_data,
// month_labels_json, raw_payments_json) are represented here as nested objects;
// the Python /api/X shadow endpoint strips the json.dumps wrappers before
// serialising.
package api
import (
"encoding/json"
"fmt"
)
// Expected holds a junior fee expectation: either a concrete integer or the
// "?" sentinel (single-attendance month requiring manual review).
// MarshalJSON emits the integer or the JSON string "?".
type Expected struct {
Value int
Unknown bool
}
func (e Expected) MarshalJSON() ([]byte, error) {
if e.Unknown {
return []byte(`"?"`), nil
}
return json.Marshal(e.Value)
}
func (e *Expected) UnmarshalJSON(data []byte) error {
if len(data) > 0 && data[0] == '"' {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
if s == "?" {
e.Unknown = true
return nil
}
return fmt.Errorf("api.Expected: unexpected string %q", s)
}
e.Unknown = false
return json.Unmarshal(data, &e.Value)
}
// ExceptionData is a manual fee override for one member in one month.
type ExceptionData struct {
Amount int `json:"amount"`
Note string `json:"note"`
}
// MemberTxEntry is one payment allocation to a member+month, as stored in
// member_data.months[YYYY-MM].transactions.
type MemberTxEntry struct {
Amount float64 `json:"amount"`
Date string `json:"date"`
Sender string `json:"sender"`
Message string `json:"message"`
Confidence string `json:"confidence"`
}
// MemberOtherEntry is an "other:…" purpose payment allocated to a member.
type MemberOtherEntry struct {
Amount float64 `json:"amount"`
Date string `json:"date"`
Sender string `json:"sender"`
Message string `json:"message"`
Purpose string `json:"purpose"`
Confidence string `json:"confidence"`
}
// RawTransaction is a full payments-sheet row.
// Used for unmatched transactions and raw_payments groupings.
// Columns match the sheet layout: Date|Amount|manual fix|Person|Purpose|
// Inferred Amount|Sender|VS|Message|Bank ID|Sync ID.
type RawTransaction struct {
Date string `json:"date"`
Amount float64 `json:"amount"`
ManualFix string `json:"manual_fix"`
Person string `json:"person"`
Purpose string `json:"purpose"`
InferredAmount float64 `json:"inferred_amount"`
Sender string `json:"sender"`
VS string `json:"vs"`
Message string `json:"message"`
BankID string `json:"bank_id"`
SyncID string `json:"sync_id"`
}
// MonthCell is one cell in a member's month column on the dashboard.
type MonthCell struct {
Text string `json:"text"`
Overridden bool `json:"overridden"`
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
Amount int `json:"amount"`
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
RawMonth string `json:"raw_month"` // "YYYY-MM"
Tooltip string `json:"tooltip"`
}
// TotalCell is one cell in the monthly totals row.
type TotalCell struct {
Text string `json:"text"`
Status string `json:"status"`
}
// MemberRow is one member's summary row in the dashboard results table.
type MemberRow struct {
Name string `json:"name"`
Months []MonthCell `json:"months"`
Balance int `json:"balance"`
UnpaidPeriods string `json:"unpaid_periods"`
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
PayableAmount int `json:"payable_amount"`
}
// Credit is one entry in the credits or debts lists.
type Credit struct {
Name string `json:"name"`
Amount int `json:"amount"`
}

View File

@@ -0,0 +1,9 @@
package api
// VersionResponse is the JSON contract for GET /api/version.
// Keys match Python's BUILD_META dict (see app.py).
type VersionResponse struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
BuildDate string `json:"build_date"`
}

View File

@@ -0,0 +1,9 @@
package web
import "embed"
//go:embed templates
var templateFS embed.FS
//go:embed static
var staticFS embed.FS

View File

@@ -0,0 +1,60 @@
package web
import (
"fuj-management/go/internal/web/api"
"net/http"
)
// HTMLHandler serves the Go-native HTML frontend.
type HTMLHandler struct {
renderer *Renderer
build BuildInfo
apiHandler *api.Handler
}
// NewHTMLHandler constructs an HTMLHandler.
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler) *HTMLHandler {
return &HTMLHandler{renderer: r, build: b, apiHandler: ah}
}
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleAdults(r.Context())
if err != nil {
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
Error: err.Error(),
})
return
}
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
Data: data,
})
}
func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
data, err := h.apiHandler.AssembleJuniors(r.Context())
if err != nil {
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
Error: err.Error(),
})
return
}
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
Data: data,
})
}
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "payments", PageData{Active: "payments", Build: h.build})
}
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "sync", PageData{Active: "sync", Build: h.build})
}
func (h *HTMLHandler) ServeFlushCache(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", PageData{Active: "flush", Build: h.build})
}

View File

@@ -0,0 +1,128 @@
package web_test
import (
"context"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/domain/reconcile"
"fuj-management/go/internal/web"
"fuj-management/go/internal/web/api"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fixtureSources returns one adult ("Test Member", tier A) with a 2026-01 fee
// of 750 CZK (4 sessions) and a matching payment of 750 — mirrors Python's
// test_adults_route fixture.
type fixtureSources struct{}
func (fixtureSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
return []reconcile.Member{
{Name: "Test Member", Tier: "A", Fees: map[string]reconcile.FeeData{
"2026-01": {Expected: 750, Attendance: 4},
}},
}, []string{"2026-01"}, nil
}
func (fixtureSources) LoadJuniors(_ context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, nil
}
func (fixtureSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
amt := float64(750)
return []reconcile.Transaction{
{Date: "2026-01-01", Amount: 750, Person: "Test Member", Purpose: "2026-01", InferredAmount: &amt},
}, nil
}
func (fixtureSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, nil
}
func fixtureHandler(t *testing.T) *api.Handler {
t.Helper()
return &api.Handler{
Sources: fixtureSources{},
Config: config.Config{BankAccount: "CZ0000000000000000000000"},
}
}
func TestHTMLHandlerSmoke(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
{"/payments", h.ServePayments},
{"/sync-bank", h.ServeSync},
{"/flush-cache", h.ServeFlushCache},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
tc.handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("Content-Type = %q, want text/html", ct)
}
body := w.Body.String()
if n := strings.Count(body, `class="active"`); n != 1 {
t.Errorf(`class="active" count = %d, want 1`, n)
}
want := fmt.Sprintf(`href="%s" class="active"`, tc.path)
if !strings.Contains(body, want) {
t.Errorf("missing active link %q in body", want)
}
})
}
}
func TestAdultsPage(t *testing.T) {
renderer, err := web.NewRenderer()
if err != nil {
t.Fatalf("NewRenderer: %v", err)
}
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t))
req := httptest.NewRequest(http.MethodGet, "/adults", nil)
w := httptest.NewRecorder()
h.ServeAdults(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Adults Dashboard",
"Test Member",
"750/750 CZK (4)", // paid/expected (attendance)
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
// Python assertion: cell text never says literally "OK"
if strings.Contains(body, ">OK<") {
t.Error("body should not contain >OK<")
}
}

101
go/internal/web/render.go Normal file
View File

@@ -0,0 +1,101 @@
package web
import (
"fmt"
"fuj-management/go/internal/web/api"
"html/template"
"log/slog"
"net/http"
"net/url"
"strconv"
)
// PageData is the view model passed to every HTML template.
type PageData struct {
Active string
Build BuildInfo
}
// AdultsPageData is the view model for the /adults HTML page.
type AdultsPageData struct {
PageData
Data api.AdultsResponse
Error string
}
// JuniorsPageData is the view model for the /juniors HTML page.
type JuniorsPageData struct {
PageData
Data api.JuniorsResponse
Error string
}
// Renderer parses and executes HTML templates from the embedded FS.
type Renderer struct {
tmpls map[string]*template.Template
}
var pageNames = []string{"adults", "juniors", "payments", "sync", "flush_cache"}
// qrHref builds the /qr query URL for a single-month Pay button.
// rawMonth is "YYYY-MM"; it is converted to "MM/YYYY" in the QR message.
func qrHref(account string, amount int, name, rawMonth string) string {
// Convert "YYYY-MM" → "MM/YYYY" to match Python's showPayQR JS.
if len(rawMonth) == 7 && rawMonth[4] == '-' {
rawMonth = rawMonth[5:] + "/" + rawMonth[:4]
}
msg := name + ": " + rawMonth
return "/qr?" + url.Values{
"account": {account},
"amount": {strconv.Itoa(amount)},
"message": {msg},
}.Encode()
}
// qrHrefAll builds the /qr query URL for a Pay-All button.
// rawPeriods is the "+" -joined MM/YYYY string from MemberRow.RawUnpaidPeriods.
func qrHrefAll(account string, amount int, name, rawPeriods string) string {
msg := name + ": " + rawPeriods
return "/qr?" + url.Values{
"account": {account},
"amount": {strconv.Itoa(amount)},
"message": {msg},
}.Encode()
}
var tmplFuncs = template.FuncMap{
"qrHref": qrHref,
"qrHrefAll": qrHrefAll,
}
// NewRenderer parses all templates from the embedded FS.
// A parse failure should be treated as a startup-time fatal error.
func NewRenderer() (*Renderer, error) {
tmpls := make(map[string]*template.Template, len(pageNames))
for _, name := range pageNames {
t, err := template.New("").Funcs(tmplFuncs).ParseFS(templateFS,
"templates/base.tmpl",
"templates/partials/nav.tmpl",
"templates/partials/footer.tmpl",
"templates/"+name+".tmpl",
)
if err != nil {
return nil, fmt.Errorf("parse template %q: %w", name, err)
}
tmpls[name] = t
}
return &Renderer{tmpls: tmpls}, nil
}
// Render executes the named template with data, writing to w.
func (r *Renderer) Render(w http.ResponseWriter, name string, data any) {
t, ok := r.tmpls[name]
if !ok {
http.Error(w, "template not found: "+name, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, "base", data); err != nil {
slog.Error("render template", "name", name, "err", err)
}
}

View File

@@ -2,7 +2,11 @@ package web
import ( import (
"fmt" "fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web/api"
"fuj-management/go/internal/web/middleware" "fuj-management/go/internal/web/middleware"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
) )
@@ -15,18 +19,48 @@ type BuildInfo struct {
} }
// Run registers routes and starts the HTTP server on addr. // Run registers routes and starts the HTTP server on addr.
func Run(logger *slog.Logger, addr string, build BuildInfo) error { func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
renderer, err := NewRenderer()
if err != nil {
return fmt.Errorf("init templates: %w", err)
}
ah := &api.Handler{
BuildVersion: build.Version,
BuildCommit: build.Commit,
BuildDate: build.BuildDate,
Sources: sources,
Config: cfg,
Logger: logger,
}
hh := NewHTMLHandler(renderer, build, ah)
staticSubFS, err := fs.Sub(staticFS, "static")
if err != nil {
return fmt.Errorf("static subfs: %w", err)
}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", helloHandler(build))
// HTML routes
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/adults", http.StatusFound)
})
mux.HandleFunc("GET /adults", hh.ServeAdults)
mux.HandleFunc("GET /juniors", hh.ServeJuniors)
mux.HandleFunc("GET /payments", hh.ServePayments)
mux.HandleFunc("GET /sync-bank", hh.ServeSync)
mux.HandleFunc("GET /flush-cache", hh.ServeFlushCache)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// JSON API routes
mux.HandleFunc("GET /api/version", ah.ServeVersion)
mux.HandleFunc("GET /api/adults", ah.ServeAdults)
mux.HandleFunc("GET /api/juniors", ah.ServeJuniors)
mux.HandleFunc("GET /api/payments", ah.ServePayments)
logger.Info("starting server", "addr", addr) logger.Info("starting server", "addr", addr)
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux)) return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
} }
func helloHandler(build BuildInfo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
build.Version, build.Commit, build.BuildDate)
}
}

View File

@@ -0,0 +1,478 @@
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.nav-archived a {
font-size: 10px;
color: #666;
border-color: #222;
}
.nav-archived a.active {
color: #ccc;
background-color: #333;
border-color: #555;
}
.nav-archived a:hover {
color: #999;
border-color: #444;
}
.description {
margin-bottom: 20px;
text-align: center;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.cell-unpaid-current {
color: #994444;
background-color: rgba(153, 68, 68, 0.05);
position: relative;
}
.cell-overridden {
color: #ffa500 !important;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
text-decoration: none;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
border-bottom: 1px solid #333;
margin-bottom: 5px;
}
.filter-container {
width: 100%;
max-width: 1200px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.filter-input {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
width: 250px;
outline: none;
}
.filter-input:focus {
border-color: #00ff00;
}
.filter-select {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
outline: none;
}
.filter-select:focus {
border-color: #00ff00;
}
.month-hidden {
display: none !important;
}
.filter-label {
color: #888;
text-transform: lowercase;
}
.info-icon {
color: #00ff00;
cursor: pointer;
margin-left: 5px;
font-size: 10px;
opacity: 0.5;
}
.info-icon:hover {
opacity: 1;
}
/* Modal Styles */
#memberModal {
display: none !important;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#memberModal.active {
display: flex !important;
}
.modal-content {
background-color: #0c0c0c;
border: 1px solid #00ff00;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
position: relative;
}
.modal-header {
border-bottom: 1px solid #333;
margin-bottom: 20px;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
color: #00ff00;
font-size: 14px;
text-transform: uppercase;
}
.close-btn {
color: #ff3333;
cursor: pointer;
font-size: 14px;
text-transform: lowercase;
}
.modal-section {
margin-bottom: 25px;
}
.modal-section-title {
color: #555;
text-transform: uppercase;
font-size: 10px;
margin-bottom: 8px;
border-bottom: 1px dashed #222;
}
.raw-toggle {
color: #333;
font-size: 9px;
text-transform: lowercase;
margin-left: 8px;
text-decoration: none;
letter-spacing: 0;
}
.raw-toggle:hover {
color: #666;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table th,
.modal-table td {
text-align: left;
padding: 4px 0;
border-bottom: 1px dashed #1a1a1a;
}
.modal-table th {
color: #666;
font-weight: normal;
font-size: 10px;
}
.tx-list {
list-style: none;
padding: 0;
margin: 0;
}
.tx-item {
padding: 8px 0;
border-bottom: 1px dashed #222;
}
.tx-meta {
color: #555;
font-size: 10px;
margin-bottom: 4px;
}
.tx-main {
display: flex;
justify-content: space-between;
gap: 20px;
}
.tx-amount {
color: #00ff00;
}
.tx-sender {
color: #ccc;
}
.tx-msg {
color: #888;
font-style: italic;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}

View File

@@ -0,0 +1,91 @@
// Client-side filters for the Adults (and future Juniors) dashboard table.
// Mirrors adults.html:864-1051 from the Python frontend.
(function () {
'use strict';
// NFD-normalize + strip diacritics + lowercase, matching Python's
// unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode().lower()
function normalize(s) {
return s.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase();
}
const container = document.getElementById('filterContainer');
if (!container) return;
const currentMonth = container.dataset.currentMonth || '';
const nameInput = document.getElementById('nameFilter');
const fromSelect = document.getElementById('fromMonth');
const toSelect = document.getElementById('toMonth');
const applyBtn = document.getElementById('applyFilter');
const clearBtn = document.getElementById('clearFilter');
// ── Month column visibility ───────────────────────────────────────────────
// Hide columns whose raw month is in the future by default.
function hideFutureMonths() {
if (!currentMonth) return;
document.querySelectorAll('[data-raw-month]').forEach(el => {
if (el.dataset.rawMonth > currentMonth) {
el.classList.add('month-hidden');
}
});
// Sync toMonth select to the last non-hidden month.
const ths = [...document.querySelectorAll('thead th[data-month-idx]')];
const visibleIdxs = ths
.filter(th => !th.classList.contains('month-hidden'))
.map(th => parseInt(th.dataset.monthIdx, 10));
if (visibleIdxs.length) {
toSelect.value = String(visibleIdxs[visibleIdxs.length - 1]);
}
}
function applyMonthFilter() {
const from = fromSelect.value !== '' ? parseInt(fromSelect.value, 10) : -Infinity;
const to = toSelect.value !== '' ? parseInt(toSelect.value, 10) : Infinity;
document.querySelectorAll('[data-month-idx]').forEach(el => {
const idx = parseInt(el.dataset.monthIdx, 10);
if (idx < from || idx > to) {
el.classList.add('month-hidden');
} else {
el.classList.remove('month-hidden');
}
});
}
function clearMonthFilter() {
document.querySelectorAll('[data-month-idx]').forEach(el => {
el.classList.remove('month-hidden');
});
fromSelect.value = '';
toSelect.value = '';
}
// ── Name row visibility ───────────────────────────────────────────────────
function applyNameFilter() {
const query = normalize(nameInput.value.trim());
document.querySelectorAll('tr.member-row').forEach(row => {
const name = normalize(row.dataset.name || '');
row.style.display = (!query || name.includes(query)) ? '' : 'none';
});
}
// ── Event wiring ─────────────────────────────────────────────────────────
nameInput.addEventListener('input', applyNameFilter);
applyBtn.addEventListener('click', applyMonthFilter);
clearBtn.addEventListener('click', function () {
nameInput.value = '';
applyNameFilter();
clearMonthFilter();
hideFutureMonths();
});
// ── Initialise ────────────────────────────────────────────────────────────
hideFutureMonths();
}());

View File

@@ -0,0 +1,141 @@
{{define "title"}}Adults{{end}}
{{define "content"}}
<h1>Adults Dashboard</h1>
{{if .Error}}
<div class="description">Error loading data: {{.Error}}</div>
{{else}}
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
</div>
<div class="filter-item">
<label class="filter-label" for="fromMonth">From</label>
<select id="fromMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<label class="filter-label" for="toMonth">To</label>
<select id="toMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
</div>
</div>
{{if .Data.Results}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{{range $i, $m := .Data.Months}}
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
{{end}}
<th>Balance</th>
</tr>
</thead>
<tbody>
{{range $row := .Data.Results}}
<tr class="member-row" data-name="{{$row.Name}}">
<td class="member-name">{{$row.Name}}</td>
{{range $i, $cell := $row.Months}}
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
{{$cell.Text}}
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
{{end}}
</td>
{{end}}
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
{{$row.Balance}}
{{if gt $row.PayableAmount 0}}
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
{{end}}
</td>
</tr>
{{end}}
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
{{range $i, $t := .Data.Totals}}
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
{{$t.Text}}
</td>
{{end}}
<td></td>
</tr>
</tbody>
</table>
</div>
{{else}}
<div class="description">No members found.</div>
{{end}}
{{if .Data.Credits}}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{{range .Data.Credits}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{if .Data.Debts}}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{{range .Data.Debts}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{if .Data.Unmatched}}
<h2>Unmatched Transactions</h2>
<div class="list-container">
<div class="unmatched-row unmatched-header">
<span>Date</span>
<span>Amount</span>
<span>Sender</span>
<span>Message</span>
</div>
{{range .Data.Unmatched}}
<div class="unmatched-row">
<span>{{.Date}}</span>
<span>{{printf "%.0f" .Amount}}</span>
<span>{{.Sender}}</span>
<span>{{.Message}}</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
<script src="/static/js/filters.js" defer></script>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "base"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ — {{template "title" .}}</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
{{template "nav" .}}
{{template "content" .}}
{{template "footer" .}}
</body>
</html>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Flush Cache{{end}}
{{define "content"}}
<h1>Flush Cache</h1>
<p class="description">Coming in M6.6</p>
{{end}}

View File

@@ -0,0 +1,121 @@
{{define "title"}}Juniors{{end}}
{{define "content"}}
<h1>Juniors Dashboard</h1>
{{if .Error}}
<div class="description">Error loading data: {{.Error}}</div>
{{else}}
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{.Data.AttendanceURL}}" target="_blank" rel="noopener">Attendance Sheet</a> |
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
</div>
<div class="filter-item">
<label class="filter-label" for="fromMonth">From</label>
<select id="fromMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<label class="filter-label" for="toMonth">To</label>
<select id="toMonth" class="filter-select">
<option value="">All</option>
{{range $i, $m := .Data.Months}}
<option value="{{$i}}">{{$m}}</option>
{{end}}
</select>
</div>
<div class="filter-item">
<button id="applyFilter" class="filter-select" type="button" style="cursor: pointer;">Apply</button>
<button id="clearFilter" class="filter-select" type="button" style="cursor: pointer;">All</button>
</div>
</div>
{{if .Data.Results}}
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{{range $i, $m := .Data.Months}}
<th data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}">{{$m}}</th>
{{end}}
<th>Balance</th>
</tr>
</thead>
<tbody>
{{range $row := .Data.Results}}
<tr class="member-row" data-name="{{$row.Name}}">
<td class="member-name">{{$row.Name}}</td>
{{range $i, $cell := $row.Months}}
<td data-month-idx="{{$i}}" title="{{$cell.Tooltip}}"
class="{{if eq $cell.Status "empty"}}cell-empty{{else if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (ge $cell.RawMonth $.Data.CurrentMonth)}}cell-unpaid-current{{else if or (eq $cell.Status "unpaid") (eq $cell.Status "partial")}}cell-unpaid{{else if eq $cell.Status "ok"}}cell-ok{{end}}{{if $cell.Overridden}} cell-overridden{{end}}">
{{$cell.Text}}
{{if and (or (eq $cell.Status "unpaid") (eq $cell.Status "partial")) (lt $cell.RawMonth $.Data.CurrentMonth)}}
<a class="pay-btn" href="{{qrHref $.Data.BankAccount $cell.Amount $row.Name $cell.RawMonth}}">Pay</a>
{{end}}
</td>
{{end}}
<td class="{{if lt $row.Balance 0}}balance-neg{{else if gt $row.Balance 0}}balance-pos{{end}}" style="position: relative;">
{{$row.Balance}}
{{if gt $row.PayableAmount 0}}
<a class="pay-btn" href="{{qrHrefAll $.Data.BankAccount $row.PayableAmount $row.Name $row.RawUnpaidPeriods}}">Pay All</a>
{{end}}
</td>
</tr>
{{end}}
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
<td style="text-align: left; padding: 6px 8px;">TOTAL</td>
{{range $i, $t := .Data.Totals}}
<td data-month-idx="{{$i}}" data-raw-month="{{index $.Data.RawMonths $i}}" class="{{if eq $t.Status "ok"}}cell-ok{{else if eq $t.Status "unpaid"}}cell-unpaid{{else if eq $t.Status "surplus"}}cell-overridden{{end}}" style="padding-top: 4px; padding-bottom: 4px;">
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
{{$t.Text}}
</td>
{{end}}
<td></td>
</tr>
</tbody>
</table>
</div>
{{else}}
<div class="description">No members found.</div>
{{end}}
{{if .Data.Credits}}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{{range .Data.Credits}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{if .Data.Debts}}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{{range .Data.Debts}}
<div class="list-item">
<span class="list-item-name">{{.Name}}</span>
<span class="list-item-val" style="color: #ff3333;">{{.Amount}} CZK</span>
</div>
{{end}}
</div>
{{end}}
{{end}}
<script src="/static/js/filters.js" defer></script>
{{end}}

View File

@@ -0,0 +1,3 @@
{{define "footer"}}
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
{{end}}

View File

@@ -0,0 +1,17 @@
{{define "nav"}}
<div class="nav">
<div>
<a href="/adults"{{if eq .Active "adults"}} class="active"{{end}}>[Adults]</a>
<a href="/juniors"{{if eq .Active "juniors"}} class="active"{{end}}>[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/payments"{{if eq .Active "payments"}} class="active"{{end}}>[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank"{{if eq .Active "sync"}} class="active"{{end}}>[Sync Bank Data]</a>
<a href="/flush-cache"{{if eq .Active "flush"}} class="active"{{end}}>[Flush Cache]</a>
</div>
</div>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">Coming in M6.4</p>
{{end}}

View File

@@ -0,0 +1,5 @@
{{define "title"}}Sync Bank Data{{end}}
{{define "content"}}
<h1>Sync Bank Data</h1>
<p class="description">Coming in M6.6</p>
{{end}}

View File

@@ -0,0 +1,399 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "#/$defs/AdultsResponse",
"$defs": {
"AdultsMemberData": {
"properties": {
"tier": {
"type": "string"
},
"months": {
"additionalProperties": {
"$ref": "#/$defs/AdultsMonthData"
},
"type": "object"
},
"other_transactions": {
"items": {
"$ref": "#/$defs/MemberOtherEntry"
},
"type": "array"
},
"total_balance": {
"type": "integer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"tier",
"months",
"other_transactions",
"total_balance"
]
},
"AdultsMonthData": {
"properties": {
"expected": {
"type": "integer"
},
"original_expected": {
"type": "integer"
},
"attendance_count": {
"type": "integer"
},
"exception": {
"$ref": "#/$defs/ExceptionData"
},
"paid": {
"type": "number"
},
"transactions": {
"items": {
"$ref": "#/$defs/MemberTxEntry"
},
"type": "array"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"expected",
"original_expected",
"attendance_count",
"exception",
"paid",
"transactions"
]
},
"AdultsResponse": {
"properties": {
"months": {
"items": {
"type": "string"
},
"type": "array"
},
"raw_months": {
"items": {
"type": "string"
},
"type": "array"
},
"results": {
"items": {
"$ref": "#/$defs/MemberRow"
},
"type": "array"
},
"totals": {
"items": {
"$ref": "#/$defs/TotalCell"
},
"type": "array"
},
"member_data": {
"additionalProperties": {
"$ref": "#/$defs/AdultsMemberData"
},
"type": "object"
},
"month_labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"raw_payments": {
"additionalProperties": {
"items": {
"$ref": "#/$defs/RawTransaction"
},
"type": "array"
},
"type": "object"
},
"credits": {
"items": {
"$ref": "#/$defs/Credit"
},
"type": "array"
},
"debts": {
"items": {
"$ref": "#/$defs/Credit"
},
"type": "array"
},
"unmatched": {
"items": {
"$ref": "#/$defs/RawTransaction"
},
"type": "array"
},
"attendance_url": {
"type": "string"
},
"payments_url": {
"type": "string"
},
"bank_account": {
"type": "string"
},
"current_month": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"months",
"raw_months",
"results",
"totals",
"member_data",
"month_labels",
"raw_payments",
"credits",
"debts",
"unmatched",
"attendance_url",
"payments_url",
"bank_account",
"current_month"
]
},
"Credit": {
"properties": {
"name": {
"type": "string"
},
"amount": {
"type": "integer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"name",
"amount"
]
},
"ExceptionData": {
"properties": {
"amount": {
"type": "integer"
},
"note": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"amount",
"note"
]
},
"MemberOtherEntry": {
"properties": {
"amount": {
"type": "number"
},
"date": {
"type": "string"
},
"sender": {
"type": "string"
},
"message": {
"type": "string"
},
"purpose": {
"type": "string"
},
"confidence": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"amount",
"date",
"sender",
"message",
"purpose",
"confidence"
]
},
"MemberRow": {
"properties": {
"name": {
"type": "string"
},
"months": {
"items": {
"$ref": "#/$defs/MonthCell"
},
"type": "array"
},
"balance": {
"type": "integer"
},
"unpaid_periods": {
"type": "string"
},
"raw_unpaid_periods": {
"type": "string"
},
"payable_amount": {
"type": "integer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"name",
"months",
"balance",
"unpaid_periods",
"raw_unpaid_periods",
"payable_amount"
]
},
"MemberTxEntry": {
"properties": {
"amount": {
"type": "number"
},
"date": {
"type": "string"
},
"sender": {
"type": "string"
},
"message": {
"type": "string"
},
"confidence": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"amount",
"date",
"sender",
"message",
"confidence"
]
},
"MonthCell": {
"properties": {
"text": {
"type": "string"
},
"overridden": {
"type": "boolean"
},
"status": {
"type": "string"
},
"amount": {
"type": "integer"
},
"month": {
"type": "string"
},
"raw_month": {
"type": "string"
},
"tooltip": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"text",
"overridden",
"status",
"amount",
"month",
"raw_month",
"tooltip"
]
},
"RawTransaction": {
"properties": {
"date": {
"type": "string"
},
"amount": {
"type": "number"
},
"manual_fix": {
"type": "string"
},
"person": {
"type": "string"
},
"purpose": {
"type": "string"
},
"inferred_amount": {
"type": "number"
},
"sender": {
"type": "string"
},
"vs": {
"type": "string"
},
"message": {
"type": "string"
},
"bank_id": {
"type": "string"
},
"sync_id": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"date",
"amount",
"manual_fix",
"person",
"purpose",
"inferred_amount",
"sender",
"vs",
"message",
"bank_id",
"sync_id"
]
},
"TotalCell": {
"properties": {
"text": {
"type": "string"
},
"status": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"text",
"status"
]
}
}
}

View File

@@ -0,0 +1,411 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "#/$defs/JuniorsResponse",
"$defs": {
"Credit": {
"properties": {
"name": {
"type": "string"
},
"amount": {
"type": "integer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"name",
"amount"
]
},
"ExceptionData": {
"properties": {
"amount": {
"type": "integer"
},
"note": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"amount",
"note"
]
},
"Expected": {
"oneOf": [
{
"type": "integer"
},
{
"enum": [
"?"
]
}
]
},
"JuniorsMemberData": {
"properties": {
"tier": {
"type": "string"
},
"months": {
"additionalProperties": {
"$ref": "#/$defs/JuniorsMonthData"
},
"type": "object"
},
"other_transactions": {
"items": {
"$ref": "#/$defs/MemberOtherEntry"
},
"type": "array"
},
"total_balance": {
"type": "integer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"tier",
"months",
"other_transactions",
"total_balance"
]
},
"JuniorsMonthData": {
"properties": {
"expected": {
"$ref": "#/$defs/Expected"
},
"original_expected": {
"$ref": "#/$defs/Expected"
},
"attendance_count": {
"type": "integer"
},
"exception": {
"$ref": "#/$defs/ExceptionData"
},
"paid": {
"type": "number"
},
"transactions": {
"items": {
"$ref": "#/$defs/MemberTxEntry"
},
"type": "array"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"expected",
"original_expected",
"attendance_count",
"exception",
"paid",
"transactions"
]
},
"JuniorsResponse": {
"properties": {
"months": {
"items": {
"type": "string"
},
"type": "array"
},
"raw_months": {
"items": {
"type": "string"
},
"type": "array"
},
"results": {
"items": {
"$ref": "#/$defs/MemberRow"
},
"type": "array"
},
"totals": {
"items": {
"$ref": "#/$defs/TotalCell"
},
"type": "array"
},
"member_data": {
"additionalProperties": {
"$ref": "#/$defs/JuniorsMemberData"
},
"type": "object"
},
"month_labels": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"raw_payments": {
"additionalProperties": {
"items": {
"$ref": "#/$defs/RawTransaction"
},
"type": "array"
},
"type": "object"
},
"credits": {
"items": {
"$ref": "#/$defs/Credit"
},
"type": "array"
},
"debts": {
"items": {
"$ref": "#/$defs/Credit"
},
"type": "array"
},
"unmatched": {
"items": {
"$ref": "#/$defs/RawTransaction"
},
"type": "array"
},
"attendance_url": {
"type": "string"
},
"payments_url": {
"type": "string"
},
"bank_account": {
"type": "string"
},
"current_month": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"months",
"raw_months",
"results",
"totals",
"member_data",
"month_labels",
"raw_payments",
"credits",
"debts",
"unmatched",
"attendance_url",
"payments_url",
"bank_account",
"current_month"
]
},
"MemberOtherEntry": {
"properties": {
"amount": {
"type": "number"
},
"date": {
"type": "string"
},
"sender": {
"type": "string"
},
"message": {
"type": "string"
},
"purpose": {
"type": "string"
},
"confidence": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"amount",
"date",
"sender",
"message",
"purpose",
"confidence"
]
},
"MemberRow": {
"properties": {
"name": {
"type": "string"
},
"months": {
"items": {
"$ref": "#/$defs/MonthCell"
},
"type": "array"
},
"balance": {
"type": "integer"
},
"unpaid_periods": {
"type": "string"
},
"raw_unpaid_periods": {
"type": "string"
},
"payable_amount": {
"type": "integer"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"name",
"months",
"balance",
"unpaid_periods",
"raw_unpaid_periods",
"payable_amount"
]
},
"MemberTxEntry": {
"properties": {
"amount": {
"type": "number"
},
"date": {
"type": "string"
},
"sender": {
"type": "string"
},
"message": {
"type": "string"
},
"confidence": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"amount",
"date",
"sender",
"message",
"confidence"
]
},
"MonthCell": {
"properties": {
"text": {
"type": "string"
},
"overridden": {
"type": "boolean"
},
"status": {
"type": "string"
},
"amount": {
"type": "integer"
},
"month": {
"type": "string"
},
"raw_month": {
"type": "string"
},
"tooltip": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"text",
"overridden",
"status",
"amount",
"month",
"raw_month",
"tooltip"
]
},
"RawTransaction": {
"properties": {
"date": {
"type": "string"
},
"amount": {
"type": "number"
},
"manual_fix": {
"type": "string"
},
"person": {
"type": "string"
},
"purpose": {
"type": "string"
},
"inferred_amount": {
"type": "number"
},
"sender": {
"type": "string"
},
"vs": {
"type": "string"
},
"message": {
"type": "string"
},
"bank_id": {
"type": "string"
},
"sync_id": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"date",
"amount",
"manual_fix",
"person",
"purpose",
"inferred_amount",
"sender",
"vs",
"message",
"bank_id",
"sync_id"
]
},
"TotalCell": {
"properties": {
"text": {
"type": "string"
},
"status": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"text",
"status"
]
}
}
}

View File

@@ -0,0 +1,91 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "#/$defs/PaymentsResponse",
"$defs": {
"PaymentsResponse": {
"properties": {
"grouped_payments": {
"additionalProperties": {
"items": {
"$ref": "#/$defs/RawTransaction"
},
"type": "array"
},
"type": "object"
},
"sorted_people": {
"items": {
"type": "string"
},
"type": "array"
},
"attendance_url": {
"type": "string"
},
"payments_url": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"grouped_payments",
"sorted_people",
"attendance_url",
"payments_url"
]
},
"RawTransaction": {
"properties": {
"date": {
"type": "string"
},
"amount": {
"type": "number"
},
"manual_fix": {
"type": "string"
},
"person": {
"type": "string"
},
"purpose": {
"type": "string"
},
"inferred_amount": {
"type": "number"
},
"sender": {
"type": "string"
},
"vs": {
"type": "string"
},
"message": {
"type": "string"
},
"bank_id": {
"type": "string"
},
"sync_id": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"date",
"amount",
"manual_fix",
"person",
"purpose",
"inferred_amount",
"sender",
"vs",
"message",
"bank_id",
"sync_id"
]
}
}
}

View File

@@ -0,0 +1,26 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$ref": "#/$defs/VersionResponse",
"$defs": {
"VersionResponse": {
"properties": {
"tag": {
"type": "string"
},
"commit": {
"type": "string"
},
"build_date": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object",
"required": [
"tag",
"commit",
"build_date"
]
}
}
}

View File

@@ -30,8 +30,7 @@ JUNIOR_MONTHLY_RATE = {
"2026-03": 250 # reduced fee for March 2026 "2026-03": 250 # reduced fee for March 2026
} }
ADULT_MERGED_MONTHS = { ADULT_MERGED_MONTHS = {
#"2025-12": "2026-01", # keys are merged into values "2025-09": "2025-10", # keys are merged into values
#"2025-09": "2025-10"
} }
JUNIOR_MERGED_MONTHS = { JUNIOR_MERGED_MONTHS = {

View File

@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
idx_sender = get_col_index("Sender") idx_sender = get_col_index("Sender")
idx_message = get_col_index("Message") idx_message = get_col_index("Message")
idx_bank_id = get_col_index("Bank ID") idx_bank_id = get_col_index("Bank ID")
idx_vs = get_col_index("VS")
idx_sync_id = get_col_index("Sync ID")
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose} required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
missing = [name for name, idx in required.items() if idx == -1] missing = [name for name, idx in required.items() if idx == -1]
@@ -247,16 +249,33 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
def get_val(idx): def get_val(idx):
return row[idx] if idx != -1 and idx < len(row) else "" return row[idx] if idx != -1 and idx < len(row) else ""
def get_str(idx):
v = get_val(idx)
if isinstance(v, float) and v.is_integer():
return str(int(v))
return str(v)
def get_float(idx):
v = get_val(idx)
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).strip())
except (ValueError, TypeError):
return 0.0
tx = { tx = {
"date": format_date(get_val(idx_date)), "date": format_date(get_val(idx_date)),
"amount": get_val(idx_amount), "amount": get_float(idx_amount),
"manual_fix": get_val(idx_manual), "manual_fix": get_val(idx_manual),
"person": get_val(idx_person), "person": get_val(idx_person),
"purpose": get_val(idx_purpose), "purpose": get_val(idx_purpose),
"inferred_amount": get_val(idx_inferred_amount), "inferred_amount": get_val(idx_inferred_amount),
"sender": get_val(idx_sender), "sender": get_val(idx_sender),
"message": get_val(idx_message), "vs": get_str(idx_vs),
"message": get_str(idx_message),
"bank_id": get_val(idx_bank_id), "bank_id": get_val(idx_bank_id),
"sync_id": get_val(idx_sync_id),
} }
transactions.append(tx) transactions.append(tx)

446
scripts/views.py Normal file
View File

@@ -0,0 +1,446 @@
import json
import re
from datetime import datetime
from attendance import ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import canonical_member_key
def get_month_labels(sorted_months, merged_months):
labels = {}
for m in sorted_months:
dt = datetime.strptime(m, "%Y-%m")
merged_in = sorted([k for k, v in merged_months.items() if v == m])
if merged_in:
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
years = {d.year for d in all_dts}
if len(years) > 1:
parts = [d.strftime("%b %Y") for d in all_dts]
labels[m] = "+".join(parts)
else:
parts = [d.strftime("%b") for d in all_dts]
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
else:
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 adapt_junior_members(junior_members):
"""Convert 4-tuple junior fee data to (fee, total_count) for reconcile."""
adapted = []
for name, tier, fees_dict in junior_members:
adapted_fees = {}
for m, fee_data in fees_dict.items():
if len(fee_data) == 4:
fee, total_count, _, _ = fee_data
adapted_fees[m] = (fee, total_count)
else:
fee, count = fee_data
adapted_fees[m] = (fee, count)
adapted.append((name, tier, adapted_fees))
return adapted
def build_adults_view_model(
members,
sorted_months,
result,
transactions,
current_month,
*,
attendance_url,
payments_url,
bank_account,
):
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
formatted_results = []
for name in adult_names:
data = result["members"][name]
row = {
"name": name,
"months": [],
"balance": data["total_balance"],
"unpaid_periods": "",
"raw_unpaid_periods": "",
}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {
"expected": 0, "original_expected": 0,
"attendance_count": 0, "paid": 0, "exception": None,
})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = (
f"{override_amount} ({original_expected}) CZK ({count})"
if count > 0
else f"{override_amount} ({original_expected}) CZK"
)
else:
is_overridden = False
fee_display = (
f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
)
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected > 0:
amount_to_pay = max(0, expected - paid)
if paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
)
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
)
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
else:
cell_text = "-"
amount_to_pay = 0
if expected > 0 or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip,
})
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status,
})
def _settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
credits = sorted(
[{"name": n, "amount": _settled_balance(n)} for n in adult_names if _settled_balance(n) > 0],
key=lambda x: x["name"],
)
debts = sorted(
[{"name": n, "amount": abs(_settled_balance(n))} for n in adult_names if _settled_balance(n) < 0],
key=lambda x: x["name"],
)
raw_payments_by_person = group_payments_by_person(
transactions, [name for name, _, _ in members]
)
return dict(
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
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=result["unmatched"],
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=bank_account,
current_month=current_month,
)
def build_juniors_view_model(
junior_members,
adapted_members,
sorted_months,
result,
transactions,
current_month,
*,
attendance_url,
payments_url,
bank_account,
):
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
junior_names = sorted([name for name, tier, _ in adapted_members])
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
formatted_results = []
for name in junior_names:
data = result["members"][name]
row = {
"name": name,
"months": [],
"balance": data["total_balance"],
"unpaid_periods": "",
"raw_unpaid_periods": "",
}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {
"expected": 0, "original_expected": 0,
"attendance_count": 0, "paid": 0, "exception": None,
})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
if expected != "?" and isinstance(expected, int):
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
orig_fee_data = junior_members_dict.get(name, {}).get(m)
adult_count = 0
junior_count = 0
if orig_fee_data and len(orig_fee_data) == 4:
_, _, adult_count, junior_count = orig_fee_data
breakdown = ""
if adult_count > 0 and junior_count > 0:
breakdown = f":{junior_count}J,{adult_count}A"
elif junior_count > 0:
breakdown = f":{junior_count}J"
elif adult_count > 0:
breakdown = f":{adult_count}A"
count_str = f" ({count}{breakdown})" if count > 0 else ""
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
else:
is_overridden = False
fee_display = f"{expected} CZK{count_str}"
status = "empty"
cell_text = "-"
amount_to_pay = 0
is_unknown = original_expected == "?"
if is_unknown or (isinstance(expected, int) and expected > 0):
if is_unknown:
status = "empty"
cell_text = f"?{count_str}"
elif paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
amount_to_pay = expected - paid
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
)
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
amount_to_pay = expected
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
)
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
if (not is_unknown and isinstance(expected, int) and expected > 0) or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip,
})
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status,
})
junior_all_names = [name for name, _, _ in adapted_members]
def _junior_settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
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"],
)
raw_payments_by_person = group_payments_by_person(
transactions, [name for name, _, _ in adapted_members]
)
return dict(
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
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=result["unmatched"],
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=bank_account,
current_month=current_month,
)
def build_payments_view_model(transactions, member_names, *, attendance_url, payments_url):
grouped = group_payments_by_person(transactions, member_names)
for tx in 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())
return dict(
grouped_payments=grouped,
sorted_people=sorted_people,
attendance_url=attendance_url,
payments_url=payments_url,
)

View File

@@ -1044,8 +1044,6 @@
} }
}); });
var defaultFrom = Math.max(0, maxMonthIdx - 4);
fromSelect.value = defaultFrom;
toSelect.value = maxMonthIdx; toSelect.value = maxMonthIdx;
applyMonthFilter(); applyMonthFilter();
})(); })();

View File

@@ -1025,8 +1025,6 @@
} }
}); });
var defaultFrom = Math.max(0, maxMonthIdx - 4);
fromSelect.value = defaultFrom;
toSelect.value = maxMonthIdx; toSelect.value = maxMonthIdx;
applyMonthFilter(); applyMonthFilter();
})(); })();

View File

@@ -1,7 +1,17 @@
import unittest import unittest
import json
from unittest.mock import patch from unittest.mock import patch
from app import app from app import app
EXPECTED_ADULTS_KEYS = {
"months", "raw_months", "results", "totals", "member_data", "month_labels",
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
"payments_url", "bank_account", "current_month",
}
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
"""Test helper: call fetch_func directly, bypassing the cache layer.""" """Test helper: call fetch_func directly, bypassing the cache layer."""
@@ -97,5 +107,83 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'500/500 CZK', response.data) self.assertIn(b'500/500 CZK', response.data)
self.assertIn(b'?', response.data) self.assertIn(b'?', response.data)
def test_api_version(self):
"""Test /api/version returns BUILD_META keys as JSON."""
response = self.client.get('/api/version')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_members_with_fees')
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01']
)
mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
'purpose': '2026-01', 'message': 'test payment',
'sender': 'External Bank User', 'inferred_amount': 750,
'vs': '', 'sync_id': 'abc123',
}]
response = self.client.get('/api/adults')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_junior_members_with_fees')
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
mock_get_junior_members.return_value = (
[
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
],
['2026-01']
)
mock_fetch_sheet.return_value = [{
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
'vs': '', 'sync_id': 'def456',
}]
response = self.client.get('/api/juniors')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
def test_api_payments(self, mock_fetch_sheet, mock_cache):
"""Test /api/payments returns JSON with correct top-level keys."""
mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
'vs': '', 'sync_id': 'ghi789',
}]
response = self.client.get('/api/payments')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
self.assertIsInstance(data['grouped_payments'], dict)
self.assertIsInstance(data['sorted_people'], list)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()