Compare commits

...

32 Commits

Author SHA1 Message Date
fbc5a41d12 gitignore go/parity
All checks were successful
Deploy to K8s / deploy (push) Successful in 22s
2026-05-24 21:15:38 +02:00
7f801d27f5 Merge branch 'feat/go-m6-7-embed-verify'
All checks were successful
Deploy to K8s / deploy (push) Successful in 15s
2026-05-24 21:13:15 +02:00
10e2e9dc04 Merge pull request 'fix(reconcile): fill earliest month deficit first in multi-month allocations' (#35) from fix/fill-first-multi-month-allocation into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 13s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Successful in 52s
Reviewed-on: #35
2026-05-11 22:01:36 +00:00
8734089223 fix(reconcile): fill earliest month deficit first in multi-month allocations
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Replace proportional split with a fill-first loop that allocates
min(remaining, deficit) to each matched month in user-supplied order,
where deficit = expected - already_paid. Prior transactions' contributions
are now properly accounted for, so a second payment on overlapping months
fills only what's still owed instead of splitting proportionally by total
expected. Surplus after all deficits are covered goes to the credit bucket.

Fixes: Matyáš Thér 200+550 showing 566/183 instead of 500/250.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 23:59:36 +02:00
aaa876e593 fix(python): parse Fio 2-digit-year dates + add make sync-debug
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Successful in 59s
Fio's transparent page now serves recent rows as DD.MM.YY while older
rows stay DD.MM.YYYY. parse_czech_date only knew the 4-digit form so
every recent transfer was silently dropped — make sync-2026 reported
zero new transactions. Adds %d.%m.%y and %d/%m/%y to the format list,
mirroring the Go-side fix from 2026-05-07.

Also adds a Python analog of make go-sync-debug:
- --dry-run skips header write / append / sort and prints "would …" lines
- --print-fio-table prints aligned per-txn table with NEW/DUP status
- make sync-debug [DAYS=N] wrapper (default DAYS=30)
- always-on stderr diagnostics in fio_utils: which fetcher was chosen
  (with FIO_API_TOKEN-unset lag warning) + raw-vs-filtered counts, so
  this class of "scraper drops everything" bug surfaces immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 22:56:49 +02:00
d981392593 feat(go): M6.7 — single-binary embed verification
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Add TestEmbedCompleteness and TestStaticAssetsServed in
go/internal/web/assets_test.go. The completeness guard walks the
on-disk templates/ and static/ directories and asserts every file is
present in the corresponding embed.FS, catching forgotten files on
future additions. The static mux test hits /static/css/app.css and all
JS files through the same http.FileServerFS wiring used in server.go,
confirming assets are served from the embedded FS with correct
Content-Type and a 404 for unknown paths.

Standalone binary smoke test passed manually: binary copied to /tmp
(no adjacent templates/ or static/), assets served correctly.

Closes M6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 15:24:47 +02:00
f25552eef2 chore: CHANGELOG + tick M6.6 (f6ba85b) and M6.6.1 (4276d7b) in progress tracker
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 14:56:57 +02:00
4276d7b915 Merge pull request 'feat(go): M6.6.1 — QR payment popup modal on /adults and /juniors' (#33) from feat/go-m6-6-1-payment-qr-modal into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #33
2026-05-08 12:54:23 +00:00
f6ba85b18f Merge pull request 'feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages' (#32) from feat/go-m6-6-action-pages into main
Some checks failed
Deploy to K8s / deploy (push) Has been cancelled
Reviewed-on: #32
2026-05-08 12:54:16 +00:00
919845518c feat(go): M6.6.1 — QR payment popup modal on /adults and /juniors
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Replace bare <a href=/qr> Pay buttons with <button data-*> elements that
open an in-page #qrModal (matching Python's showPayQR UX), driven by a
new payment-qr.js vanilla-JS IIFE module.  Remove the now-dead qrHref /
qrHrefAll template helpers from render.go.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:49:35 +02:00
fe935235e8 feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version pages
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
- GET /qr: Czech QR Platba PNG; ports Python qr_code() exactly
  (account validation, amount clamping, * stripping, SPD format)
- GET /sync-bank: Fio sync → infer → cache flush with captured log
- GET+POST /flush-cache: form + action, shows deleted count
- GET /version: JSON alias of /api/version (Python parity)
- FlushCache() added to membership.Sources; wired through api.Handler
- web.ActionHandlers{BankSync} closure-based dep injection for sync
- New dep: github.com/skip2/go-qrcode
- TestQRBuildSPD (9 cases), TestServeQR, TestServeFlushCache{GET,POST},
  TestServeSync, TestServeVersion added

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 14:26:54 +02:00
e22ab8cc49 chore: CHANGELOG + tick M6.5 in progress tracker — SHA e53e238
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 13:19:19 +02:00
e53e238ca6 Merge pull request 'feat(go): M6.5 — member-detail modal JS module' (#31) from feat/go-m6-5-modal-js into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #31
2026-05-08 11:18:46 +00:00
309c26f209 feat(go): M6.5 — member-detail modal JS module for /adults and /juniors
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds static/js/member-detail.js: fetches /api/<page> once on page load,
caches the response, and renders a per-member detail modal on [i] row click.
Keyboard nav: Esc closes, ↑/↓ walk visible (filtered) rows. All modal CSS
was already in place from M6.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 13:14:41 +02:00
01573faced chore: CHANGELOG + tick M6.4 in progress tracker — SHA 689f1c0
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:48:42 +02:00
689f1c01fd Merge pull request 'feat(go): M6.4 — Go-native /payments page (grouped-by-person ledger)' (#30) from feat/go-m6-4-payments-page into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #30
2026-05-08 10:48:04 +00:00
cb8a09b571 feat(go): M6.4 — Go-native /payments page (grouped-by-person ledger)
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
- Extract AssemblePayments(ctx) from ServePayments in api/handler.go,
  mirroring the AssembleAdults/AssembleJuniors pattern
- Add PaymentsPageData view-model wrapper in render.go
- Rewire html_handler.go ServePayments to call AssemblePayments and
  render with PaymentsPageData
- Replace payments.tmpl placeholder with real grouped-by-person ledger:
  alphabetical member blocks, txn-table (Date/Amount/Purpose/Message),
  newest-first rows, Unmatched/Unknown bucket
- Append ledger CSS classes to app.css (.ledger-container, .member-block,
  .txn-table, .txn-date/amount/purpose/message, tr:hover)
- Add TestPaymentsPage markup test

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 12:29:32 +02:00
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
53 changed files with 4580 additions and 187 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ tmp/
# go build output # go build output
bin/ bin/
go/parity

View File

@@ -1,5 +1,109 @@
# Changelog # Changelog
## 2026-05-11 23:58 CEST — fix(reconcile): fill earliest month deficit first in multi-month allocations
- Multi-month payment allocation now fills the earliest in-window deficit first and spills
any remainder to later months, accounting for prior transactions' contributions to each month.
Previously a single transaction was split proportionally to each month's total expected fee,
ignoring what earlier transactions had already paid — surfaced by Matyáš Thér's 200+550 case
showing 566/183 instead of 500/250.
- Files: `scripts/match_payments.py`, `go/internal/domain/reconcile/reconcile.go`, tests, parity fixtures.
## 2026-05-11 22:56 CEST — fix(python): parse Fio 2-digit-year dates + add `make sync-debug` dry-run tool
- Fix: `scripts/fio_utils.py` `parse_czech_date` now accepts `DD.MM.YY` / `D.M.YY` in addition to the 4-digit-year variants. Fio's transparent page now mixes both forms in the same response; the 2-digit rows were being silently dropped, which caused `make sync-2026` to miss every recent transfer. Mirrors the Go-side fix from 2026-05-07 (CHANGELOG entry below).
- Added `--dry-run` and `--print-fio-table` flags to `scripts/sync_fio_to_sheets.py`, plus a `make sync-debug [DAYS=N]` Makefile target. Mirrors `make go-sync-debug`: fetches from Fio and dedupes against the sheet, prints `STATUS=NEW/DUP` per transaction, and prints per-row `Dry run: would append …` lines + `would sort by date` instead of touching the sheet.
- Added always-on stderr diagnostics in `scripts/fio_utils.py`: which fetcher was selected (authenticated API vs. transparent-page scraper with `FIO_API_TOKEN`-unset warning), and raw-vs-after-filter transaction counts on both paths — so this class of "scraper drops everything" bug surfaces immediately.
## 2026-05-08 15:24 CEST — feat(go): M6.7 — single-binary embed verification
- Confirmed `embed.FS` wiring is complete: templates parsed via `template.ParseFS(templateFS, ...)`, static assets served via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- Added `go/internal/web/assets_test.go` with two tests: `TestEmbedCompleteness` (walks disk vs embed.FS to catch forgotten files) and `TestStaticAssetsServed` (hits `/static/css/app.css` and all JS files through the mux, asserts 200 + Content-Type + non-empty body + 404 for unknown paths).
- Closes M6; single binary confirmed self-contained with no adjacent `templates/` or `static/` required at runtime.
- Key files: `go/internal/web/assets_test.go` (new).
## 2026-05-08 14:55 CEST — feat(go): M6.6.1 — Pay-button QR popup modal
- Restored the Python `showPayQR` in-page modal UX that was lost in M6.6 (Pay buttons were navigating the tab to the raw `/qr` PNG).
- Replaced `<a href="{{qrHref ...}}">Pay</a>` with `<button data-name|amount|month|raw-month>` on `/adults` and `/juniors`; click is handled by a new `static/js/payment-qr.js` IIFE module that opens `#qrModal` with title, account, amount, message, and the QR image.
- Added `#qrModal` markup to both templates; CSS `display:none` / `.active{display:flex}` rules added (content rules were already present from M6.1). `Esc`, `[close]`, and outside-click all dismiss; coexists with the M6.5 member-detail modal.
- Removed the now-dead `qrHref` / `qrHrefAll` template helpers from `render.go`.
- Markup tests in `html_handler_test.go` assert modal IDs, script tag, `data-bank-account`, and that no bare `href="/qr"` links remain.
- Key files: `go/internal/web/static/js/payment-qr.js`, `go/internal/web/templates/adults.tmpl`, `go/internal/web/templates/juniors.tmpl`, `go/internal/web/render.go`, `go/internal/web/static/css/app.css`.
## 2026-05-08 13:57 CEST — feat(go): M6.6 — /qr, /sync-bank, /flush-cache, /version
- Added `GET /qr`: generates Czech QR Platba PNG from SPD payload (account, amount, message query params); ports Python's `qr_code()` handler exactly including account validation, amount clamping, and `*` stripping.
- Implemented `GET /sync-bank`: runs Fio sync → infer payments → cache flush, captures output into `sync.tmpl` page with success/error banner.
- Implemented `GET /flush-cache` + `POST /flush-cache`: form + action that deletes cache files and shows deleted count.
- Added `GET /version` as a JSON alias of `GET /api/version` (Python parity).
- Added `FlushCache() (int, error)` to `membership.Sources` interface; implemented on `realSources` via `cache.FileCache.Flush()`.
- Introduced `web.ActionHandlers{BankSync}` — closure-based dep injection for sync, constructed in `serverCmd` with fio + sheets clients.
- New dependency: `github.com/skip2/go-qrcode`.
- Key files: `go/internal/web/qr.go`, `go/internal/web/html_handler.go`, `go/internal/web/server.go`, `go/internal/services/membership/loader.go`, `go/internal/web/templates/sync.tmpl`, `go/internal/web/templates/flush_cache.tmpl`.
## 2026-05-08 13:19 CEST — feat(go): M6.5 — member-detail modal JS module
- Added `static/js/member-detail.js`: fetches `/api/adults` or `/api/juniors` once on page load, caches the response, renders a per-member detail modal on `[i]` row click.
- Modal sections: status-per-month table, fee exceptions, other transactions, matched payment history, toggleable raw-payments debug view.
- Keyboard nav: `Esc` closes, `↑`/`↓` walk visible (name-filtered) rows; click outside modal content closes it.
- Added `[i]` info icon and `#memberModal` markup to `adults.tmpl` and `juniors.tmpl`; all CSS was already in place from M6.1.
- Added `TestModalMarkup` assertions to `html_handler_test.go`.
## 2026-05-08 12:48 CEST — feat(go): M6.4 — Go-native /payments page
- Extracted `AssemblePayments(ctx)` from the inlined JSON handler body, following the M6.2/M6.3 `AssembleAdults`/`AssembleJuniors` pattern.
- Added `PaymentsPageData` to `render.go`; wired `HTMLHandler.ServePayments` to call `AssemblePayments` and render the new template.
- Replaced the "Coming in M6.4" placeholder in `payments.tmpl` with the full grouped-by-person ledger: alphabetical `<h2>` member blocks, each with a `txn-table` (Date / Amount / Purpose / Bank Message), newest-first rows, `"Unmatched / Unknown"` bucket.
- Appended ledger CSS classes to `app.css` (`.ledger-container`, `.member-block`, `.txn-table`, `.txn-{date,amount,purpose,message}`, `tr:hover`).
- Added `TestPaymentsPage` markup test. No filters, no JS — matches the Python page.
## 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 ## 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. - `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.

View File

@@ -35,6 +35,7 @@ help:
@echo " make sync - Sync Fio transactions to Google Sheets" @echo " make sync - Sync Fio transactions to Google Sheets"
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)" @echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026" @echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet" @echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
@echo " make reconcile - Show balance report using Google Sheets data" @echo " make reconcile - Show balance report using Google Sheets data"
@echo " make venv - Sync virtual environment with pyproject.toml" @echo " make venv - Sync virtual environment with pyproject.toml"
@@ -125,6 +126,9 @@ sync-2025: $(PYTHON)
sync-2026: $(PYTHON) sync-2026: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date $(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
infer: $(PYTHON) infer: $(PYTHON)
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS) $(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)

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:** M5JSON-only `/api/...` routes ✅ **Current milestone:** M6Go-native HTML frontend
**Started:** 2026-05-04 **Started:** 2026-05-04
**Last updated:** 2026-05-07 (M5.4) **Last updated:** 2026-05-08 (M6.6 + M6.6.1 merged)
## How to use ## How to use
@@ -110,13 +110,14 @@ 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 - [x] **M6.4** `/payments` page: grouped-by-person ledger view — `689f1c0`
- [ ] **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, ↑/↓) - [x] **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, ↑/↓) — `e53e238`
- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages - [x] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages — `f6ba85b`
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets - [x] **M6.6.1** Pay-button QR popup modal (`payment-qr.js`); restores Python `showPayQR` UX lost in M6.6 — `4276d7b`
- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets — (pending merge)
**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end. **Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end.

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

@@ -0,0 +1,215 @@
# M6.4 — Go-native `/payments` page (grouped-by-person ledger)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-1220-go-m6-4-payments-page.md`. The first execution
> step after approval is to copy this file there.
## Context
The Go port of the Flask app is at milestone **M6.4**: replace the placeholder
`/payments` template ([go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl))
with a feature-equivalent grouped-by-person ledger view, matching the Python
[templates/payments.html](templates/payments.html) page.
Compared to M6.2 (`/adults`) and M6.3 (`/juniors`), this page is **structurally
much simpler**:
- No filters (no name input, no month-range selects).
- No totals / credits / debts / unmatched-as-extra-section.
- No JS (no `filters.js`, no modal — modal is M6.5 territory and Python's
payments page itself has none).
- No QR / Pay buttons.
- Single grouping dimension: **by person**, sorted alphabetically, with a
synthetic `"Unmatched / Unknown"` bucket appended.
- Each person's transactions sorted **newest-first**.
- Columns: Date, Amount (right-aligned, green), Purpose, Bank Message.
The JSON API side is already done (`/api/payments` at
[go/internal/web/api/handler.go:78-86](go/internal/web/api/handler.go#L78-L86)),
returning a `PaymentsResponse{ GroupedPayments, SortedPeople, AttendanceURL,
PaymentsURL }`. The HTML side just needs to consume it.
## Approach
Mirror the M6.2/M6.3 pattern: extract an `AssembleX(ctx)` method, add a typed
`PageData` wrapper, render a real template, lift the page-specific CSS into the
shared stylesheet.
### 1. Extract `AssemblePayments(ctx) (PaymentsResponse, error)`
In [go/internal/web/api/handler.go](go/internal/web/api/handler.go), refactor
`ServePayments` (currently inlines the load+build at lines 78-86) to mirror
`AssembleAdults` / `AssembleJuniors`:
```go
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
resp, err := h.AssemblePayments(r.Context())
if err != nil { h.writeError(w, r, err); return }
writeJSON(w, resp)
}
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil { return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err) }
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
}
```
Pure refactor — `make parity` should report zero diffs on `/api/payments`.
### 2. Add `PaymentsPageData` wrapper
In [go/internal/web/render.go](go/internal/web/render.go), alongside
`AdultsPageData` / `JuniorsPageData`:
```go
type PaymentsPageData struct {
PageData
Data api.PaymentsResponse
Error string
}
```
No changes needed to `pageNames``"payments"` is already registered.
### 3. Rewire HTML handler
Replace [go/internal/web/html_handler.go:50-52](go/internal/web/html_handler.go#L50-L52):
```go
func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
data := PaymentsPageData{ PageData: PageData{Active: "payments", Build: h.build} }
resp, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil { data.Error = err.Error() } else { data.Data = resp }
h.renderer.Render(w, "payments", data)
}
```
### 4. Replace template stub
Rewrite [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl)
(currently a 5-line placeholder). Structure:
```gotmpl
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">
All bank transactions from the
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
grouped by member. Names are matched against the
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="ledger-container">
{{range $person := .Data.SortedPeople}}
<div class="member-block">
<h2>{{$person}}</h2>
<table class="txn-table">
<thead><tr>
<th class="txn-date">Date</th>
<th class="txn-amount">Amount</th>
<th class="txn-purpose">Purpose</th>
<th class="txn-message">Bank Message</th>
</tr></thead>
<tbody>
{{range $tx := index $.Data.GroupedPayments $person}}
<tr>
<td class="txn-date">{{$tx.Date}}</td>
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
<td class="txn-purpose">{{$tx.Purpose}}</td>
<td class="txn-message">{{$tx.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}
```
Notes:
- `printf "%.0f"` matches the visual style of integer CZK amounts (Python prints
the float as-is, but bank-row amounts are always whole CZK). If the user
prefers exact Python parity (`750.0 CZK`), drop the `printf` and use
`{{$tx.Amount}}`.
- Iterating a `map` requires using `.Data.SortedPeople` to drive order then
`index $.Data.GroupedPayments $person` — Go templates don't preserve map
insertion order.
### 5. Add CSS classes to `app.css`
Append the page-specific rules from [templates/payments.html:111-164](templates/payments.html#L111-L164)
to [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css):
`.ledger-container`, `.member-block`, `.txn-table`, `.txn-table th/td`,
`.txn-date`, `.txn-amount`, `.txn-purpose`, `.txn-message`, `tr:hover`. The
terminal-green palette already matches.
### 6. Unit test
Add a markup-level test to `go/internal/web/html_handler_test.go` (the file
created in M6.2/M6.3) following the same pattern: stub `Sources` returning a
fixed transaction list, assert response is HTML 200, assert key strings appear
(person names as `<h2>`, "Payments Ledger" `<h1>`, expected amount, "Unmatched
/ Unknown" bucket when an unattributed tx exists).
## Critical files
| Action | Path |
|---------|------|
| edit | [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — extract `AssemblePayments` |
| edit | [go/internal/web/render.go](go/internal/web/render.go) — add `PaymentsPageData` |
| edit | [go/internal/web/html_handler.go](go/internal/web/html_handler.go) — rewire `ServePayments` |
| rewrite | [go/internal/web/templates/payments.tmpl](go/internal/web/templates/payments.tmpl) — real template |
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — append ledger CSS |
| edit | `go/internal/web/html_handler_test.go` — payments markup test |
No changes to `server.go`, `assets.go`, `Sources` interface, `buildPaymentsResponse`,
or any domain code — these were completed in M5 / M6.1.
## Reused existing helpers
- [go/internal/web/api/build_payments.go](go/internal/web/api/build_payments.go) — `buildPaymentsResponse` (existing, unchanged)
- [go/internal/web/api/build_common.go:84-117](go/internal/web/api/build_common.go#L84-L117) — `groupRawPaymentsByPerson` handles canonicalization, comma-splitting, `[?]` stripping, newest-first sort
- [go/internal/web/api/handler.go:116-129](go/internal/web/api/handler.go#L116-L129) — `allMemberNames`
- [go/internal/web/templates/partials/nav.tmpl](go/internal/web/templates/partials/nav.tmpl) and `footer.tmpl` — already include the `[Payments Ledger]` nav link
## Verification
End-to-end:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make parity` — confirm the `AssemblePayments` extraction is byte-equal on
`/api/payments`.
3. `make web-go &` then:
- `curl -i localhost:8080/payments | head -30` — HTTP 200, `text/html`.
- Browser load `http://localhost:8080/payments`:
- "Payments Ledger" heading + description with two clickable sheet links.
- Alphabetical member blocks, each with a table.
- Amounts right-aligned, green, "CZK" suffix.
- Newest-first ordering inside each block.
- "Unmatched / Unknown" bucket sorts in alphabetical position (capital U →
near end).
- Hover on a row darkens the background.
- Nav highlights `[Payments Ledger]` as the active link.
- Cross-check with Python: `make web-py &``http://localhost:5001/payments`
should look visually identical except for nav styling.
4. `make web-py & make web-go &` then `tea pr create` once committed (per
project branch-per-feature workflow).
## Branching
Create `feat/go-m6-4-payments-page` off `main`, push, open MR via `tea`.
Follow project conventions: Co-Authored-By trailer, CHANGELOG entry once user
confirms it works, tick **M6.4** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:116](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L116)
with the merge SHA after the user merges in Gitea.
## Out of scope (deferred)
- **Modal / `[i]` info button** — M6.5 (not present in Python `/payments` either).
- **Period selector** — `/payments` has none in Python; not adding one.
- **embed.FS deploy verification** — M6.7.
- **Shared partial extraction** — payments structurally diverges from
adults/juniors enough that there's no obvious common partial to lift; defer.

View File

@@ -0,0 +1,288 @@
# M6.5 — Member-detail modal JS module (`/adults`, `/juniors`)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-HHMM-go-m6-5-modal-js.md` (use `date "+%Y-%m-%d-%H%M"`
> when copying). The first execution step after approval is to copy this file
> there. Branch: `feat/go-m6-5-modal-js` off `main`.
## Context
The Go port of the Flask app is at milestone **M6.5**: the `/adults` and
`/juniors` pages currently render their tables ([go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl),
[go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl))
but have no row-click member-detail modal — the feature that lets a user click
a member's row to inspect status per month, exception overrides, "other"
transactions, the matched payment list, and a debug raw-payments view.
The Python implementation lives inline in
[templates/adults.html:718-993](templates/adults.html#L718-L993)
(matching code in [templates/juniors.html](templates/juniors.html) — same
modal markup and JS). It uses globals injected via Jinja:
`memberData`, `sortedMonths`, `monthLabels`, `rawPaymentsByPerson`. M6.5
replaces this with a clean static JS module.
Per milestone wording — *"fetches `/api/adults` (or juniors), renders
status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓)"* —
and per the user's design choice: the JS module fetches `/api/<page>` **once
on page load** and caches the response in a module-scoped variable. Subsequent
row clicks render synchronously from cache. This keeps HTML purely
server-rendered and reuses the parity-tested JSON contract already shipped in
M5.
All the building blocks are already in place:
- **JSON API**: `/api/adults` and `/api/juniors` already return
`member_data`, `month_labels`, `raw_payments` as nested objects (see
[go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42)
and the analogous juniors response). Junior `Expected` serialises as `"?"`
for the unknown sentinel via custom `MarshalJSON`
([go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29)) — JS sees a literal string `"?"`.
- **CSS**: `#memberModal`, `.modal-content`, `.modal-section`, `.modal-table`,
`.tx-list`, `.tx-item`, `.tx-meta`, `.tx-amount`, `.info-icon`, `.raw-toggle`
were lifted whole during M6.1 and live in
[go/internal/web/static/css/app.css](go/internal/web/static/css/app.css)
(lines 168-487). No CSS work needed.
- **Member-row hooks**: `tr.member-row[data-name="..."]` and `td.member-name`
are already rendered by both templates.
- **JS module convention**: `static/js/filters.js` is loaded with `<script src="/static/js/filters.js" defer>`;
M6.5 adds a sibling `static/js/member-detail.js` with the same loading
pattern.
## Approach
### 1. Add `[i]` info icon + `#memberModal` markup to both templates
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl):
a. Inside the `<td class="member-name">{{$row.Name}}</td>` cell (line 59 of
each template), append the icon button:
```gotmpl
<td class="member-name">{{$row.Name}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></td>
```
Using a `data-name` attribute and a CSS class (rather than `onclick="..."`)
keeps the template free of inline JS — the module wires up the listener
itself.
b. After the closing `</div>` of the table-container / sections, before the
`<script>` tag at line 140 (adults) / 120 (juniors), inject the modal
markup. Mirror the Python structure verbatim
([templates/adults.html:644-707](templates/adults.html#L644-L707)):
```gotmpl
<div id="memberModal" class="modal" onclick="if (event.target === this) this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title" id="modalMemberName">—</span>
<span id="modalTier" class="modal-tier"></span>
<a href="#" class="modal-close" onclick="event.preventDefault(); document.getElementById('memberModal').classList.remove('active')">[x]</a>
</div>
<div class="modal-section">
<div class="modal-section-title">Status by Month</div>
<table class="modal-table">
<thead><tr><th>Month</th><th>Sessions</th><th>Expected</th><th>Paid</th><th>Status</th></tr></thead>
<tbody id="modalStatusBody"></tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display:none">
<div class="modal-section-title">Exception Overrides</div>
<div id="modalExceptionList" class="tx-list"></div>
</div>
<div class="modal-section" id="modalOtherSection" style="display:none">
<div class="modal-section-title">Other Payments</div>
<div id="modalOtherList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">Matched Transactions</div>
<div id="modalTxList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments (debug)
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display:none"></div>
</div>
</div>
</div>
```
c. Add the script tag alongside `filters.js`. To tell the module which API
endpoint to fetch, use a `data-page` attribute on `body` (or on the
filter container — already exists). Simplest: add `data-page="adults"` /
`data-page="juniors"` to the existing `<div id="filterContainer">`.
The module reads it on load.
```gotmpl
<script src="/static/js/member-detail.js" defer></script>
```
### 2. Create [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js)
A single shared module — both pages use the same DOM contract, so the same
JS works for both. Structure (vanilla ES, no build step, matches `filters.js`
style — IIFE, `'use strict'`, no globals leaked):
```js
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const page = container.dataset.page; // "adults" | "juniors"
if (!page) return;
let apiData = null; // cached /api/<page> response
let currentMemberName = null;
// ── Data load ─────────────────────────────────────────────────────────
async function loadData() {
if (apiData) return apiData;
const r = await fetch('/api/' + page);
if (!r.ok) throw new Error('failed to fetch /api/' + page);
apiData = await r.json();
return apiData;
}
// Pre-warm immediately so first click is instant.
loadData().catch(err => console.error('[member-detail]', err));
// ── Modal render ──────────────────────────────────────────────────────
async function showMember(name) { /* port of Python showMemberDetails */ }
function toggleRawPayments(ev) { /* port */ }
function closeModal() { document.getElementById('memberModal').classList.remove('active'); }
function navigateMember(dir) { /* port — walk visible .member-row */ }
// ── Wiring ────────────────────────────────────────────────────────────
document.querySelectorAll('.info-icon[data-name]').forEach(el => {
el.addEventListener('click', ev => {
ev.stopPropagation();
showMember(el.dataset.name);
});
});
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
document.addEventListener('keydown', e => {
const modal = document.getElementById('memberModal');
if (e.key === 'Escape') { closeModal(); return; }
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
});
}());
```
The four `port` functions transcribe Python literally, with two adjustments:
- `data.tier` — read directly from the API response (already present as
`AdultsMemberData.Tier` / `JuniorsMemberData.Tier`).
- Junior `expected` may equal the literal string `"?"` (sentinel from
`Expected.MarshalJSON`). The status-row formatter must treat string
`expected` as "unknown / single attendance" and skip the numeric
comparisons — same logic Python uses when it sees `"?"`.
`navigateMember` walks `document.querySelectorAll('tr.member-row')` filtered
by `style.display !== 'none'`, finds the index whose `dataset.name`
matches `currentMemberName`, and calls `showMember` on the next/previous
visible row. Identical to Python lines 944-962 but uses `dataset.name`
instead of parsing `childNodes[0].textContent`.
### 3. Wire `data-page` attribute
Append `data-page="adults"` to the `filterContainer` div in `adults.tmpl`,
`data-page="juniors"` in `juniors.tmpl`. One-line edit per template.
### 4. Unit test
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
with assertions that the rendered HTML for `/adults` and `/juniors` contains:
- `class="info-icon"` with the expected `data-name` per fixture row,
- `id="memberModal"`,
- `<script src="/static/js/member-detail.js"`,
- `data-page="adults"` / `data-page="juniors"` on the filter container.
These are markup-level checks only — the JS module behaviour is not unit
tested in Go (would require headless-browser tooling that is out of scope
for this milestone). Manual browser verification covers it.
## Critical files
| Action | Path |
|--------|------|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — add `[i]` icon, modal markup, `data-page`, script tag |
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
| new | `go/internal/web/static/js/member-detail.js` — modal module |
| edit | `go/internal/web/html_handler_test.go` — markup assertions |
No changes to: `app.css` (already complete), `assets.go` (the `embed.FS`
glob `static/js/*` already picks up new files), `server.go`, `render.go`,
the API handlers, or any domain code.
## Reused existing infrastructure
- [go/internal/web/api/handler.go](go/internal/web/api/handler.go) — `/api/adults`, `/api/juniors` already serve the modal's data
- [go/internal/web/api/adults.go:27-42](go/internal/web/api/adults.go#L27-L42), [go/internal/web/api/juniors.go](go/internal/web/api/juniors.go) — typed responses
- [go/internal/web/api/types.go:24-29](go/internal/web/api/types.go#L24-L29) — `Expected` "?" sentinel marshalling
- [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — modal CSS already lifted
- [go/internal/web/static/js/filters.js](go/internal/web/static/js/filters.js) — IIFE / `'use strict'` / `data-*` attribute style to match
- [templates/adults.html:725-962](templates/adults.html#L725-L962) — Python reference implementation to port (logic, not literal copy)
## Verification
End-to-end:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make web-go &` — boot Go on `:8080`.
3. Browser: `http://localhost:8080/adults`
- Click `[i]` next to a member name → modal opens with that member's data.
- Status table populated; cells colour-coded (cell-ok / cell-unpaid).
- Exception section visible only if member has exceptions; exception
amount shown with "*" override style.
- "Other Payments" section visible only if member has other-purpose
transactions.
- Matched transactions list newest-first; raw-payments section starts
hidden, `[show]` toggles to `[hide]`.
- `Esc` closes the modal; `ArrowDown` / `ArrowUp` walk visible rows
(after applying name filter).
- Click outside the `.modal-content` closes the modal.
- Open DevTools Network tab → reload; confirm one request to `/api/adults`
fires on page load (data is pre-warmed), no further requests on row
click.
4. Browser: `http://localhost:8080/juniors`
- Same checks; verify `expected = "?"` rows render the question-mark
status without throwing JS errors.
5. Cross-check against Python: `make web-py &` → `http://localhost:5001/adults`
should look visually equivalent (modulo nav styling) for the same fixture.
6. After commit: `tea pr create --base main --head feat/go-m6-5-modal-js`.
## Branching
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-5-modal-js` off `main`,
commit with `Co-Authored-By` trailer, push with `-u`, open MR via
`tea pr create`. Do not merge from CLI. After the user merges in Gitea:
- Add CHANGELOG entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
- Tick **M6.5** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:117](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L117)
with the merge SHA.
## Out of scope (deferred)
- **`/qr` modal** — separate `#qrModal` element, lives in M6.6 alongside
`/sync-bank`, `/flush-cache`, `/version`.
- **Pay buttons inside the modal** — Python's modal does not include
Pay-from-modal; current Go pages already render row-level Pay buttons
in M6.2/M6.3.
- **Modal on `/payments`** — Python `/payments` has no modal, neither does
Go (confirmed in M6.4 plan).
- **Deep-linking (`/adults#name=Foo`)** — not in Python, not adding.
- **Headless-browser JS tests** — out of scope for this milestone; manual
browser verification per §Verification.

View File

@@ -0,0 +1,124 @@
# M6.6 — `/qr`, `/sync-bank`, `/flush-cache`, `/version` (Go parity)
> On approval, relocate this file to `docs/plans/YYYY-MM-DD-HHMM-go-m6-6-action-pages.md` per the repo's `CLAUDE.md` "Plans" convention before starting work.
## Context
The Go-rewrite progress tracker ([2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118)) lists M6.6 as:
> `/qr, /sync-bank, /flush-cache, /version pages`
After M6.4 (payments page) and M6.5 (member-detail modal JS), the only remaining pre-deployment HTML/action endpoints in Python that the Go binary doesn't yet implement are these four. The adults/juniors templates already produce `/qr?...` URLs via the `qrHref` / `qrHrefAll` helpers ([render.go:47-71](go/internal/web/render.go#L47-L71)) — the matching handler is the missing piece. `sync` and `flush_cache` templates exist as "Coming in M6.6" stubs and need real markup + working handlers. `/api/version` already serves build metadata; `/version` just needs to alias it (Python parity).
After M6.6 the M6 gate ("Browser smoke on :8080: all pages render … QR loads, sync/flush work end-to-end") is reachable.
## Confirmed design choices
- **`/sync-bank`**: Python-faithful — GET both renders and triggers the sync, captures stdout/stderr into a log buffer, surfaces success/failure via a flag. Matches existing nav `[Sync Bank Data]` link.
- **`/version`**: JSON alias of `/api/version`. No HTML page.
- **QR library**: `github.com/skip2/go-qrcode`. Default level `qrcode.Medium`, size 256.
## Scope per endpoint
### 1. `GET /qr` — PNG only
Match Python ([app.py:321-356](app.py#L321-L356)) verbatim semantics:
1. Read `account` (default `cfg.BankAccount`), `amount` (default `"0"`), `message` (default `""`).
2. Validate account against `^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`; on miss, silently fall back to `cfg.BankAccount`.
3. Parse amount as float64; clamp to `[0, 10_000_000]`; format `%.2f`; on parse error → `"0.00"`.
4. Truncate message to 60 runes; strip all `*`.
5. If account contains `/`, split into `{number}*BC:{bankcode}`; else use raw.
6. SPD payload: `SPD*1.0*ACC:{acc}*AM:{amount}*CC:CZK*MSG:{msg}` (no `BC:` key when IBAN — Python doesn't either).
7. `qrcode.Encode(payload, qrcode.Medium, 256)` → write bytes with `Content-Type: image/png`.
No template, no HTML.
### 2. `GET /sync-bank` — triggers + renders
Match Python ([app.py:124-151](app.py#L124-L151)):
1. Build a `*bytes.Buffer` to capture step output.
2. For each step write a header line then call:
- `banksync.SyncToSheets(ctx, cfg.PaymentsSheetID, fioClient, sheetsClient, banksync.SyncOpts{From: jan1ThisYear, To: dec31ThisYear, Sort: true})`
- `banksync.InferPayments(ctx, cfg.PaymentsSheetID, sheetsClient, sources, banksync.InferOpts{})`
- `cacheFlusher.Flush()` — append `"%d cache files deleted"`.
3. On any error: append `err.Error()` + a stack trace via `runtime/debug.Stack()`, set `success=false`, continue rendering.
4. Render `sync` template with `SyncPageData{ PageData; Output string; Success bool }`.
The `banksync` package's stdout-printing functions can be redirected by passing an `io.Writer` — confirm during implementation; if they only print to `os.Stdout`, capture by tee'ing through a `slog` handler or briefly swapping `os.Stdout` (or extend the package with a `*Writer` option). **Preferred**: add a `Logger io.Writer` field to `banksync.SyncOpts` / `InferOpts`. This is a minor extension to existing services, not a rewrite.
### 3. `/flush-cache` — GET form, POST action
Match Python ([app.py:117-122](app.py#L117-L122)):
- `GET` → render `flush_cache` with `Flushed=false`.
- `POST` → call `cacheFlusher.Flush()`, render with `Flushed=true, Deleted=n`.
### 4. `GET /version` — JSON
One-liner: register the existing `api.VersionHandler` (or its inline equivalent in [api/version.go](go/internal/web/api/version.go)) on the `/version` path as well as `/api/version`. Output: `{"tag", "commit", "build_date"}`.
## Files to create / modify
### New files
- **[go/internal/web/templates/qr.tmpl](go/internal/web/templates/qr.tmpl)** — *not actually needed*; remove from plan if `/qr` is PNG-only. (Decision: skip — no template.)
- Replace stub **[go/internal/web/templates/sync.tmpl](go/internal/web/templates/sync.tmpl)** with full markup: title, status banner colored by `Success`, `<pre>` of `Output`, "Run again" link.
- Replace stub **[go/internal/web/templates/flush_cache.tmpl](go/internal/web/templates/flush_cache.tmpl)** with: form (`POST /flush-cache``<button>[Flush Cache]</button>`), success banner when `Flushed`.
### Modified files
- **[go/go.mod](go/go.mod)** / `go.sum` — add `github.com/skip2/go-qrcode`.
- **[go/internal/web/server.go](go/internal/web/server.go)** — register `GET /qr`, `GET /sync-bank` (replace stub), `GET /flush-cache` (form), `POST /flush-cache` (action), `GET /version`. Widen `Run(...)` signature to accept `fio.Client`, `sheets.Client`, and a `cache.Flusher` — these flow into a new `Deps` struct on `HTMLHandler`.
- **[go/internal/web/html_handler.go](go/internal/web/html_handler.go)** — add `ServeQR`, real `ServeSync`, split `ServeFlushCacheGET` / `ServeFlushCachePOST`, add `ServeVersion` (delegates to api package).
- **[go/internal/web/render.go](go/internal/web/render.go)** — add `SyncPageData{ PageData; Output string; Success bool }` and `FlushPageData{ PageData; Flushed bool; Deleted int }` view-models. (`pageNames` already includes `"sync"` and `"flush_cache"`; no change.)
- **[go/internal/web/qr.go](go/internal/web/qr.go)** *(new file)* — pure helper: `BuildSPD(account, amount, message string, defaultAccount string) (payload string)` + `RenderQRCode(payload string) ([]byte, error)`. Easier to unit-test than a method on the handler.
- **[go/internal/services/membership/loader.go](go/internal/services/membership/loader.go)** — add a small `CacheFlusher` interface (`Flush() (int, error)`); have `Sources` aggregate include it. *Alternative considered*: pass `*cache.FileCache` straight into `web.Run`. Choose whichever needs fewer test-stub edits — likely the direct-pass approach, since stubs in tests don't need cache behavior. **Decision: pass `cache.Flusher` directly into `web.Run` as a separate dependency, not through `Sources`.** Simpler, fewer interface changes.
- **[go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)** + `infer.go` — accept an optional `io.Writer` (in `SyncOpts.Logger` / `InferOpts.Logger`) and route progress prints through it; default to `os.Stdout` when nil for CLI parity.
- **[go/cmd/fuj/main.go](go/cmd/fuj/main.go)** — in the `server` subcommand, build `fio.Client`, `sheets.Client`, and `cache.Flusher` (already constructed inside `membership.NewSources` — expose it or construct a sibling) and pass them into `web.Run`.
## Reuse (already in the codebase)
- **Bank sync**: `banksync.SyncToSheets` ([go/internal/services/banksync/sync.go](go/internal/services/banksync/sync.go)) and `banksync.InferPayments` ([go/internal/services/banksync/infer.go](go/internal/services/banksync/infer.go)) — same calls the CLI `sync` / `infer` subcommands use ([go/cmd/fuj/main.go:184](go/cmd/fuj/main.go#L184), [:222](go/cmd/fuj/main.go#L222)).
- **Cache flush**: `(*cache.FileCache).Flush() (int, error)` ([go/internal/io/cache/filecache.go:92](go/internal/io/cache/filecache.go#L92)) — already returns the deleted count.
- **Build metadata**: `BuildInfo{Version, Commit, BuildDate}` ([go/internal/web/server.go:28-35](go/internal/web/server.go#L28-L35)) is already plumbed everywhere; `/api/version` JSON shape is already correct ([go/internal/web/api/version.go](go/internal/web/api/version.go)).
- **Template render pattern**: `Renderer.Render(w, name, data)` ([go/internal/web/render.go:98-108](go/internal/web/render.go#L98-L108)).
- **Test scaffolding**: `web.NewRenderer()` + `httptest.NewRequest/NewRecorder` ([go/internal/web/html_handler_test.go:52-94](go/internal/web/html_handler_test.go#L52-L94)).
## Tests
Add to `go/internal/web/html_handler_test.go` (and one new `qr_test.go` for the SPD builder):
- **`TestQRBuildSPD`** *(unit, in [go/internal/web/qr_test.go](go/internal/web/qr_test.go))* — table-driven: invalid account → fallback; `/`-account → `{num}*BC:{bc}`; IBAN → raw; amount `-1``0.00`; amount `99999999``9999999.00`; amount `"abc"``0.00`; message with `*` stripped; message length-clamped to 60.
- **`TestServeQR`** — request `/qr?account=…&amount=10&message=Hi`; assert 200, `Content-Type: image/png`, body starts with PNG magic `\x89PNG\r\n\x1a\n`.
- **`TestServeFlushCacheGet`** — assert form rendered, no banner.
- **`TestServeFlushCachePost`** — fixture flusher returns 3; assert banner says "3" and `Flushed`.
- **`TestServeSync`** — fixture banksync stubs return `(2, nil)` and `(1, nil)`, fixture flusher returns 4; assert page renders, status is success, output contains the section headers.
- **`TestServeVersion`** — assert `application/json`, body `{"tag":"v0","commit":"abc1234","build_date":"2026-01-01"}`.
- Extend the `TestHTMLHandlerSmoke` table to cover `/qr` and `/version`.
## Verification (end-to-end)
1. `cd go && go build ./... && go test ./...`
2. `cd go && go run ./cmd/fuj server` — open http://localhost:8080
3. Smoke:
- **/adults** → click `Pay` next to any unpaid month → modal shows QR image (this exercises `/qr`). Confirm a Czech banking app can scan it (visual check).
- Direct `/qr?account=2702008874/2010&amount=700&message=Test` → PNG renders.
- **/sync-bank** → click `[Sync Bank Data]` in nav → page shows "Syncing…", then sections + "Inferred Payments", "Cache Flushed", green status banner.
- **/flush-cache** → click `[Flush Cache]` → success banner with file count.
- **/version** in browser → JSON visible.
- Adults / juniors / payments still render (no regressions from `Run` signature change).
4. Run the parity diff: `go run ./cmd/parity``/api/version` must still match Python.
5. Append a CHANGELOG entry per `CLAUDE.md`; tick `M6.6` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:118](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L118).
## Branch / MR
Per `CLAUDE.md` workflow: `feat/go-m6-6-action-pages` off `main`, push with `-u`, open with `tea pr create --base main --head feat/go-m6-6-action-pages`.
## Out of scope (deferred)
- Auth / CSRF protection on `/sync-bank` and `/flush-cache` — Python has none either; deferred to M8 hardening.
- M6.7 (single-binary embed verification) — separate ticket.
- Streaming progress for `/sync-bank` (currently buffered until completion, like Python).
- Configurable QR error-correction level / size.

View File

@@ -0,0 +1,335 @@
# M6.6.1 — Pay-button QR popup modal (`/adults`, `/juniors`)
> Plan-mode note: per project convention this plan should live at
> `docs/plans/2026-05-08-1439-go-m6-6-1-payment-qr-modal.md`. The first
> execution step after approval is to copy this file there.
> Branch: `feat/go-m6-6-1-payment-qr-modal` off `main`.
## Context
M6.6 ([docs/plans/2026-05-08-1334-go-m6-6-action-pages.md](docs/plans/2026-05-08-1334-go-m6-6-action-pages.md))
landed the backend `/qr` PNG endpoint plus `/sync-bank`, `/flush-cache`,
`/version`. While doing it, the **client-side** half of the Pay flow was
collapsed from a modal-with-details into a plain `<a href="/qr?...">`
clicking Pay now navigates the whole tab to the raw PNG.
The Python app instead opens an in-page `#qrModal` showing the QR image
*plus* labelled details (account, amount, message, title), then closes
the modal on outside-click or `[close]`
([templates/adults.html:631-648](templates/adults.html#L631-L648),
JS at [templates/adults.html:963-993](templates/adults.html#L963-L993)).
This is the UX the user expects from the dashboard — staying on the
table while displaying the QR is significantly more practical than
losing context to a fullscreen image.
M6.6.1 restores parity: the Pay/Pay-All buttons open an in-page
`#qrModal`, identical in structure to the Python original, driven by a
new vanilla-JS module `payment-qr.js` that mirrors the `member-detail.js`
convention from M6.5.
All the building blocks are already in place:
- **`/qr` endpoint** ships PNGs ([go/internal/web/qr.go](go/internal/web/qr.go), tested by [TestServeQR](go/internal/web/html_handler_test.go#L207-L231)).
- **CSS** for `#qrModal .modal-content`, `.qr-image`, `.qr-details` is
already in [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — lifted during M6.1.
- **Template helpers** `qrHref` / `qrHrefAll` ([go/internal/web/render.go:63-85](go/internal/web/render.go#L63-L85))
already produce the correct query string + Czech `MM/YYYY` format
for the `message=` param. They're useful for sanity (and for any
no-JS fallback) but become dead code post-M6.6.1 — see §Out of scope.
- **Bank account** is already exposed as `$.Data.BankAccount` to every
template that has a Pay button.
- **JS module convention**: `member-detail.js` is the M6.5 baseline —
IIFE, `'use strict'`, event delegation off `data-*` attributes, no
inline `onclick`, no globals leaked.
## Approach
### 1. Replace `<a href="{{qrHref ...}}">` with `<button data-...>`
In [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl)
and [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl)
(both have identical Pay markup at lines 65 and 72):
```gotmpl
<button type="button" class="pay-btn"
data-name="{{$row.Name}}"
data-amount="{{$cell.Amount}}"
data-month="{{$cell.Month}}"
data-raw-month="{{$cell.RawMonth}}">Pay</button>
```
```gotmpl
<button type="button" class="pay-btn"
data-name="{{$row.Name}}"
data-amount="{{$row.PayableAmount}}"
data-month="{{$row.UnpaidPeriods}}"
data-raw-month="{{$row.RawUnpaidPeriods}}"
data-pay-all="1">Pay All</button>
```
Why `<button type="button">` and not `<a>`: the click no longer
navigates — the JS handles it. `type="button"` prevents accidental form
submission. A no-JS fallback could be added later by re-introducing
`href="{{qrHref ...}}"` and letting the JS call `e.preventDefault()`
deferred until anyone actually asks (see §Out of scope).
`data-month` carries the human-readable period (e.g. `January 2026`,
or `January 2026 + February 2026` for Pay-All) which the modal title
displays. `data-raw-month` carries the machine format (`2026-01`,
`2026-01+2026-02`) which the JS converts to `MM/YYYY` for the QR
message — same conversion Python's `showPayQR` does at
[templates/adults.html:966-968](templates/adults.html#L966-L968).
`data-pay-all` is purely informational; the JS doesn't actually need
it (the same code path handles single + multi-period), but keeping it
helps debugging and any future analytics.
### 2. Add `#qrModal` markup
Append after the `#memberModal` block in both `adults.tmpl` and
`juniors.tmpl` (around line 185 of adults, line 165 of juniors), mirroring
[templates/adults.html:631-648](templates/adults.html#L631-L648) but
using `class="active"` toggling (the convention M6.5 picked) instead of
`style.display = 'block'`:
```gotmpl
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for —</div>
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
```
The CSS already targets `#qrModal .modal-content` directly. To make the
"hidden by default / visible when `.active`" toggle behave the same
way `#memberModal` does, the existing CSS rules for `#memberModal`
(`display:none`, `#memberModal.active { display: flex; }`) need a
sibling pair for `#qrModal`. Audit `app.css` during implementation:
- if `#qrModal` already has both rules, no change.
- if it has only `display:none`, add `#qrModal.active { display: flex; }`.
- if it has neither, add both.
This is a 2-line CSS adjustment at most; tracked under §Critical files.
### 3. Bank account on the page
The modal needs the bank account string for the `qrAccount` label and
for building the `/qr?account=…` URL. Two clean options:
**(a)** Read it from the `<button>`'s `data-bank-account` attribute.
Per-button duplication, but trivially done in templates and keeps the JS
module's surface tiny.
**(b)** Stamp it once on the page as `<body data-bank-account="…">`
(or on the existing `#filterContainer`).
**Decision: (b)** — single stamping site, follows the same pattern
M6.5 used for `data-page` on `#filterContainer`. Add
`data-bank-account="{{.Data.BankAccount}}"` to `#filterContainer` in
both templates (one-line edit each).
### 4. Create [go/internal/web/static/js/payment-qr.js](go/internal/web/static/js/payment-qr.js)
A new module loaded with `<script src="/static/js/payment-qr.js" defer></script>`
right after `member-detail.js` in both templates. Pure ES, no build
step, IIFE, `'use strict'`. Skeleton — port of Python's `showPayQR`
([templates/adults.html:963-986](templates/adults.html#L963-L986))
plus event wiring:
```js
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const bankAccount = container.dataset.bankAccount || '';
const modal = document.getElementById('qrModal');
const titleEl = document.getElementById('qrTitle');
const imgEl = document.getElementById('qrImg');
const accountEl = document.getElementById('qrAccount');
const amountEl = document.getElementById('qrAmount');
const messageEl = document.getElementById('qrMessage');
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges are converted piece-wise.
// Mirrors templates/adults.html:966-968.
function toCzechMonth(rawMonth) {
return rawMonth.split('+')
.map(p => p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'))
.join('+');
}
function showQR(name, amount, month, rawMonth) {
const numericMonth = toCzechMonth(rawMonth);
const message = `${name}: ${numericMonth}`;
titleEl.innerText = `Payment for ${month}`;
accountEl.innerText = bankAccount;
amountEl.innerText = amount;
messageEl.innerText = message;
const url = '/qr?'
+ 'account=' + encodeURIComponent(bankAccount)
+ '&amount=' + encodeURIComponent(amount)
+ '&message=' + encodeURIComponent(message);
imgEl.src = url;
modal.classList.add('active');
}
function closeQR() { modal.classList.remove('active'); }
// Event delegation: any .pay-btn click anywhere in the page.
document.addEventListener('click', ev => {
const btn = ev.target.closest('.pay-btn');
if (!btn) return;
ev.preventDefault();
showQR(
btn.dataset.name,
btn.dataset.amount,
btn.dataset.month,
btn.dataset.rawMonth,
);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
});
}());
```
Notes:
- **Event delegation** (single listener on `document`) handles both Pay
and Pay-All without enumerating buttons. It also survives any future
re-render of the table without needing rebinding.
- **Coexistence with `member-detail.js`**: `member-detail.js` listens
for `Escape` too, but only acts when `#memberModal` is active; the
new module similarly only acts when `#qrModal` is active. No collision.
- **Pre-warming**: nothing to pre-warm — `/qr` is a tiny on-demand PNG
and only fires when the user clicks.
- **Inline `onclick`** on `[close]` and outside-click in the markup are
carried over from the M6.5 modal style; consistent with `#memberModal`.
(Switching the project's modal close pattern to data attributes is a
separate cleanup, out of scope here.)
### 5. Tests
Extend [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go)
with markup-level checks. Headless-browser JS is out of scope (same
rationale as M6.5).
In `TestModalMarkup` (or a sibling test, e.g. `TestPaymentQRMarkup`):
- Body for `/adults` and `/juniors` contains
`<script src="/static/js/payment-qr.js"`.
- Body contains `id="qrModal"`, `id="qrImg"`, `id="qrAccount"`,
`id="qrAmount"`, `id="qrMessage"`, `id="qrTitle"`.
- The fixture row's Pay button is rendered as
`<button … class="pay-btn" … data-name="Test Member" data-amount="…" data-raw-month="2026-01"`.
- The fixture row's `.pay-btn` does **not** include `href=`.
- `#filterContainer` has `data-bank-account="CZ0000000000000000000000"`
(matching the test's `fixtureHandler` config).
### 6. Decide fate of `qrHref` / `qrHrefAll` template helpers
Once Pay/Pay-All buttons are JS-driven, the helpers in
[render.go:63-85](go/internal/web/render.go#L63-L85) have no callers.
**Decision: delete them**, plus their entries in `tmplFuncs`. A
no-JS fallback is not on the roadmap; if it ever becomes one, the
helpers are easy to resurrect from git.
The unit-test file currently has no direct tests for these helpers —
nothing to remove from tests.
## Critical files
| Action | Path |
|--------|------|
| edit | [go/internal/web/templates/adults.tmpl](go/internal/web/templates/adults.tmpl) — Pay/Pay-All → `<button data-…>`; add `#qrModal`; add `data-bank-account` on filter container; add `<script src="/static/js/payment-qr.js" defer>` |
| edit | [go/internal/web/templates/juniors.tmpl](go/internal/web/templates/juniors.tmpl) — same |
| new | `go/internal/web/static/js/payment-qr.js` — modal module |
| edit | [go/internal/web/render.go](go/internal/web/render.go) — drop `qrHref`, `qrHrefAll`, and their `tmplFuncs` entries (and the `net/url`, `strconv` imports if no longer needed) |
| edit | [go/internal/web/static/css/app.css](go/internal/web/static/css/app.css) — verify / add `#qrModal { display:none; }` + `#qrModal.active { display:flex; }` rules |
| edit | [go/internal/web/html_handler_test.go](go/internal/web/html_handler_test.go) — markup assertions |
No changes to: `assets.go` (`embed.FS` glob picks up new JS automatically),
`server.go`, `html_handler.go`, the `/qr` handler, `cmd/fuj/main.go`, the
API handlers, or any domain code.
## Reused existing infrastructure
- [go/internal/web/qr.go](go/internal/web/qr.go) — `/qr?account=…&amount=…&message=…` endpoint serves the PNG already
- [go/internal/web/static/css/app.css:445-478](go/internal/web/static/css/app.css#L445-L478) — modal CSS already lifted whole
- [go/internal/web/static/js/member-detail.js](go/internal/web/static/js/member-detail.js) — IIFE / `'use strict'` / `data-*` style to mirror
- [go/internal/web/api/types.go](go/internal/web/api/types.go) — `MemberRow.UnpaidPeriods` and `RawUnpaidPeriods` already populated for Pay-All
- [templates/adults.html:631-648,963-986](templates/adults.html#L631-L648) — Python reference for both modal markup and `showPayQR` JS (logic to port, not literal copy)
## Verification
End-to-end, after copying the plan into `docs/plans/`:
1. `cd go && make go-build go-test go-lint` — all green.
2. `make web-go &` — boot Go on `:8080`.
3. Browser: `http://localhost:8080/adults`
- Click `Pay` on any unpaid month → `#qrModal` opens with
- title `Payment for <Month YYYY>`,
- `qrAccount` matching `BANK_ACCOUNT`,
- `qrAmount` matching the cell amount,
- `qrMessage` formatted as `<Name>: MM/YYYY`,
- QR image rendered from `/qr?…` (Network tab shows PNG response).
- Tab does **not** navigate; URL stays `/adults`.
- Click `Pay All` → modal opens with `+`-joined `MM/YYYY+MM/YYYY` message.
- Click outside the modal-content → modal closes.
- Click `[close]` → modal closes.
- Press `Esc` → modal closes.
- Open `[i]` member-detail modal first, then `Esc` → only that closes
(verify both modals don't fight each other).
4. Browser: `http://localhost:8080/juniors` — same checks. Verify Pay
on a `"?"`-expected row doesn't appear (Pay button is conditional on
`unpaid`/`partial` cells).
5. Cross-check against Python: `make web-py &`
`http://localhost:5001/adults` modal looks visually equivalent for
the same fixture (modulo any nav / theme differences already accepted
in M6).
6. Scan a QR with a Czech banking app (or `zbarimg` on the saved PNG) —
payload starts with `SPD*1.0*ACC:` and the message decodes to
`<Name>: MM/YYYY`.
7. After the user merges in Gitea: append CHANGELOG entry (timestamp via
`date "+%Y-%m-%d %H:%M %Z"`); add a new tick line for **M6.6.1** in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
immediately under the M6.6 line, in the same format as the other
sub-bullets.
## Branching
Per [CLAUDE.md](CLAUDE.md): create `feat/go-m6-6-1-payment-qr-modal` off
`main`, commit with `Co-Authored-By` trailer, push with `-u`, open MR via
`tea pr create`. Do not merge or delete from CLI.
## Out of scope (deferred)
- **Pay button no-JS fallback** — feasible to keep `qrHref` and have
the JS `preventDefault` an `<a>`, but no current need. Resurrect the
helpers if asked.
- **Modal pattern unification** — `#memberModal` and `#qrModal` use
inline `onclick` for `[close]` and outside-click. Consistent with
M6.5 but worth a follow-up to delegate-event everything.
- **Pay-from-modal inside `#memberModal`** — Python doesn't have it, and
no one's asked for it on the Go side.
- **Per-button QR error-correction / size** — `/qr` is fixed at
Medium/256, same as M6.6. Configurable via query param can be added if
printing larger physical posters becomes a use case.
- **`/payments` Pay buttons** — the payments page is a ledger view; it
doesn't render Pay buttons in either backend.

View File

@@ -0,0 +1,184 @@
# M6.7 — Single-binary embed verification
## Context
M6.7 is the final task in M6 (Go-native HTML frontend). Per the
[progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md): "Wire
`embed.FS` into handlers; verify single-binary deployment includes all
assets."
The wiring is **already in place** from M6.1 onward:
- [go/internal/web/assets.go](../../go/internal/web/assets.go) declares
`//go:embed templates``templateFS` and `//go:embed static``staticFS`.
- [go/internal/web/render.go:66](../../go/internal/web/render.go#L66)
parses every page template via `template.New(...).ParseFS(templateFS, ...)`.
- [go/internal/web/server.go:48-68](../../go/internal/web/server.go#L48-L68)
serves `/static/*` via `http.FileServerFS(fs.Sub(staticFS, "static"))`.
- [go/build/Dockerfile](../../go/build/Dockerfile) copies only the compiled
binary into the `alpine:3` runtime image — no `templates/` or `static/`
directory ever lands beside it.
What is missing is **proof** the embed is complete and stays complete:
1. Nothing fails the build/test if a contributor adds a new file under
`internal/web/templates/` or `internal/web/static/` that isn't matched
by an `//go:embed` glob (or, more realistically, adds a sibling
directory like `static/img/` and the glob still picks it up — but a
typo'd directive would silently drop it).
2. No automated test exercises the `/static/*` route against the embedded
FS — current tests in
[html_handler_test.go](../../go/internal/web/html_handler_test.go)
render templates (which proves `templateFS` is good) but never hit a
static URL through the mux.
3. The "single binary, no working-dir assets" property is undocumented —
if it ever broke, no one would notice until the Docker image started
500'ing in prod.
The intended outcome: a small test file plus a documented manual
verification step, after which M6.7 can be ticked and M6 closed.
## Plan
### 1. Add `go/internal/web/assets_test.go`
One new file, two tests, no production code changes.
**Test A — embed completeness regression guard.** Walks the on-disk
`templates/` and `static/` directories and asserts every regular file is
also present in the corresponding embedded FS. Catches:
- A new template added without updating the `//go:embed` directive
(current globs are `templates` and `static` — recursive by default for
directories, so this is a low-probability regression, but the test
doubles as living documentation of the contract).
- A typo in the directive (e.g. someone renames `static``assets` in
one place but not the other).
Implementation sketch:
```go
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskFS fs.FS // os.DirFS("templates") / os.DirFS("static")
embed fs.FS // exported helper or via internal test in package web
root string
}{...}
for _, tc := range cases {
_ = fs.WalkDir(tc.diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() { return err }
embPath := tc.root + "/" + path
if _, err := fs.Stat(tc.embed, embPath); err != nil {
t.Errorf("file %q on disk but missing in embed.FS: %v", embPath, err)
}
return nil
})
}
}
```
Because `templateFS` and `staticFS` are unexported package vars, this
test lives in `package web` (not `web_test`) — sibling to
[assets.go](../../go/internal/web/assets.go). All the existing handler
tests are in `package web_test`; that's fine, this one is internal.
**Test B — `/static/*` end-to-end via the mux.** Builds an `http.ServeMux`
with the same wiring as
[server.go:68](../../go/internal/web/server.go#L68), fires httptest
requests, asserts:
- `GET /static/css/app.css` → 200, `Content-Type: text/css; charset=utf-8`,
body contains a known string from app.css (e.g. a CSS selector).
- `GET /static/js/member-detail.js` → 200, `Content-Type` starts with
`text/javascript` or `application/javascript`, body non-empty.
- `GET /static/js/payment-qr.js` → 200, body non-empty.
- `GET /static/css/missing.css` → 404 (sanity: the file server actually
rejects unknown paths instead of returning some default).
Rather than duplicate the mux assembly, factor a tiny helper (or test the
existing mux). The cleanest move: extract `staticHandler()` from
[server.go:48-50,68](../../go/internal/web/server.go#L48-L68) into a small
exported-from-package function or just `staticFS` / `fs.Sub` helper, and
have the test call it. Smallest delta: keep production code unchanged and
replicate the two-line wiring inside the test file (acceptable — it's
two lines and the test exists precisely to lock that contract).
### 2. Manual / one-shot verification (no code; documented in plan only)
Run once locally and tick M6.7. Command transcript:
```bash
make go-build # → ./bin/fuj
cp bin/fuj /tmp/fuj-standalone
cd /tmp # working dir has no templates/ or static/
./fuj-standalone server &
SERVER_PID=$!
sleep 1
curl -sf http://localhost:8080/adults | grep -q "Adults Dashboard"
curl -sf http://localhost:8080/juniors | grep -q "Juniors"
curl -sf http://localhost:8080/payments | grep -q "Payments Ledger"
curl -sf -o /tmp/app.css http://localhost:8080/static/css/app.css \
&& test -s /tmp/app.css
curl -sf -o /tmp/qr.js http://localhost:8080/static/js/payment-qr.js \
&& test -s /tmp/qr.js
kill $SERVER_PID
```
`fuj server` will fail to talk to Sheets without credentials, so the
`/adults` etc. pages will render with the `Error` field set — that's
fine; the assertion is that the **template + static asset pipeline** is
self-contained, not that data loads. Each curl above only checks for
markup present in every render path (header text and stylesheet body).
### 3. Tracker + changelog
- Tick `M6.7` in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:120](2026-05-03-2349-go-backend-rewrite-progress.md#L120),
append the merge SHA on the line.
- Mark "Last updated" date and bump milestone status: M6 complete, next is
M7.
- Append a `CHANGELOG.md` entry per CLAUDE.md convention (`date "+%Y-%m-%d %H:%M %Z"`).
## Files touched
| File | Change |
| --- | --- |
| [go/internal/web/assets_test.go](../../go/internal/web/assets_test.go) | **new** — two tests (embed completeness + `/static/*` mux) |
| [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) | tick M6.7, bump "last updated" |
| [CHANGELOG.md](../../CHANGELOG.md) | new top entry |
No production source files change. (If extracting the static handler
reads cleaner, a 4-line refactor in
[server.go](../../go/internal/web/server.go) is acceptable but optional.)
## Branch + MR
Per project convention this is a feature, so:
```bash
git checkout -b feat/go-m6-7-embed-verify
# … commits …
git push -u origin feat/go-m6-7-embed-verify
tea pr create --title "feat(go): M6.7 — single-binary embed verification" \
--description "<short body referencing M6.7>" --base main \
--head feat/go-m6-7-embed-verify
```
## Verification
After implementation:
1. `make go-test` → green (new `TestEmbedCompleteness` and `TestStaticAssetsServed` pass).
2. `make go-lint` → clean.
3. Run the manual transcript in §2 above — all curls succeed, no
"template not found" or 404 on static assets.
4. `make go-build && docker build -f go/build/Dockerfile -t fuj-go:m6-7 go/`
succeeds; `docker run --rm -p 8080:8080 fuj-go:m6-7` serves `/adults`
with stylesheet attached (visual smoke test in browser).
## Out of scope
- Re-architecting how templates are parsed or served.
- Compressing / fingerprinting static assets (a separate concern).
- Live integration test with real Sheets data — covered later in M7.

View File

@@ -0,0 +1,184 @@
# Fill-first multi-month payment allocation
## Context
Matyáš Thér paid in two transactions:
| # | Amount | Purpose |
|---|--------|---------------------|
| 1 | 200 | `2026-02` |
| 2 | 550 | `2026-02, 2026-03` |
Total 750 = his expected fee for the two months (likely 2026-02 = 500, 2026-03 = 250 — junior tier or exception-adjusted). The app currently shows 2026-02 = **566** paid, 2026-03 = **183** paid. The user wants:
> First use the second payment for the rest of 2026-02 (no more, no less), then put the remainder toward 2026-03.
Both Python ([scripts/match_payments.py](scripts/match_payments.py)) and Go ([go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go)) have the same bug: when a single transaction's `in_window_share` is less than the sum of in-window expected fees, both fall into a **proportional** branch that splits the new transaction across months in proportion to each month's *total* expected fee — never consulting what prior transactions already paid into earlier months.
Trace for txn 2:
- `in_window = [(2026-02, 500), (2026-03, 250)]`, `total_expected = 750`, `in_window_share = 550`.
- `550 < 750` → proportional:
- 02 alloc = `550 × 500 / 750 = 366.67`
- 03 alloc = `550 366.67 = 183.33`
- Combined with txn 1's 200 → 02 = 566.67, 03 = 183.33 → display `566 / 183`. ✓ matches reported numbers.
The fix: replace the greedy + proportional branches with a single **fill-first** loop that iterates `in_window` in user-supplied order (already chronological by convention from [scripts/infer_payments.py:151](scripts/infer_payments.py#L151)) and allocates `min(remaining, max(0, expected paid_so_far))` to each month, with any final surplus going to the credit bucket. This collapses three cases (greedy / proportional / pure overflow) into one and naturally consults the ledger's `paid` field which is already updated by prior transactions in the same reconcile pass.
The even-split branch (`total_expected == 0`, prepayment before fees known) stays untouched — different semantic, folding it in would silently change behavior.
## Changes
### Python — [scripts/match_payments.py:471-498](scripts/match_payments.py#L471-L498)
Replace both `total_expected > 0` branches (current lines 471498) with a single unified loop. Keep lines 466469 above and the even-split fallback below (lines 499510) as-is.
```python
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
# convention), allocate min(remaining, deficit) to each month, where
# deficit accounts for prior transactions already credited to that month.
# Any surplus after all in-window deficits are covered → credit bucket.
remaining = in_window_share
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(exp) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
ledger[member_name][m]["paid"] += alloc
ledger[member_name][m]["transactions"].append({
"amount": alloc,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
remaining -= alloc
if remaining > 0:
credits[member_name] = credits.get(member_name, 0) + int(remaining)
else:
# … existing even-split branch (lines 499510) unchanged …
```
Note: skipping the `transactions.append` when `alloc <= 0` (e.g. month already fully paid by a prior txn) avoids zero-amount ghost rows in the per-month transaction list. This is a small UI-visible side effect; before committing, grep tests for assertions on `len(transactions)` per month to confirm nothing relies on the current "one row per (txn, month) regardless of alloc" behavior.
### Go — [go/internal/domain/reconcile/reconcile.go:320-357](go/internal/domain/reconcile/reconcile.go#L320-L357)
Same shape — replace both `totalExpected > 0` branches. Even-split branch (lines 358372) stays.
```go
if totalExpected > 0 {
// Fill-first; see Python reconcile() for rationale.
remaining := inWindowShare
for _, mw := range inWindow {
md := ledger[memberName][mw.month]
deficit := float64(mw.expected) - md.Paid
if deficit < 0 {
deficit = 0
}
alloc := remaining
if deficit < alloc {
alloc = deficit
}
if alloc <= 0 {
continue
}
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
remaining -= alloc
}
if remaining > 0 {
credits[memberName] += int(remaining)
}
} else {
// … existing even-split branch (lines 358372) unchanged …
}
```
### Tests
#### Python — [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py)
1. **Rewrite `test_proportional_underpayment`** ([line 96](tests/test_reconcile_exceptions.py#L96)) — its current assertions (`paid_02 < 750`, `paid_03 < 350`, `paid_04 < 750`, and 02/04 equal allocation) are incompatible with fill-first. Under fill-first with the same fixture (1250 across `02:750, 03:350, 04:750`):
- 02: `min(1250, 750) = 750` (full) → remaining 500
- 03: `min(500, 350) = 350` (full) → remaining 150
- 04: `min(150, 750) = 150` (partial) → remaining 0
Replace assertions with these exact expected values, rename to `test_underpayment_fills_earliest_first`.
2. **Add `test_fill_first_across_two_transactions`** — the Matyáš regression:
```python
def test_fill_first_across_two_transactions(self):
"""Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
sorted_months = ['2026-02', '2026-03']
tx1 = _tx('Matyáš', '2026-02', 200)
tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
result = reconcile(members, sorted_months, [tx1, tx2])
months = result['members']['Matyáš']['months']
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
```
3. `test_greedy_exact_match`, `test_greedy_overpayment_goes_to_credit`, `test_single_month_unchanged`, `test_two_members_multi_month` should pass unchanged — fill-first agrees with greedy when payment ≥ total expected.
#### Go — [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go)
- Add `TestUnderpaymentFillsEarliestFirst` mirroring the rewritten Python test.
- Add `TestFillFirstAcrossTwoTransactions` mirroring the Matyáš scenario.
#### Parity — [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go)
Add a fixture for the Matyáš two-transaction case. Since both implementations change together and Python remains canonical, existing parity fixtures should continue to pass; verify after edits.
### Changelog — [CHANGELOG.md](CHANGELOG.md)
Append top entry (use `date "+%Y-%m-%d %H:%M %Z"` at commit time):
```markdown
## 2026-05-11 23:55 CET — fill-first multi-month payment allocation
- Multi-month payment allocation now fills the earliest in-window deficit first
and spills the remainder to later months, accounting for prior transactions'
contributions. Previously a single transaction was split proportionally to
each month's total expected fee, ignoring earlier payments — surfaced by
Matyáš Thér's two-payment 200+550 case showing 566/183 instead of 500/250.
- scripts/match_payments.py, go/internal/domain/reconcile/reconcile.go, tests.
```
## Critical files
- [scripts/match_payments.py](scripts/match_payments.py) — Python reconcile (canonical)
- [go/internal/domain/reconcile/reconcile.go](go/internal/domain/reconcile/reconcile.go) — Go reconcile (mirrors Python)
- [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py) — rewrite `test_proportional_underpayment` + add Matyáš test
- [go/internal/domain/reconcile/reconcile_test.go](go/internal/domain/reconcile/reconcile_test.go) — add Go tests
- [go/tests/parity/reconcile/reconcile_parity_test.go](go/tests/parity/reconcile/reconcile_parity_test.go) — add parity fixture
- [CHANGELOG.md](CHANGELOG.md) — top entry
## Verification
1. `make test` — Python unit tests including the rewritten + new fill-first tests.
2. `cd go && go test ./internal/domain/reconcile/... ./tests/parity/reconcile/...` — Go unit + parity tests.
3. `make web` → load `/adults` or `/juniors` (whichever lists Matyáš Thér) → his 2026-02 row should be fully paid (no shortfall), 2026-03 fully paid, no leftover credit.
4. Spot-check one other multi-month-purpose member to make sure fully-covered cases still look right.
## Branch & MR (per CLAUDE.md)
1. `git checkout -b fix/fill-first-multi-month-allocation`
2. Apply edits + tests + CHANGELOG entry.
3. `make test && (cd go && go test ./...)` — both green.
4. Commit: `fix(reconcile): fill earliest month deficit first in multi-month allocations` with `Co-Authored-By` trailer.
5. `git push -u origin fix/fill-first-multi-month-allocation`
6. `tea pr create --title "fix(reconcile): fill earliest month deficit first" --description "<body>" --base main --head fix/fill-first-multi-month-allocation`
7. Print MR URL. Do not merge from CLI.

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"
"io"
"log/slog" "log/slog"
"os" "os"
"time" "time"
@@ -82,9 +83,40 @@ func serverCmd(args []string) {
os.Exit(1) os.Exit(1)
} }
sheetsCli, err := sheets.New(ctx, cfg.CredentialsPath, cfg.DriveTimeout)
if err != nil {
fmt.Fprintf(os.Stderr, "fuj server: sheets client for sync: %v\n", err)
os.Exit(1)
}
fioCli := fio.New(cfg.FioAPIToken, config.IBANAccountNum(cfg.BankAccount), nil)
actions := web.ActionHandlers{
BankSync: func(ctx context.Context, out io.Writer) error {
yr := time.Now().Year()
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioCli, sheetsCli,
banksync.SyncOpts{From: from, To: to, Sort: true})
if err != nil {
return fmt.Errorf("sync: %w", err)
}
fmt.Fprintf(out, "Synced %d new transaction(s).\n\n", n)
fmt.Fprintln(out, "=== Infer Payments ===")
n, err = banksync.InferPayments(ctx, config.PaymentsSheetID, sheetsCli, sources, banksync.InferOpts{})
if err != nil {
return fmt.Errorf("infer: %w", err)
}
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
return nil
},
}
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, sources, cfg); err != nil { if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg, actions); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -5,6 +5,7 @@ go 1.26.1
require ( require (
github.com/google/go-cmp v0.7.0 github.com/google/go-cmp v0.7.0
github.com/invopop/jsonschema v0.14.0 github.com/invopop/jsonschema v0.14.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
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

View File

@@ -37,6 +37,8 @@ github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= 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/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

View File

@@ -115,10 +115,11 @@ type monthExpected struct {
expected int expected int
} }
// Reconcile matches transactions to members and months using three allocation phases: // Reconcile matches transactions to members and months using two allocation phases:
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit. // 1. Fill-first: iterate matched months in user-supplied order, allocating min(remaining,
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder. // deficit) to each month where deficit = expected already-paid. Surplus → credit.
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally. // Handles both the "greedy" (payment covers all) and "partial" cases in one pass.
// 2. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
// //
// defaultYear seeds czech.ParseMonthReferences in the inference fallback. // defaultYear seeds czech.ParseMonthReferences in the inference fallback.
// Pass time.Now().Year() in production; pass a fixed year in tests. // Pass time.Now().Year() in production; pass a fixed year in tests.
@@ -317,34 +318,26 @@ func Reconcile(
totalExpected += mw.expected totalExpected += mw.expected
} }
if totalExpected > 0 && inWindowShare >= float64(totalExpected) { if totalExpected > 0 {
// Greedy: payment covers all expected fees; overflow → credit // Fill-first: iterate inWindow in matched-months order (chronological by
credits[memberName] += int(inWindowShare - float64(totalExpected)) // convention), allocating min(remaining, deficit) to each month. Deficit
for _, mw := range inWindow { // is net of what prior transactions already paid, so a second payment on
alloc := float64(mw.expected) // the same months correctly fills only what remains due. Any surplus after
md := ledger[memberName][mw.month] // all deficits are covered goes to the credit bucket.
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
}
} else if totalExpected > 0 {
// Proportional: distribute by each month's share; last month absorbs float remainder
remaining := inWindowShare remaining := inWindowShare
for i, mw := range inWindow { for _, mw := range inWindow {
var alloc float64
if i == len(inWindow)-1 {
alloc = remaining
} else {
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
}
remaining -= alloc
md := ledger[memberName][mw.month] md := ledger[memberName][mw.month]
deficit := float64(mw.expected) - md.Paid
if deficit < 0 {
deficit = 0
}
alloc := remaining
if deficit < alloc {
alloc = deficit
}
if alloc <= 0 {
continue
}
md.Paid += alloc md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{ md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc, Amount: alloc,
@@ -354,6 +347,10 @@ func Reconcile(
Confidence: string(m.Confidence), Confidence: string(m.Confidence),
}) })
ledger[memberName][mw.month] = md ledger[memberName][mw.month] = md
remaining -= alloc
}
if remaining > 0 {
credits[memberName] += int(remaining)
} }
} else { } else {
// Even-split fallback: prepayment before attendance recorded // Even-split fallback: prepayment before attendance recorded

View File

@@ -111,36 +111,26 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
} }
} }
func TestReconcileProportionalUnderpayment(t *testing.T) { func TestReconcileUnderpaymentFillsEarliestFirst(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": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 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
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear) result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
months := result.Members["Alice"].Months months := result.Members["Alice"].Months
paid02 := months["2026-02"].Paid // 02 filled first (750), then 03 (350), then remainder 150 to 04
paid03 := months["2026-03"].Paid if math.Abs(months["2026-02"].Paid-750) > 0.01 {
paid04 := months["2026-04"].Paid t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
if paid02 >= 750 {
t.Errorf("2026-02 should be underpaid, got %f", paid02)
} }
if paid03 >= 350 { if math.Abs(months["2026-03"].Paid-350) > 0.01 {
t.Errorf("2026-03 should be underpaid, got %f", paid03) t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
} }
if paid04 >= 750 { if math.Abs(months["2026-04"].Paid-150) > 0.01 {
t.Errorf("2026-04 should be underpaid, got %f", paid04) t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
}
if math.Abs(paid02+paid03+paid04-amount) > 0.01 {
t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04)
}
if math.Abs(paid02-paid04) > 0.01 {
t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04)
} }
} }
@@ -374,3 +364,52 @@ func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched) t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
} }
} }
// Payment < total expected → fill earliest months first, spill remainder to later.
func TestUnderpaymentFillsEarliestFirst(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{
"2026-02": {Expected: 750, Attendance: 3},
"2026-03": {Expected: 350, Attendance: 3},
"2026-04": {Expected: 750, Attendance: 3},
}}}
txs := []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}
result := Reconcile(members, []string{"2026-02", "2026-03", "2026-04"}, txs, nil, defaultYear)
months := result.Members["Alice"].Months
// 02 filled first (750), then 03 (350), then remainder 150 to 04
if math.Abs(months["2026-02"].Paid-750) > 0.01 {
t.Errorf("02: want 750, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-350) > 0.01 {
t.Errorf("03: want 350, got %f", months["2026-03"].Paid)
}
if math.Abs(months["2026-04"].Paid-150) > 0.01 {
t.Errorf("04: want 150, got %f", months["2026-04"].Paid)
}
}
// Prior txn fills 02 partially; later txn finishes 02 then spills to 03.
func TestFillFirstAcrossTwoTransactions(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Matyáš", Tier: "A", Fees: map[string]FeeData{
"2026-02": {Expected: 500, Attendance: 2},
"2026-03": {Expected: 250, Attendance: 1},
}}}
sortedMonths := []string{"2026-02", "2026-03"}
txs := []Transaction{
tx("Matyáš", "2026-02", 200),
tx("Matyáš", "2026-02, 2026-03", 550),
}
result := Reconcile(members, sortedMonths, txs, nil, defaultYear)
months := result.Members["Matyáš"].Months
if math.Abs(months["2026-02"].Paid-500) > 0.01 {
t.Errorf("02: want 500, got %f", months["2026-02"].Paid)
}
if math.Abs(months["2026-03"].Paid-250) > 0.01 {
t.Errorf("03: want 250, got %f", months["2026-03"].Paid)
}
}

View File

@@ -25,11 +25,17 @@ type ExceptionLoader interface {
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
} }
// CacheFlusher can invalidate all cached data.
type CacheFlusher interface {
FlushCache() (int, error)
}
// Sources is the aggregate interface required by ReconcileReport. // Sources is the aggregate interface required by ReconcileReport.
type Sources interface { type Sources interface {
AttendanceLoader AttendanceLoader
TransactionLoader TransactionLoader
ExceptionLoader ExceptionLoader
CacheFlusher
} }
// NewStubSources returns a Sources whose every method returns ErrIOPending. // NewStubSources returns a Sources whose every method returns ErrIOPending.
@@ -52,3 +58,5 @@ func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction,
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) { func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, ErrIOPending return nil, ErrIOPending
} }
func (stubSources) FlushCache() (int, error) { return 0, nil }

View File

@@ -31,6 +31,8 @@ func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionK
return f.exceptions, nil return f.exceptions, nil
} }
func (fakeSources) FlushCache() (int, error) { return 0, nil }
func TestReconcileReport(t *testing.T) { func TestReconcileReport(t *testing.T) {
t.Parallel() t.Parallel()
s := fakeSources{ s := fakeSources{

View File

@@ -27,7 +27,9 @@ const (
// 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{
@@ -109,6 +111,9 @@ func (s *realSources) LoadTransactions(ctx context.Context) ([]reconcile.Transac
return parseTransactionRows(rows) return parseTransactionRows(rows)
} }
// FlushCache deletes all cached data files and resets in-memory cache state.
func (s *realSources) FlushCache() (int, error) { return s.cache.Flush() }
// LoadExceptions fetches the exceptions tab (cached). // LoadExceptions fetches the exceptions tab (cached).
func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) { func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
rows, err := cache.Get(ctx, s.cache, "exceptions_dict", rows, err := cache.Get(ctx, s.cache, "exceptions_dict",

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"])
} }

View File

@@ -23,6 +23,9 @@ type Handler struct {
Logger *slog.Logger Logger *slog.Logger
} }
// FlushCache invalidates all cached data via the underlying Sources.
func (h *Handler) FlushCache() (int, error) { return h.Sources.FlushCache() }
// ServeVersion handles GET /api/version. // ServeVersion handles GET /api/version.
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
writeJSON(w, VersionResponse{ writeJSON(w, VersionResponse{
@@ -34,37 +37,64 @@ func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
// ServeAdults handles GET /api/adults. // ServeAdults handles GET /api/adults.
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() resp, err := h.AssembleAdults(r.Context())
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
if err != nil { if err != nil {
h.writeError(w, r, err) h.writeError(w, r, err)
return 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()) result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01"))) return buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
} }
// ServeJuniors handles GET /api/juniors. // ServeJuniors handles GET /api/juniors.
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() resp, err := h.AssembleJuniors(r.Context())
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
if err != nil { if err != nil {
h.writeError(w, r, err) h.writeError(w, r, err)
return 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()) result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01"))) return buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
} }
// ServePayments handles GET /api/payments. // ServePayments handles GET /api/payments.
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) { func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() resp, err := h.AssemblePayments(r.Context())
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil { if err != nil {
h.writeError(w, r, fmt.Errorf("load transactions: %w", err)) h.writeError(w, r, err)
return return
} }
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx))) writeJSON(w, resp)
}
// AssemblePayments loads transactions and builds the payments view model.
// Shared between the JSON API route and the HTML handler.
func (h *Handler) AssemblePayments(ctx context.Context) (PaymentsResponse, error) {
txns, err := h.Sources.LoadTransactions(ctx)
if err != nil {
return PaymentsResponse{}, fmt.Errorf("load transactions: %w", err)
}
return buildPaymentsResponse(txns, h.allMemberNames(ctx)), nil
} }
func (h *Handler) loadAll(ctx context.Context, adults bool) ( func (h *Handler) loadAll(ctx context.Context, adults bool) (

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,93 @@
package web
import (
"io/fs"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// TestEmbedCompleteness guards against a new template or static file being
// added to disk but missing from the embedded FS (e.g. a new directory that
// the //go:embed glob does not match).
func TestEmbedCompleteness(t *testing.T) {
cases := []struct {
name string
diskDir string
embedFS fs.FS
embedRoot string
}{
{"templates", "templates", templateFS, "templates"},
{"static", "static", staticFS, "static"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diskFS := os.DirFS(tc.diskDir)
_ = fs.WalkDir(diskFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
embPath := tc.embedRoot + "/" + path
if _, statErr := fs.Stat(tc.embedFS, embPath); statErr != nil {
t.Errorf("file %q exists on disk but is missing from embed.FS (%v)", embPath, statErr)
}
return nil
})
})
}
}
// TestStaticAssetsServed verifies that /static/* is served from the embedded
// FS through the same mux wiring used in server.go, so a standalone binary
// with no adjacent static/ directory still delivers assets.
func TestStaticAssetsServed(t *testing.T) {
subFS, err := fs.Sub(staticFS, "static")
if err != nil {
t.Fatalf("fs.Sub static: %v", err)
}
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(subFS)))
cases := []struct {
path string
wantCT string
wantSnippet string
}{
{"/static/css/app.css", "text/css", "body {"},
{"/static/js/member-detail.js", "javascript", "Member-detail modal"},
{"/static/js/filters.js", "javascript", ""},
{"/static/js/payment-qr.js", "javascript", ""},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %s: status %d, want 200", tc.path, w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, tc.wantCT) {
t.Errorf("GET %s: Content-Type %q, want it to contain %q", tc.path, ct, tc.wantCT)
}
if tc.wantSnippet != "" && !strings.Contains(w.Body.String(), tc.wantSnippet) {
t.Errorf("GET %s: body missing expected snippet %q", tc.path, tc.wantSnippet)
}
})
}
// Sanity: unknown path → 404 (file server doesn't fall through silently)
t.Run("missing-file", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/static/css/nonexistent.css", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("unknown static path: status %d, want 404", w.Code)
}
})
}

View File

@@ -0,0 +1,143 @@
package web
import (
"bytes"
"fmt"
"fuj-management/go/internal/web/api"
"net/http"
"runtime/debug"
)
// HTMLHandler serves the Go-native HTML frontend.
type HTMLHandler struct {
renderer *Renderer
build BuildInfo
apiHandler *api.Handler
actions ActionHandlers
}
// NewHTMLHandler constructs an HTMLHandler.
func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHandlers) *HTMLHandler {
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
}
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) {
data, err := h.apiHandler.AssemblePayments(r.Context())
if err != nil {
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
Error: err.Error(),
})
return
}
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
Data: data,
})
}
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
pd := PageData{Active: "sync", Build: h.build}
if h.actions.BankSync == nil {
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
Output: "Bank sync is not configured.",
Success: false,
})
return
}
var buf bytes.Buffer
success := true
if err := h.actions.BankSync(r.Context(), &buf); err != nil {
fmt.Fprintf(&buf, "\nError: %s\n\nStack trace:\n%s", err.Error(), debug.Stack())
success = false
}
fmt.Fprintln(&buf, "\n=== Flush Cache ===")
n, err := h.apiHandler.FlushCache()
if err != nil {
fmt.Fprintf(&buf, "flush error: %s\n", err.Error())
success = false
} else {
fmt.Fprintf(&buf, "%d cache file(s) deleted.\n", n)
}
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
Output: buf.String(),
Success: success,
})
}
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
})
}
// ServeFlushCachePOST handles POST /flush-cache: flushes and re-renders with count.
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
n, _ := h.apiHandler.FlushCache()
h.renderer.Render(w, "flush_cache", FlushPageData{
PageData: PageData{Active: "flush", Build: h.build},
Flushed: true,
Deleted: n,
})
}
// ServeQR handles GET /qr: generates and returns a Czech QR Platba PNG.
func (h *HTMLHandler) ServeQR(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
account := q.Get("account")
amount := q.Get("amount")
message := q.Get("message")
if account == "" {
account = h.apiHandler.Config.BankAccount
}
if amount == "" {
amount = "0"
}
payload := BuildSPD(account, amount, message, h.apiHandler.Config.BankAccount)
png, err := RenderQRCode(payload)
if err != nil {
http.Error(w, "qr encode: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(png)
}

View File

@@ -0,0 +1,383 @@
package web_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/domain/reconcile"
"fuj-management/go/internal/web"
"fuj-management/go/internal/web/api"
"io"
"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 (fixtureSources) FlushCache() (int, error) { return 0, 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), web.ActionHandlers{})
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
{"/payments", h.ServePayments},
{"/sync-bank", h.ServeSync},
{"/flush-cache", h.ServeFlushCacheGET},
}
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), web.ActionHandlers{})
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<")
}
}
func TestModalMarkup(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), web.ActionHandlers{})
cases := []struct {
path string
handler http.HandlerFunc
page string
wantRow string // data-name present only when the page has rows
}{
{"/adults", h.ServeAdults, "adults", `data-name="Test Member"`},
{"/juniors", h.ServeJuniors, "juniors", ""},
}
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)
body := w.Body.String()
for _, want := range []string{
`data-page="` + tc.page + `"`,
`id="memberModal"`,
`/static/js/member-detail.js`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
if tc.wantRow != "" && !strings.Contains(body, tc.wantRow) {
t.Errorf("body missing info-icon row %q", tc.wantRow)
}
})
}
}
func TestPaymentsPage(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), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/payments", nil)
w := httptest.NewRecorder()
h.ServePayments(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Payments Ledger",
"<h2>Test Member</h2>",
"750 CZK",
"2026-01-01",
"2026-01",
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestServeQR(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), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/qr?account=2702008874%2F2010&amount=700&message=Test", nil)
w := httptest.NewRecorder()
h.ServeQR(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); ct != "image/png" {
t.Errorf("Content-Type = %q, want image/png", ct)
}
// PNG magic bytes: \x89PNG\r\n\x1a\n
magic := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
body := w.Body.Bytes()
if len(body) < len(magic) || !bytes.Equal(body[:len(magic)], magic) {
t.Error("response body does not start with PNG magic bytes")
}
}
func TestServeFlushCacheGET(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), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodGet, "/flush-cache", nil)
w := httptest.NewRecorder()
h.ServeFlushCacheGET(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Flush Cache") {
t.Error("body missing Flush Cache heading")
}
if strings.Contains(body, "file(s) deleted") {
t.Error("GET should not show deleted count")
}
}
func TestServeFlushCachePOST(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), web.ActionHandlers{})
req := httptest.NewRequest(http.MethodPost, "/flush-cache", nil)
w := httptest.NewRecorder()
h.ServeFlushCachePOST(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// fixtureSources.FlushCache returns 0
if !strings.Contains(body, "file(s) deleted") {
t.Error("POST should show deleted count")
}
}
func TestServeSync(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"}
actions := web.ActionHandlers{
BankSync: func(_ context.Context, out io.Writer) error {
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
fmt.Fprintln(out, "Synced 3 new transaction(s).")
fmt.Fprintln(out, "=== Infer Payments ===")
fmt.Fprintln(out, "Inferred 2 row(s).")
return nil
},
}
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
req := httptest.NewRequest(http.MethodGet, "/sync-bank", nil)
w := httptest.NewRecorder()
h.ServeSync(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Sync Bank Data",
"Synced 3 new transaction(s).",
"Inferred 2 row(s).",
"Flush Cache",
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
}
func TestPaymentQRMarkup(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), web.ActionHandlers{})
cases := []struct {
path string
handler http.HandlerFunc
}{
{"/adults", h.ServeAdults},
{"/juniors", h.ServeJuniors},
}
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)
body := w.Body.String()
for _, want := range []string{
`id="qrModal"`,
`id="qrImg"`,
`id="qrTitle"`,
`id="qrAccount"`,
`id="qrAmount"`,
`id="qrMessage"`,
`/static/js/payment-qr.js`,
`data-bank-account="CZ0000000000000000000000"`,
} {
if !strings.Contains(body, want) {
t.Errorf("body missing %q", want)
}
}
// Pay buttons must use <button>, never a bare href to /qr.
if strings.Contains(body, `href="/qr`) {
t.Error("body must not contain href=/qr (Pay buttons should be <button>, not <a>)")
}
})
}
}
func TestServeVersion(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/version", nil)
w := httptest.NewRecorder()
fixtureHandler(t).ServeVersion(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var raw map[string]json.RawMessage
if err := json.NewDecoder(w.Body).Decode(&raw); err != nil {
t.Fatalf("decode JSON: %v", err)
}
for _, key := range []string{"tag", "commit", "build_date"} {
if _, ok := raw[key]; !ok {
t.Errorf("JSON response missing key %q", key)
}
}
}

50
go/internal/web/qr.go Normal file
View File

@@ -0,0 +1,50 @@
package web
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
qrcode "github.com/skip2/go-qrcode"
)
var validAccount = regexp.MustCompile(`^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$`)
// BuildSPD builds a Czech QR Platba SPD string, matching Python's qr_code handler.
// Invalid account falls back to defaultAccount.
// Amount is clamped to [0, 10_000_000]; non-numeric input becomes "0.00".
// Message is truncated to 60 runes and stripped of '*' characters.
func BuildSPD(account, amount, message, defaultAccount string) string {
if !validAccount.MatchString(account) {
account = defaultAccount
}
var amtStr string
var f float64
if _, err := fmt.Sscanf(amount, "%f", &f); err != nil || f < 0 || f > 10_000_000 {
amtStr = "0.00"
} else {
amtStr = fmt.Sprintf("%.2f", f)
}
if utf8.RuneCountInString(message) > 60 {
runes := []rune(message)
message = string(runes[:60])
}
message = strings.ReplaceAll(message, "*", "")
var accStr string
if parts := strings.SplitN(account, "/", 2); len(parts) == 2 {
accStr = parts[0] + "*BC:" + parts[1]
} else {
accStr = account
}
return fmt.Sprintf("SPD*1.0*ACC:%s*AM:%s*CC:CZK*MSG:%s", accStr, amtStr, message)
}
// RenderQRCode encodes payload as a PNG QR code (256×256, error correction Medium).
func RenderQRCode(payload string) ([]byte, error) {
return qrcode.Encode(payload, qrcode.Medium, 256)
}

