diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 04775ac42..682eb5193 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -7,6 +7,7 @@ import ( "fmt" "log" "os" + "strings" "sync" "time" @@ -286,6 +287,14 @@ func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int6 // Use gronx to calculate next run time now := time.UnixMilli(nowMS) + if tz := strings.TrimSpace(schedule.TZ); tz != "" { + loc, err := time.LoadLocation(tz) + if err != nil { + log.Printf("[cron] failed to load timezone %q: %v", tz, err) + } else { + now = now.In(loc) + } + } nextTime, err := gronx.NextTickAfter(schedule.Expr, now, false) if err != nil { log.Printf("[cron] failed to compute next run for expr '%s': %v", schedule.Expr, err) diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go index 1a0dd1829..8ebb3f3ef 100644 --- a/pkg/cron/service_test.go +++ b/pkg/cron/service_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "runtime" "testing" + "time" ) func TestSaveStore_FilePermissions(t *testing.T) { @@ -33,6 +34,48 @@ func TestSaveStore_FilePermissions(t *testing.T) { } } +func TestComputeNextRun_UsesScheduleTimeZone(t *testing.T) { + cs := NewCronService(filepath.Join(t.TempDir(), "cron", "jobs.json"), nil) + now := time.Date(2026, time.March, 13, 12, 30, 0, 0, time.UTC).UnixMilli() + baseline := cs.computeNextRun(&CronSchedule{Kind: "cron", Expr: "0 9 * * *"}, now) + if baseline == nil { + t.Fatal("baseline computeNextRun() returned nil") + } + + t.Run("uses explicit timezone", func(t *testing.T) { + next := cs.computeNextRun(&CronSchedule{ + Kind: "cron", + Expr: "0 9 * * *", + TZ: "America/New_York", + }, now) + if next == nil { + t.Fatal("computeNextRun() returned nil") + } + + wantNext := time.Date(2026, time.March, 13, 13, 0, 0, 0, time.UTC).UnixMilli() + if *next != wantNext { + t.Fatalf("computeNextRun() = %d, want %d", *next, wantNext) + } + if *next == *baseline { + t.Fatal("explicit timezone should change the computed next run for this fixture") + } + }) + + t.Run("falls back to baseline on invalid timezone", func(t *testing.T) { + next := cs.computeNextRun(&CronSchedule{ + Kind: "cron", + Expr: "0 9 * * *", + TZ: "Mars/OlympusMons", + }, now) + if next == nil { + t.Fatal("computeNextRun() returned nil") + } + if *next != *baseline { + t.Fatalf("computeNextRun() = %d, want baseline %d", *next, *baseline) + } + }) +} + func int64Ptr(v int64) *int64 { return &v }