feat(go): scheduled bank sync (Go only)

The Go server now runs sync -> infer -> flush-cache by itself at the
times in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00, "off"
disables), read in SYNC_TZ (default Europe/Prague). Nothing runs at
startup; an invalid value stops the server with an error.

- internal/scheduler: time-of-day parsing, Next, and the run loop
- banksync.Runner: one lock shared by /sync-bank and the scheduler so
  runs never overlap, plus the status of the last run
- footer shows the last sync since start and the next scheduled run
- binary embeds time/tzdata because the Alpine image has no zone data

Deliberately not implemented in the legacy Python app, which is being
phased out; recorded in CLAUDE.md under "Deliberately Go-only features".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 12:17:15 +02:00
co-authored by Claude Opus 5.5
parent 1d5a1c95d2
commit 2c2db60fe7
19 changed files with 741 additions and 19 deletions
+19 -10
View File
@@ -21,17 +21,26 @@ func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHan
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
}
// page builds the data shared by every page (nav highlight, build info, sync summary).
func (h *HTMLHandler) page(active string) PageData {
pd := PageData{Active: active, Build: h.build}
if h.actions.SyncInfo != nil {
pd.SyncInfo = h.actions.SyncInfo()
}
return pd
}
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},
PageData: h.page("adults"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "adults", AdultsPageData{
PageData: PageData{Active: "adults", Build: h.build},
PageData: h.page("adults"),
Data: data,
})
}
@@ -40,13 +49,13 @@ 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},
PageData: h.page("juniors"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "juniors", JuniorsPageData{
PageData: PageData{Active: "juniors", Build: h.build},
PageData: h.page("juniors"),
Data: data,
})
}
@@ -55,20 +64,20 @@ 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},
PageData: h.page("payments"),
Error: err.Error(),
})
return
}
h.renderer.Render(w, "payments", PaymentsPageData{
PageData: PageData{Active: "payments", Build: h.build},
PageData: h.page("payments"),
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}
pd := h.page("sync")
if h.actions.BankSync == nil {
h.renderer.Render(w, "sync", SyncPageData{
@@ -96,7 +105,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
}
h.renderer.Render(w, "sync", SyncPageData{
PageData: pd,
PageData: h.page("sync"), // rebuilt so the footer reflects this run
Output: buf.String(),
Success: success,
})
@@ -105,7 +114,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
// 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},
PageData: h.page("flush"),
})
}
@@ -113,7 +122,7 @@ func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request)
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},
PageData: h.page("flush"),
Flushed: true,
Deleted: n,
})