View File

@@ -0,0 +1,91 @@
package web
import (
"strings"
"testing"
)
func TestQRBuildSPD(t *testing.T) {
const def = "2702008874/2010"
cases := []struct {
name string
account string
amount string
message string
want string
}{
{
name: "czech account",
account: "2702008874/2010",
amount: "700",
message: "Test Member: 01/2026",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:700.00*CC:CZK*MSG:Test Member: 01/2026",
},
{
name: "IBAN account",
account: "CZ6508000000192000145399",
amount: "500",
message: "hi",
want: "SPD*1.0*ACC:CZ6508000000192000145399*AM:500.00*CC:CZK*MSG:hi",
},
{
name: "invalid account falls back to default",
account: "NOTANACCOUNT",
amount: "100",
message: "x",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:x",
},
{
name: "empty account falls back to default",
account: "",
amount: "0",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "negative amount clamped to 0.00",
account: def,
amount: "-1",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "amount over 10M clamped to 0.00",
account: def,
amount: "99999999",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "non-numeric amount becomes 0.00",
account: def,
amount: "abc",
message: "",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:",
},
{
name: "asterisks stripped from message",
account: def,
amount: "100",
message: "pay*now",
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:100.00*CC:CZK*MSG:paynow",
},
{
name: "message truncated to 60 runes",
account: def,
amount: "0",
message: strings.Repeat("á", 65),
want: "SPD*1.0*ACC:2702008874*BC:2010*AM:0.00*CC:CZK*MSG:" + strings.Repeat("á", 60),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := BuildSPD(tc.account, tc.amount, tc.message, def)
if got != tc.want {
t.Errorf("\ngot: %s\nwant: %s", got, tc.want)
}
})
}
}

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

