Files
fuj-management/go/internal/services/banksync/runner.go
T
kacerrandClaude Opus 5.5 2c2db60fe7 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>
2026-10-08 12:17:15 +02:00

64 lines
1.5 KiB
Go

package banksync
import (
"context"
"errors"
"io"
"sync"
"time"
)
// ErrBusy is returned by Runner.Run when another run is still in progress.
var ErrBusy = errors.New("a sync is already running")
// RunStatus describes one finished run of a Runner.
type RunStatus struct {
Started, Finished time.Time
Trigger string // "manual" or "scheduled"
Err error
}
// Runner serialises bank sync runs so a scheduled run and a manual one never
// overlap (overlapping runs could append the same rows twice), and remembers
// the outcome of the last run for display.
type Runner struct {
job func(ctx context.Context, out io.Writer) error
running sync.Mutex
mu sync.Mutex
last RunStatus
hasLast bool
}
// NewRunner wraps job, which performs one full sync and writes a
// human-readable log to out.
func NewRunner(job func(ctx context.Context, out io.Writer) error) *Runner {
return &Runner{job: job}
}
// Run executes the job unless one is already running, in which case it
// returns ErrBusy without waiting.
func (r *Runner) Run(ctx context.Context, trigger string, out io.Writer) error {
if !r.running.TryLock() {
return ErrBusy
}
defer r.running.Unlock()
st := RunStatus{Started: time.Now(), Trigger: trigger}
st.Err = r.job(ctx, out)
st.Finished = time.Now()
r.mu.Lock()
r.last, r.hasLast = st, true
r.mu.Unlock()
return st.Err
}
// Last returns the outcome of the most recent finished run, if any.
func (r *Runner) Last() (RunStatus, bool) {
r.mu.Lock()
defer r.mu.Unlock()
return r.last, r.hasLast
}