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
+24
View File
@@ -1,6 +1,8 @@
package config
import (
"fmt"
"fuj-management/go/internal/scheduler"
"os"
"strconv"
"strings"
@@ -92,6 +94,28 @@ func Load() Config {
}
}
// Scheduled bank sync defaults (Go server only; the legacy Python app has no scheduler).
const (
DefaultSyncSchedule = "04:00" // SYNC_SCHEDULE: comma-separated HH:MM times, or "off"
DefaultSyncTimezone = "Europe/Prague" // SYNC_TZ: IANA zone the schedule is read in
)
// LoadSyncSchedule reads SYNC_SCHEDULE and SYNC_TZ. An empty schedule means
// the scheduled sync is disabled. Resolving the zone needs a zone database:
// the server binary embeds one via time/tzdata.
func LoadSyncSchedule() ([]scheduler.TimeOfDay, *time.Location, error) {
times, err := scheduler.ParseTimes(env("SYNC_SCHEDULE", DefaultSyncSchedule))
if err != nil {
return nil, nil, fmt.Errorf("SYNC_SCHEDULE: %w", err)
}
tz := env("SYNC_TZ", DefaultSyncTimezone)
loc, err := time.LoadLocation(tz)
if err != nil {
return nil, nil, fmt.Errorf("SYNC_TZ: unknown time zone %q", tz)
}
return times, loc, nil
}
// IBANAccountNum extracts the bare account number from a Czech IBAN.
// "CZ8520100000002800359168" → "2800359168"
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
+45
View File
@@ -0,0 +1,45 @@
package config
import (
"testing"
)
func TestLoadSyncSchedule(t *testing.T) {
cases := []struct {
name, schedule, tz string
wantTimes int
wantZone string
wantErr bool
}{
{"defaults", "", "", 1, "Europe/Prague", false},
{"two times", "06:00,18:00", "", 2, "Europe/Prague", false},
{"off", "off", "", 0, "Europe/Prague", false},
{"other zone", "04:00", "UTC", 1, "UTC", false},
{"bad time", "25:00", "", 0, "", true},
{"bad zone", "04:00", "Mars/Olympus", 0, "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SYNC_SCHEDULE", tc.schedule)
t.Setenv("SYNC_TZ", tc.tz)
times, loc, err := LoadSyncSchedule()
if (err != nil) != tc.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
}
if tc.wantErr {
return
}
if len(times) != tc.wantTimes {
t.Errorf("times = %v, want %d entries", times, tc.wantTimes)
}
if loc.String() != tc.wantZone {
t.Errorf("zone = %s, want %s", loc, tc.wantZone)
}
})
}
t.Setenv("SYNC_SCHEDULE", "")
t.Setenv("SYNC_TZ", "")
if times, _, _ := LoadSyncSchedule(); len(times) != 1 || times[0].String() != DefaultSyncSchedule {
t.Errorf("default schedule = %v, want [%s]", times, DefaultSyncSchedule)
}
}