@@ -0,0 +1,91 @@
package web
import (
"fmt"
"fuj-management/go/internal/web/api"
"html/template"
"log/slog"
"net/http"
)
// 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
}
// PaymentsPageData is the view model for the /payments HTML page.
type PaymentsPageData struct {
PageData
Data api.PaymentsResponse
Error string
}
// SyncPageData is the view model for the /sync-bank HTML page.
type SyncPageData struct {
PageData
Output string
Success bool
}
// FlushPageData is the view model for the /flush-cache HTML page.
type FlushPageData struct {
PageData
Flushed bool
Deleted int
}
// 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"}
var tmplFuncs = template.FuncMap{}
// 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

@@ -1,11 +1,14 @@
package web package web
import ( import (
"context"
"fmt" "fmt"
"fuj-management/go/internal/config" "fuj-management/go/internal/config"
"fuj-management/go/internal/services/membership" "fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web/api" "fuj-management/go/internal/web/api"
"fuj-management/go/internal/web/middleware" "fuj-management/go/internal/web/middleware"
"io"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
) )
@@ -17,9 +20,22 @@ type BuildInfo struct {
BuildDate string BuildDate string
} }
// ActionHandlers holds function closures for side-effectful operations that
// require dependencies (fio, sheets) not present on the core API handler.
type ActionHandlers struct {
// BankSync runs sync+infer and writes a human-readable log to out.
// nil disables the /sync-bank action (renders an error instead).
BankSync func(ctx context.Context, out io.Writer) error
}
// 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, sources membership.Sources, cfg config.Config) error { func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config, actions ActionHandlers) error {
h := &api.Handler{ renderer, err := NewRenderer()
if err != nil {
return fmt.Errorf("init templates: %w", err)
}
ah := &api.Handler{
BuildVersion: build.Version, BuildVersion: build.Version,
BuildCommit: build.Commit, BuildCommit: build.Commit,
BuildDate: build.BuildDate, BuildDate: build.BuildDate,
@@ -27,22 +43,37 @@ func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.S
Config: cfg, Config: cfg,
Logger: logger, Logger: logger,
} }
hh := NewHTMLHandler(renderer, build, ah, actions)
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))
mux.HandleFunc("GET /api/version", h.ServeVersion) // HTML routes
mux.HandleFunc("GET /api/adults", h.ServeAdults) mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("GET /api/juniors", h.ServeJuniors) http.Redirect(w, r, "/adults", http.StatusFound)
mux.HandleFunc("GET /api/payments", h.ServePayments) })
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.ServeFlushCacheGET)
mux.HandleFunc("POST /flush-cache", hh.ServeFlushCachePOST)
mux.HandleFunc("GET /qr", hh.ServeQR)
// Static files
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServerFS(staticSubFS)))
// JSON API routes
mux.HandleFunc("GET /api/version", ah.ServeVersion)
mux.HandleFunc("GET /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,551 @@
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 {
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;
}
#qrModal.active {
display: flex !important;
}
#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;
}
/* /payments ledger */
.ledger-container {
width: 100%;
max-width: 1000px;
margin-bottom: 40px;
}
.member-block {
margin-bottom: 20px;
}
.txn-table {
border-collapse: collapse;
width: 100%;
margin-top: 5px;
}
.txn-table th,
.txn-table td {
padding: 2px 8px;
text-align: left;
border-bottom: 1px dashed #222;
}
.txn-table th {
color: #555;
text-transform: lowercase;
font-weight: normal;
border-bottom: 1px solid #333;
}
.txn-date {
min-width: 80px;
color: #888;
}
.txn-amount {
min-width: 80px;
text-align: right !important;
color: #00ff00;
}
.txn-purpose {
min-width: 100px;
color: #aaa;
}
.txn-message {
color: #666;
font-style: italic;
}
.txn-table tr:hover {
background-color: #1a1a1a;
}

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,245 @@
// Member-detail modal for /adults and /juniors pages.
// Fetches /api/<page> once on load, caches the response, renders on row click.
// Mirrors templates/adults.html JS (lines 718-993) but as a standalone module.
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const page = container.dataset.page; // "adults" | "juniors"
if (!page) return;
let apiData = null;
let currentMemberName = null;
// ── Data load ─────────────────────────────────────────────────────────────
async function loadData() {
if (apiData) return apiData;
const r = await fetch('/api/' + page);
if (!r.ok) throw new Error('[member-detail] failed to fetch /api/' + page + ': ' + r.status);
apiData = await r.json();
return apiData;
}
// Pre-warm on page load so first click is instant.
loadData().catch(function (err) { console.error(err); });
// ── Modal render ──────────────────────────────────────────────────────────
async function showMember(name) {
currentMemberName = name;
const data = (await loadData());
const member = data.member_data[name];
if (!member) return;
document.getElementById('modalMemberName').textContent = name;
document.getElementById('modalTier').textContent = 'Tier: ' + (member.tier || '-');
const monthLabels = data.month_labels || {};
const statusBody = document.getElementById('modalStatusBody');
statusBody.innerHTML = '';
const allTransactions = [];
const monthKeys = Object.keys(member.months || {}).sort().reverse();
monthKeys.forEach(function (m) {
const md = member.months[m];
const expected = md.expected; // int for adults; int or "?" for juniors
const paid = md.paid || 0;
const attendance = md.attendance_count || 0;
const originalExpected = md.original_expected;
const isUnknown = (expected === '?');
let status = '-';
let statusClass = '';
if (!isUnknown && (expected > 0 || paid > 0)) {
if (paid >= expected && expected > 0) {
status = paid + '/' + expected;
statusClass = 'cell-ok';
} else if (paid > 0) {
status = paid + '/' + expected;
} else {
status = '0/' + expected;
statusClass = 'cell-unpaid';
}
} else if (isUnknown) {
status = paid > 0 ? paid + '/?' : '?';
}
let expectedCell;
if (isUnknown) {
expectedCell = '?';
} else if (md.exception) {
expectedCell = '<span style="color: #ffaa00;" title="Overridden from ' + originalExpected + '">' + expected + '*</span>';
} else {
expectedCell = expected;
}
const displayMonth = monthLabels[m] || m;
const row = document.createElement('tr');
row.innerHTML =
'<td style="color: #888;">' + displayMonth + '</td>' +
'<td style="text-align: center; color: #ccc;">' + attendance + '</td>' +
'<td style="text-align: center; color: #ccc;">' + expectedCell + '</td>' +
'<td style="text-align: center; color: #ccc;">' + paid + '</td>' +
'<td style="text-align: right;" class="' + statusClass + '">' + status + '</td>';
statusBody.appendChild(row);
if (md.transactions) {
md.transactions.forEach(function (tx) {
allTransactions.push(Object.assign({ month: m }, tx));
});
}
});
// Exceptions
const exList = document.getElementById('modalExceptionList');
const exSection = document.getElementById('modalExceptionSection');
exList.innerHTML = '';
const exceptions = [];
monthKeys.forEach(function (m) {
if (member.months[m].exception) {
exceptions.push(Object.assign({ month: m }, member.months[m].exception));
}
});
if (exceptions.length > 0) {
exSection.style.display = 'block';
exceptions.forEach(function (ex) {
const displayMonth = monthLabels[ex.month] || ex.month;
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML =
'<div class="tx-meta">' + displayMonth + '</div>' +
'<div class="tx-main"><span class="tx-amount" style="color: #ffaa00;">' + ex.amount + ' CZK</span></div>' +
'<div class="tx-msg">' + (ex.note || 'No details provided.') + '</div>';
exList.appendChild(item);
});
} else {
exSection.style.display = 'none';
}
// Other transactions
const otherList = document.getElementById('modalOtherList');
const otherSection = document.getElementById('modalOtherSection');
otherList.innerHTML = '';
if (member.other_transactions && member.other_transactions.length > 0) {
otherSection.style.display = 'block';
member.other_transactions.forEach(function (tx) {
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML =
'<div class="tx-meta">' + tx.date + ' | ' + (tx.purpose || 'Other') + '</div>' +
'<div class="tx-main">' +
'<span class="tx-amount" style="color: #66ccff;">' + tx.amount + ' CZK</span>' +
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
'</div>' +
'<div class="tx-msg">' + (tx.message || '') + '</div>';
otherList.appendChild(item);
});
} else {
otherSection.style.display = 'none';
}
// Matched transactions (payment history)
const txList = document.getElementById('modalTxList');
txList.innerHTML = '';
if (allTransactions.length === 0) {
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
} else {
allTransactions.sort(function (a, b) { return b.date.localeCompare(a.date); });
allTransactions.forEach(function (tx) {
const displayMonth = monthLabels[tx.month] || tx.month;
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML =
'<div class="tx-meta">' + tx.date + ' | matched to ' + displayMonth + '</div>' +
'<div class="tx-main">' +
'<span class="tx-amount">' + tx.amount + ' CZK</span>' +
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
'</div>' +
'<div class="tx-msg">' + (tx.message || '') + '</div>';
txList.appendChild(item);
});
}
// Raw payments — hidden by default; reset toggle on each open
const rawList = document.getElementById('modalRawList');
const rawToggle = document.getElementById('rawPaymentsToggle');
rawList.style.display = 'none';
rawToggle.textContent = '[show]';
rawList.innerHTML = '';
const rawRows = (data.raw_payments || {})[name] || [];
if (rawRows.length === 0) {
rawList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>';
} else {
rawRows.forEach(function (tx) {
const inferredNote = (tx.inferred_amount && tx.inferred_amount !== '' && tx.inferred_amount !== tx.amount)
? ' <span style="color:#888;">(inferred: ' + tx.inferred_amount + ')</span>'
: '';
const manualNote = tx.manual_fix ? ' <span style="color:#ffaa00;">[manual fix]</span>' : '';
const bankIdNote = tx.bank_id ? '<span style="color:#444;"> · bank_id: ' + tx.bank_id + '</span>' : '';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML =
'<div class="tx-meta">' + tx.date + ' | purpose: ' + (tx.purpose || '—') + manualNote + '</div>' +
'<div class="tx-main">' +
'<span class="tx-amount">' + tx.amount + ' CZK' + inferredNote + '</span>' +
'<span class="tx-sender">' + (tx.sender || '') + '</span>' +
'</div>' +
'<div class="tx-msg">' + (tx.message || '') + '</div>' +
'<div class="tx-meta">' + (tx.person || '') + bankIdNote + '</div>';
rawList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
// ── Raw-payments toggle ───────────────────────────────────────────────────
function toggleRawPayments(ev) {
ev.preventDefault();
const list = document.getElementById('modalRawList');
const link = document.getElementById('rawPaymentsToggle');
const hidden = list.style.display === 'none';
list.style.display = hidden ? 'block' : 'none';
link.textContent = hidden ? '[hide]' : '[show]';
}
// ── Close + keyboard nav ──────────────────────────────────────────────────
function closeModal() {
document.getElementById('memberModal').classList.remove('active');
}
function navigateMember(direction) {
const rows = Array.from(document.querySelectorAll('tr.member-row'));
const visible = rows.filter(function (r) { return r.style.display !== 'none'; });
const idx = visible.findIndex(function (r) { return r.dataset.name === currentMemberName; });
if (idx === -1) return;
const next = idx + direction;
if (next >= 0 && next < visible.length) {
showMember(visible[next].dataset.name);
}
}
// ── Wiring ────────────────────────────────────────────────────────────────
document.querySelectorAll('.info-icon[data-name]').forEach(function (el) {
el.addEventListener('click', function (ev) {
ev.stopPropagation();
showMember(el.dataset.name);
});
});
document.getElementById('rawPaymentsToggle').addEventListener('click', toggleRawPayments);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeModal(); return; }
const modal = document.getElementById('memberModal');
if (!modal.classList.contains('active')) return;
if (e.key === 'ArrowDown') { e.preventDefault(); navigateMember(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); navigateMember(-1); }
});
}());

View File

@@ -0,0 +1,60 @@
(function () {
'use strict';
const container = document.getElementById('filterContainer');
if (!container) return;
const bankAccount = container.dataset.bankAccount || '';
const modal = document.getElementById('qrModal');
const titleEl = document.getElementById('qrTitle');
const imgEl = document.getElementById('qrImg');
const accountEl = document.getElementById('qrAccount');
const amountEl = document.getElementById('qrAmount');
const messageEl = document.getElementById('qrMessage');
if (!modal || !titleEl || !imgEl || !accountEl || !amountEl || !messageEl) return;
// Convert "YYYY-MM" → "MM/YYYY"; "+"-joined ranges converted piece-wise.
// Mirrors templates/adults.html showPayQR logic.
function toCzechMonth(rawMonth) {
return rawMonth.split('+')
.map(function (p) { return p.replace(/^(\d{4})-(\d{2})$/, '$2/$1'); })
.join('+');
}
function showQR(name, amount, month, rawMonth) {
var numericMonth = toCzechMonth(rawMonth);
var message = name + ': ' + numericMonth;
titleEl.innerText = 'Payment for ' + month;
accountEl.innerText = bankAccount;
amountEl.innerText = amount;
messageEl.innerText = message;
imgEl.src = '/qr?'
+ 'account=' + encodeURIComponent(bankAccount)
+ '&amount=' + encodeURIComponent(amount)
+ '&message=' + encodeURIComponent(message);
modal.classList.add('active');
}
function closeQR() {
modal.classList.remove('active');
}
document.addEventListener('click', function (ev) {
var btn = ev.target.closest('.pay-btn');
if (!btn) return;
ev.preventDefault();
showQR(
btn.dataset.name,
btn.dataset.amount,
btn.dataset.month,
btn.dataset.rawMonth
);
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.classList.contains('active')) closeQR();
});
}());

View File

@@ -0,0 +1,207 @@
{{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}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
<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}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></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)}}
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
{{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}}
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
{{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}}
<div id="memberModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalMemberName">Member Name</div>
<div class="close-btn" onclick="document.getElementById('memberModal').classList.remove('active')">[close]</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Status Summary</div>
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
<table class="modal-table">
<thead><tr>
<th>Month</th>
<th style="text-align: center;">Att.</th>
<th style="text-align: center;">Expected</th>
<th style="text-align: center;">Paid</th>
<th style="text-align: right;">Status</th>
</tr></thead>
<tbody id="modalStatusBody"></tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display: none;">
<div class="modal-section-title">Fee Exceptions</div>
<div id="modalExceptionList" class="tx-list"></div>
</div>
<div class="modal-section" id="modalOtherSection" style="display: none;">
<div class="modal-section-title">Other Transactions</div>
<div id="modalOtherList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display: none;"></div>
</div>
</div>
</div>
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for —</div>
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<script src="/static/js/filters.js" defer></script>
<script src="/static/js/member-detail.js" defer></script>
<script src="/static/js/payment-qr.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,11 @@
{{define "title"}}Flush Cache{{end}}
{{define "content"}}
<h1>Flush Cache</h1>
{{if .Flushed}}
<p class="status-ok">Cache flushed: {{.Deleted}} file(s) deleted.</p>
{{end}}
<p class="description">Deletes all cached data files so the next request fetches fresh data from Google Sheets.</p>
<form method="POST" action="/flush-cache">
<button type="submit" class="btn">Flush Cache</button>
</form>
{{end}}

View File

@@ -0,0 +1,187 @@
{{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}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
<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}}<span class="info-icon" data-name="{{$row.Name}}" title="Show details">[i]</span></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)}}
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$cell.Amount}}" data-month="{{$cell.Month}}" data-raw-month="{{$cell.RawMonth}}">Pay</button>
{{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}}
<button type="button" class="pay-btn" data-name="{{$row.Name}}" data-amount="{{$row.PayableAmount}}" data-month="{{$row.UnpaidPeriods}}" data-raw-month="{{$row.RawUnpaidPeriods}}" data-pay-all="1">Pay All</button>
{{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}}
<div id="memberModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalMemberName">Member Name</div>
<div class="close-btn" onclick="document.getElementById('memberModal').classList.remove('active')">[close]</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Status Summary</div>
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
<table class="modal-table">
<thead><tr>
<th>Month</th>
<th style="text-align: center;">Att.</th>
<th style="text-align: center;">Expected</th>
<th style="text-align: center;">Paid</th>
<th style="text-align: right;">Status</th>
</tr></thead>
<tbody id="modalStatusBody"></tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display: none;">
<div class="modal-section-title">Fee Exceptions</div>
<div id="modalExceptionList" class="tx-list"></div>
</div>
<div class="modal-section" id="modalOtherSection" style="display: none;">
<div class="modal-section-title">Other Transactions</div>
<div id="modalOtherList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list"></div>
</div>
<div class="modal-section">
<div class="modal-section-title">
Raw Payments
<a href="#" id="rawPaymentsToggle" class="raw-toggle">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display: none;"></div>
</div>
</div>
</div>
<div id="qrModal" onclick="if(event.target===this)this.classList.remove('active')">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for —</div>
<div class="close-btn" onclick="document.getElementById('qrModal').classList.remove('active')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<script src="/static/js/filters.js" defer></script>
<script src="/static/js/member-detail.js" defer></script>
<script src="/static/js/payment-qr.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,38 @@
{{define "title"}}Payments Ledger{{end}}
{{define "content"}}
<h1>Payments Ledger</h1>
<p class="description">
All bank transactions from the
<a href="{{.Data.PaymentsURL}}" target="_blank">payments sheet</a>,
grouped by member. Names are matched against the
<a href="{{.Data.AttendanceURL}}" target="_blank">attendance sheet</a>.
</p>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="ledger-container">
{{range $person := .Data.SortedPeople}}
<div class="member-block">
<h2>{{$person}}</h2>
<table class="txn-table">
<thead>
<tr>
<th class="txn-date">Date</th>
<th class="txn-amount">Amount</th>
<th class="txn-purpose">Purpose</th>
<th class="txn-message">Bank Message</th>
</tr>
</thead>
<tbody>
{{range $tx := index $.Data.GroupedPayments $person}}
<tr>
<td class="txn-date">{{$tx.Date}}</td>
<td class="txn-amount">{{printf "%.0f" $tx.Amount}} CZK</td>
<td class="txn-purpose">{{$tx.Purpose}}</td>
<td class="txn-message">{{$tx.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,15 @@
{{define "title"}}Sync Bank Data{{end}}
{{define "content"}}
<h1>Sync Bank Data</h1>
{{if .Output}}
{{if .Success}}
<p class="status-ok">Sync completed successfully.</p>
{{else}}
<p class="status-error">Sync failed — see log below.</p>
{{end}}
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
{{else}}
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
{{end}}
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
{{end}}

View File

@@ -1,7 +1,7 @@
{ {
"case": "03_proportional_remainder", "case": "03_proportional_remainder",
"func": "scripts.match_payments.reconcile", "func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06", "captured_at": "2026-05-11",
"input": { "input": {
"members": [ "members": [
{ {
@@ -54,10 +54,10 @@
"original_expected": 750, "original_expected": 750,
"attendance_count": 3, "attendance_count": 3,
"exception": null, "exception": null,
"paid": 324.3243243243243, "paid": 750.0,
"transactions": [ "transactions": [
{ {
"amount": 324.3243243243243, "amount": 750.0,
"date": "2026-03-10", "date": "2026-03-10",
"sender": "Member_d035d9f9", "sender": "Member_d035d9f9",
"message": "", "message": "",
@@ -70,10 +70,10 @@
"original_expected": 750, "original_expected": 750,
"attendance_count": 2, "attendance_count": 2,
"exception": null, "exception": null,
"paid": 324.3243243243243, "paid": 50.0,
"transactions": [ "transactions": [
{ {
"amount": 324.3243243243243, "amount": 50.0,
"date": "2026-03-10", "date": "2026-03-10",
"sender": "Member_d035d9f9", "sender": "Member_d035d9f9",
"message": "", "message": "",
@@ -86,25 +86,17 @@
"original_expected": 350, "original_expected": 350,
"attendance_count": 2, "attendance_count": 2,
"exception": null, "exception": null,
"paid": 151.35135135135135, "paid": 0,
"transactions": [ "transactions": []
{
"amount": 151.35135135135135,
"date": "2026-03-10",
"sender": "Member_d035d9f9",
"message": "",
"confidence": "auto"
}
]
} }
}, },
"other_transactions": [], "other_transactions": [],
"total_balance": -1051 "total_balance": -1050
} }
}, },
"unmatched": [], "unmatched": [],
"credits": { "credits": {
"Member_d035d9f9": -1051 "Member_d035d9f9": -1050
} }
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"case": "09_multiperson_multimonth", "case": "09_multiperson_multimonth",
"func": "scripts.match_payments.reconcile", "func": "scripts.match_payments.reconcile",
"captured_at": "2026-05-06", "captured_at": "2026-05-11",
"input": { "input": {
"members": [ "members": [
{ {
@@ -63,10 +63,10 @@
"original_expected": 750, "original_expected": 750,
"attendance_count": 3, "attendance_count": 3,
"exception": null, "exception": null,
"paid": 500.0, "paid": 750.0,
"transactions": [ "transactions": [
{ {
"amount": 500.0, "amount": 750.0,
"date": "2026-02-15", "date": "2026-02-15",
"sender": "Member_d035d9f9", "sender": "Member_d035d9f9",
"message": "", "message": "",
@@ -79,10 +79,10 @@
"original_expected": 750, "original_expected": 750,
"attendance_count": 2, "attendance_count": 2,
"exception": null, "exception": null,
"paid": 500.0, "paid": 250.0,
"transactions": [ "transactions": [
{ {
"amount": 500.0, "amount": 250.0,
"date": "2026-02-15", "date": "2026-02-15",
"sender": "Member_d035d9f9", "sender": "Member_d035d9f9",
"message": "", "message": "",
@@ -102,10 +102,10 @@
"original_expected": 750, "original_expected": 750,
"attendance_count": 2, "attendance_count": 2,
"exception": null, "exception": null,
"paid": 681.8181818181819, "paid": 750.0,
"transactions": [ "transactions": [
{ {
"amount": 681.8181818181819, "amount": 750.0,
"date": "2026-02-15", "date": "2026-02-15",
"sender": "Member_d035d9f9", "sender": "Member_d035d9f9",
"message": "", "message": "",
@@ -118,10 +118,10 @@
"original_expected": 350, "original_expected": 350,
"attendance_count": 2, "attendance_count": 2,
"exception": null, "exception": null,
"paid": 318.18181818181813, "paid": 250.0,
"transactions": [ "transactions": [
{ {
"amount": 318.18181818181813, "amount": 250.0,
"date": "2026-02-15", "date": "2026-02-15",
"sender": "Member_d035d9f9", "sender": "Member_d035d9f9",
"message": "", "message": "",
@@ -131,13 +131,13 @@
} }
}, },
"other_transactions": [], "other_transactions": [],
"total_balance": -101 "total_balance": -100
} }
}, },
"unmatched": [], "unmatched": [],
"credits": { "credits": {
"Member_d035d9f9": -500, "Member_d035d9f9": -500,
"Member_f4a93e46": -101 "Member_f4a93e46": -100
} }
} }
} }

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

@@ -4,6 +4,7 @@
import json import json
import os import os
import re import re
import sys
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from html.parser import HTMLParser from html.parser import HTMLParser
@@ -89,9 +90,11 @@ def parse_czech_amount(s: str) -> float | None:
def parse_czech_date(s: str) -> str | None: def parse_czech_date(s: str) -> str | None:
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'.""" """Parse a Czech date to 'YYYY-MM-DD'. Accepts 4-digit and 2-digit years
with dot or slash separators; Fio's transparent page mixes 'DD.MM.YYYY'
and 'DD.MM.YY' in the same response."""
s = s.strip() s = s.strip()
for fmt in ("%d.%m.%Y", "%d/%m/%Y"): for fmt in ("%d.%m.%Y", "%d/%m/%Y", "%d.%m.%y", "%d/%m/%y"):
try: try:
return datetime.strptime(s, fmt).strftime("%Y-%m-%d") return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError: except ValueError:
@@ -146,6 +149,7 @@ def fetch_transactions_transparent(
"bank_id": "", # HTML scraping doesn't give stable ID "bank_id": "", # HTML scraping doesn't give stable ID
}) })
print(f"fio: transparent fetched {len(rows)} raw rows, {len(transactions)} transaction(s) after filtering", file=sys.stderr)
return transactions return transactions
@@ -169,7 +173,8 @@ def fetch_transactions_api(
transactions = [] transactions = []
tx_list = data.get("accountStatement", {}).get("transactionList", {}) tx_list = data.get("accountStatement", {}).get("transactionList", {})
for tx in (tx_list.get("transaction") or []): raw_list = tx_list.get("transaction") or []
for tx in raw_list:
# Each field is {"value": ..., "name": ..., "id": ...} or null # Each field is {"value": ..., "name": ..., "id": ...} or null
def val(col_id): def val(col_id):
col = tx.get(f"column{col_id}") col = tx.get(f"column{col_id}")
@@ -197,6 +202,7 @@ def fetch_transactions_api(
"currency": str(val(14) or "CZK"), # column14 = Currency "currency": str(val(14) or "CZK"), # column14 = Currency
}) })
print(f"fio: api fetched {len(raw_list)} raw transaction(s), {len(transactions)} after filtering", file=sys.stderr)
return transactions return transactions
@@ -204,8 +210,14 @@ def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
"""Fetch transactions, using API if token available, else transparent page.""" """Fetch transactions, using API if token available, else transparent page."""
token = os.environ.get("FIO_API_TOKEN", "").strip() token = os.environ.get("FIO_API_TOKEN", "").strip()
if token: if token:
print(f"fio: using authenticated API, window {date_from}..{date_to}", file=sys.stderr)
return fetch_transactions_api(token, date_from, date_to) return fetch_transactions_api(token, date_from, date_to)
print(
f"fio: using transparent page (FIO_API_TOKEN unset — expect publishing lag), "
f"window {date_from}..{date_to}, account=2800359168",
file=sys.stderr,
)
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL # Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
from_dt = datetime.strptime(date_from, "%Y-%m-%d") from_dt = datetime.strptime(date_from, "%Y-%m-%d")
to_dt = datetime.strptime(date_to, "%Y-%m-%d") to_dt = datetime.strptime(date_to, "%Y-%m-%d")

View File

@@ -468,26 +468,19 @@ def reconcile(
total_expected = sum(e for _, e in in_window) total_expected = sum(e for _, e in in_window)
if total_expected > 0 and in_window_share >= total_expected: if total_expected > 0:
# Greedy phase: payment covers all in-window fees; overflow → credit. # Fill-first: iterate in_window in matched_months order (chronological by
credits[member_name] = credits.get(member_name, 0) + int(in_window_share - total_expected) # convention from infer_payments.py), allocating min(remaining, deficit) to
for m, exp in in_window: # each month. Deficit is net of what prior transactions already paid, so a
alloc = float(exp) # second payment on the same months correctly fills only what remains due.
ledger[member_name][m]["paid"] += alloc # Any surplus after all deficits are covered goes to the credit bucket.
ledger[member_name][m]["transactions"].append({
"amount": alloc,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
elif total_expected > 0:
# Proportional phase: distribute in_window_share by each month's expected fee.
# Last month absorbs any float remainder so the sum equals in_window_share exactly.
remaining = in_window_share remaining = in_window_share
for i, (m, exp) in enumerate(in_window): for m, exp in in_window:
alloc = remaining if i == len(in_window) - 1 else in_window_share * exp / total_expected paid_so_far = ledger[member_name][m]["paid"]
remaining -= alloc deficit = max(0.0, float(exp) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
ledger[member_name][m]["paid"] += alloc ledger[member_name][m]["paid"] += alloc
ledger[member_name][m]["transactions"].append({ ledger[member_name][m]["transactions"].append({
"amount": alloc, "amount": alloc,
@@ -496,6 +489,9 @@ def reconcile(
"message": tx["message"], "message": tx["message"],
"confidence": confidence, "confidence": confidence,
}) })
remaining -= alloc
if remaining > 0:
credits[member_name] = credits.get(member_name, 0) + int(remaining)
else: else:
# Fallback: no expected fees (prepayment before attendance recorded); even split. # Fallback: no expected fees (prepayment before attendance recorded); even split.
per_month = in_window_share / len(in_window) per_month = in_window_share / len(in_window)

View File

@@ -77,6 +77,35 @@ def generate_sync_id(tx: dict) -> str:
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest() return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
def _trunc(s: str, n: int = 40) -> str:
s = str(s)
return s if len(s) <= n else s[: n - 1] + ""
def _print_fio_table(transactions: list[dict], statuses: list[str]) -> None:
headers = ["DATE", "AMOUNT", "SENDER", "VS", "MESSAGE", "BANKID", "STATUS"]
rows = [
[
str(tx.get("date", "")),
f"{float(tx.get('amount', 0)):.2f}",
str(tx.get("sender", "")),
str(tx.get("vs", "")),
_trunc(str(tx.get("message", ""))),
str(tx.get("bank_id", "")),
status,
]
for tx, status in zip(transactions, statuses)
]
widths = [
max(len(headers[i]), max((len(r[i]) for r in rows), default=0))
for i in range(len(headers))
]
sep = " "
print(sep.join(h.ljust(w) for h, w in zip(headers, widths)))
for row in rows:
print(sep.join(cell.ljust(w) for cell, w in zip(row, widths)))
def sort_sheet_by_date(service, spreadsheet_id): def sort_sheet_by_date(service, spreadsheet_id):
"""Sort the sheet by the Date column (Column B).""" """Sort the sheet by the Date column (Column B)."""
# Get the sheet ID (gid) of the first sheet # Get the sheet ID (gid) of the first sheet
@@ -104,12 +133,21 @@ def sort_sheet_by_date(service, spreadsheet_id):
print("Sheet sorted by date.") print("Sheet sorted by date.")
def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None, date_from_str: str = None, date_to_str: str = None, sort_by_date: bool = False): def sync_to_sheets(
spreadsheet_id: str,
credentials_path: str,
days: int = None,
date_from_str: str = None,
date_to_str: str = None,
sort_by_date: bool = False,
dry_run: bool = False,
print_fio_table: bool = False,
):
print(f"Connecting to Google Sheets using {credentials_path}...") print(f"Connecting to Google Sheets using {credentials_path}...")
service = get_sheets_service(credentials_path) service = get_sheets_service(credentials_path)
sheet = service.spreadsheets() sheet = service.spreadsheets()
# 1. Fetch existing IDs from Column G (last column in A-G range) # 1. Read existing sync IDs from Column K
print(f"Reading existing sync IDs from sheet...") print(f"Reading existing sync IDs from sheet...")
try: try:
result = sheet.values().get( result = sheet.values().get(
@@ -117,19 +155,22 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
range="A1:K" # Include header and all columns to check Sync ID range="A1:K" # Include header and all columns to check Sync ID
).execute() ).execute()
values = result.get("values", []) values = result.get("values", [])
# Check and insert labels if missing # Check and insert labels if missing
if not values or values[0] != COLUMN_LABELS: if not values or values[0] != COLUMN_LABELS:
print("Inserting column labels...") if dry_run:
sheet.values().update( print("Dry run: would write header row")
spreadsheetId=spreadsheet_id, else:
range="A1", print("Inserting column labels...")
valueInputOption="USER_ENTERED", sheet.values().update(
body={"values": [COLUMN_LABELS]} spreadsheetId=spreadsheet_id,
).execute() range="A1",
valueInputOption="USER_ENTERED",
body={"values": [COLUMN_LABELS]}
).execute()
existing_ids = set() existing_ids = set()
else: else:
# Sync ID is now the last column (index 10) # Sync ID is the last column (index 10)
existing_ids = {row[10] for row in values[1:] if len(row) > 10} existing_ids = {row[10] for row in values[1:] if len(row) > 10}
except Exception as e: except Exception as e:
print(f"Error reading sheet (maybe empty?): {e}") print(f"Error reading sheet (maybe empty?): {e}")
@@ -150,8 +191,12 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
transactions = fetch_transactions(df_str, dt_str) transactions = fetch_transactions(df_str, dt_str)
print(f"Found {len(transactions)} transactions.") print(f"Found {len(transactions)} transactions.")
# 3. Filter for new transactions if dry_run:
print(f"Dry run: window {df_str} to {dt_str}, fetched {len(transactions)} transaction(s) from Fio")
# 3. Determine NEW/DUP for each transaction
new_rows = [] new_rows = []
tx_statuses = []
for tx in transactions: for tx in transactions:
sync_id = generate_sync_id(tx) sync_id = generate_sync_id(tx)
if sync_id not in existing_ids: if sync_id not in existing_ids:
@@ -169,24 +214,48 @@ def sync_to_sheets(spreadsheet_id: str, credentials_path: str, days: int = None,
tx.get("bank_id", ""), tx.get("bank_id", ""),
sync_id, sync_id,
]) ])
tx_statuses.append("NEW")
else:
tx_statuses.append("DUP")
# 4. Print table (before early-return so all transactions are shown including DUPs)
if print_fio_table and transactions:
_print_fio_table(transactions, tx_statuses)
if not new_rows: if not new_rows:
print("No new transactions to sync.") if dry_run:
print("Dry run: would sync 0 new transaction(s).")
else:
print("No new transactions to sync.")
return return
# 4. Append to sheet # 5. Append to sheet or print dry-run would-write lines
print(f"Appending {len(new_rows)} new transactions to the sheet...") if dry_run:
body = {"values": new_rows} for tx, status in zip(transactions, tx_statuses):
sheet.values().append( if status == "NEW":
spreadsheetId=spreadsheet_id, print(
range="A2", # Appends to the end of the sheet f"Dry run: would append"
valueInputOption="USER_ENTERED", f" date={tx.get('date', '')}"
body=body f" amount={tx.get('amount', '')}"
).execute() f" sender={tx.get('sender', '')}"
print("Sync completed successfully.") f" vs={tx.get('vs', '')}"
f" message={tx.get('message', '')}"
if sort_by_date: )
sort_sheet_by_date(service, spreadsheet_id) if sort_by_date:
print("Dry run: would sort by date")
print(f"Dry run: would sync {len(new_rows)} new transaction(s).")
else:
print(f"Appending {len(new_rows)} new transactions to the sheet...")
body = {"values": new_rows}
sheet.values().append(
spreadsheetId=spreadsheet_id,
range="A2", # Appends to the end of the sheet
valueInputOption="USER_ENTERED",
body=body
).execute()
print("Sync completed successfully.")
if sort_by_date:
sort_sheet_by_date(service, spreadsheet_id)
def main(): def main():
@@ -197,16 +266,20 @@ def main():
parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD") parser.add_argument("--from", dest="date_from", help="Start date YYYY-MM-DD")
parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD") parser.add_argument("--to", dest="date_to", help="End date YYYY-MM-DD")
parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync") parser.add_argument("--sort-by-date", action="store_true", help="Sort the sheet by date after sync")
parser.add_argument("--dry-run", action="store_true", help="Fetch and dedup without writing to the sheet")
parser.add_argument("--print-fio-table", action="store_true", help="Print aligned table of all fetched transactions with NEW/DUP status (use with --dry-run)")
args = parser.parse_args() args = parser.parse_args()
try: try:
sync_to_sheets( sync_to_sheets(
spreadsheet_id=args.sheet_id, spreadsheet_id=args.sheet_id,
credentials_path=args.credentials, credentials_path=args.credentials,
days=args.days, days=args.days,
date_from_str=args.date_from, date_from_str=args.date_from,
date_to_str=args.date_to, date_to_str=args.date_to,
sort_by_date=args.sort_by_date sort_by_date=args.sort_by_date,
dry_run=args.dry_run,
print_fio_table=args.print_fio_table,
) )
except Exception as e: except Exception as e:
print(f"Sync failed: {e}") print(f"Sync failed: {e}")

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

@@ -93,8 +93,8 @@ class TestMultiMonthAllocation(unittest.TestCase):
self.assertEqual(int(months['2026-02']['paid']), 750) self.assertEqual(int(months['2026-02']['paid']), 750)
self.assertEqual(result['credits'].get('Alice', 0), 500) self.assertEqual(result['credits'].get('Alice', 0), 500)
def test_proportional_underpayment(self): def test_underpayment_fills_earliest_first(self):
"""Payment < total expected → proportional split; sum of paid == payment amount.""" """Payment < total expected → fill earliest months first, spill remainder to later."""
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})] members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
sorted_months = ['2026-02', '2026-03', '2026-04'] sorted_months = ['2026-02', '2026-03', '2026-04']
amount = 1250 amount = 1250
@@ -103,18 +103,28 @@ class TestMultiMonthAllocation(unittest.TestCase):
result = reconcile(members, sorted_months, [tx]) result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months'] months = result['members']['Alice']['months']
paid_02 = months['2026-02']['paid'] # 02 filled first (750), then 03 (350), then remainder 150 to 04
paid_03 = months['2026-03']['paid'] self.assertAlmostEqual(months['2026-02']['paid'], 750, places=2)
paid_04 = months['2026-04']['paid'] self.assertAlmostEqual(months['2026-03']['paid'], 350, places=2)
self.assertAlmostEqual(months['2026-04']['paid'], 150, places=2)
# No CZK lost
self.assertAlmostEqual(
months['2026-02']['paid'] + months['2026-03']['paid'] + months['2026-04']['paid'],
amount, places=2,
)
# All months should be partial (underpaid) def test_fill_first_across_two_transactions(self):
self.assertLess(paid_02, 750) """Prior txn fills 02 partially; later txn finishes 02 then spills to 03."""
self.assertLess(paid_03, 350) members = [('Matyáš', 'A', {'2026-02': (500, 2), '2026-03': (250, 1)})]
self.assertLess(paid_04, 750) sorted_months = ['2026-02', '2026-03']
# Sum must equal the original payment (no CZK lost) tx1 = _tx('Matyáš', '2026-02', 200)
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2) tx2 = _tx('Matyáš', '2026-02, 2026-03', 550)
# 02 and 04 have equal expected → equal allocation
self.assertAlmostEqual(paid_02, paid_04, places=2) result = reconcile(members, sorted_months, [tx1, tx2])
months = result['members']['Matyáš']['months']
self.assertAlmostEqual(months['2026-02']['paid'], 500, places=2)
self.assertAlmostEqual(months['2026-03']['paid'], 250, places=2)
def test_single_month_unchanged(self): def test_single_month_unchanged(self):
"""Single-month payment: full amount goes to that month (regression guard).""" """Single-month payment: full amount goes to that month (regression guard)."""