From 6c940f4403f6fae87c3b5875d20e171e71bb177a Mon Sep 17 00:00:00 2001 From: zabcxky-123 Date: Mon, 19 Oct 2026 08:57:50 +0800 Subject: [PATCH] fix: avoid skipping calendar windows that open after startup (#16) Co-authored-by: zabcxky-123 <227033387+zabcxky-123@users.noreply.github.com> --- server/calendar_schedule.go | 53 +++++++++------- server/calendar_schedule_startup_test.go | 79 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 21 deletions(-) create mode 100644 server/calendar_schedule_startup_test.go diff --git a/server/calendar_schedule.go b/server/calendar_schedule.go index deefc75..5992f6e 100644 --- a/server/calendar_schedule.go +++ b/server/calendar_schedule.go @@ -15,6 +15,7 @@ type scheduleWindow struct { active bool complete bool key string + start time.Time nextStart *time.Time nextEnd *time.Time } @@ -81,7 +82,7 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow { if s.EndDate != "" { key += ":" + s.EndDate } - w := scheduleWindow{active: active, complete: complete, key: key, nextEnd: end} + w := scheduleWindow{active: active, complete: complete, key: key, start: start, nextEnd: end} if now.Before(start) { w.key = "" w.nextStart = &start @@ -109,6 +110,7 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow { start, end := windowBounds(day, s, loc) if !now.Before(start) && (end == nil || now.Before(*end)) { w.active = true + w.start = start w.key = "active:" + start.Format("2006-01-02T15:04") w.nextEnd = end // Do not pause between adjacent windows (e.g. Saturday + Sunday full days). @@ -124,6 +126,34 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow { return w } +// evaluateRuntimeScheduleWindow applies startup/manual state to a calendar +// window. A plan's creation date says nothing about when its next window opens. +func evaluateRuntimeScheduleWindow(item *db.TaskSchedule, now, startedAt time.Time) scheduleWindow { + w := evaluateScheduleWindow(item, now) + if !item.Enabled || item.Status == "completed" { + w.active = false + return w + } + // Only suppress an unrecorded window that was already open at startup. + // An explicit configuration edit clears LastWindowKey and advances UpdatedAt; + // allow such edits (as well as newly-created plans) to enter the current window. + if w.active && item.LastWindowKey == "" && item.CreatedAt.Before(startedAt) && + !item.UpdatedAt.After(startedAt) && !w.start.IsZero() && w.start.Before(startedAt) { + w.active = false + w.key = "missed:" + w.key + } + if strings.HasPrefix(item.LastWindowKey, "missed:") && item.LastWindowKey == "missed:"+w.key { + w.active = false + w.key = item.LastWindowKey + } + if item.LastWindowKey == "manual" && !w.active { + w.active = true + w.key = "manual" + w.complete = false + } + return w +} + func (sc *Scheduler) fireTaskSchedules() { sc.s.scheduleMu.Lock() defer sc.s.scheduleMu.Unlock() @@ -135,26 +165,7 @@ func (sc *Scheduler) fireTaskSchedules() { open := map[int64]bool{} windows := map[int64]scheduleWindow{} for _, item := range items { - w := evaluateScheduleWindow(item, now) - // A service restart must not replay a window that started while the - // service was down. Newly-created schedules are allowed to enter an - // already-open window because their creation is an explicit user action. - if w.active && item.LastWindowKey == "" && item.CreatedAt.Before(sc.startedAt) { - w.active = false - w.key = "missed:" + w.key - } - if strings.HasPrefix(item.LastWindowKey, "missed:") && item.LastWindowKey == "missed:"+w.key { - w.active = false - w.key = item.LastWindowKey - } - if item.LastWindowKey == "manual" && !w.active { - w.active = true - w.key = "manual" - w.complete = false - } - if !item.Enabled || item.Status == "completed" { - w.active = false - } + w := evaluateRuntimeScheduleWindow(item, now, sc.startedAt) windows[item.ID] = w if w.active { for _, id := range item.TaskIDs { diff --git a/server/calendar_schedule_startup_test.go b/server/calendar_schedule_startup_test.go new file mode 100644 index 0000000..4dc5195 --- /dev/null +++ b/server/calendar_schedule_startup_test.go @@ -0,0 +1,79 @@ +package server + +import ( + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/db" +) + +func calendarTestTime(s string) time.Time { + v, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return v +} + +func TestRuntimeScheduleStartupBoundaries(t *testing.T) { + old := calendarTestTime("2026-10-15T12:11:17+08:00") + for _, tc := range []struct { + name, now, boot string + change func(*db.TaskSchedule) + active, missed bool + }{ + {"future weekday after restart", "2026-10-16T18:00:06+08:00", "2026-10-15T19:36:05+08:00", nil, true, false}, + {"window starts exactly with service", "2026-10-16T18:00:05+08:00", "2026-10-16T18:00:00+08:00", nil, true, false}, + {"already open before restart", "2026-10-16T18:10:05+08:00", "2026-10-16T18:10:00+08:00", nil, false, true}, + {"new plan inside current window", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.CreatedAt = calendarTestTime("2026-10-16T18:11:00+08:00") }, true, false}, + {"explicit edit inside current window", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.UpdatedAt = calendarTestTime("2026-10-16T18:11:00+08:00") }, true, false}, + {"recorded window survives restart", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.LastWindowKey = "active:2026-10-16T18:00" }, true, false}, + {"same missed window remains blocked", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { + s.LastWindowKey = "missed:active:2026-10-16T18:00" + s.UpdatedAt = calendarTestTime("2026-10-16T18:11:00+08:00") + }, false, true}, + {"previous missed window does not block next", "2026-10-19T18:00:05+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.LastWindowKey = "missed:active:2026-10-16T18:00" }, true, false}, + {"disabled plan does not acquire missed state", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.Enabled = false }, false, false}, + {"manual run outside window", "2026-10-16T17:00:00+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.LastWindowKey = "manual" }, true, false}, + {"once future boundary", "2026-10-16T18:00:05+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.ScheduleType = "once"; s.RunDate = "2026-10-16" }, true, false}, + {"once missed boundary", "2026-10-16T18:12:00+08:00", "2026-10-16T18:10:00+08:00", func(s *db.TaskSchedule) { s.ScheduleType = "once"; s.RunDate = "2026-10-16" }, false, true}, + {"completed plan remains stopped", "2026-10-16T18:12:00+08:00", "2026-10-15T19:36:05+08:00", func(s *db.TaskSchedule) { s.Status = "completed" }, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{1, 2, 3, 4, 5}, StartTime: "18:00:00", EndTime: "00:00:00", CreatedAt: old, UpdatedAt: old} + if tc.change != nil { + tc.change(s) + } + w := evaluateRuntimeScheduleWindow(s, calendarTestTime(tc.now), calendarTestTime(tc.boot)) + if w.active != tc.active || strings.HasPrefix(w.key, "missed:") != tc.missed { + t.Fatalf("active=%v key=%q, want active=%v missed=%v", w.active, w.key, tc.active, tc.missed) + } + }) + } +} + +func TestRuntimeScheduleWeekendAndAdjacentWindows(t *testing.T) { + old := calendarTestTime("2026-10-15T12:11:37+08:00") + boot := calendarTestTime("2026-10-16T18:20:00+08:00") + weekend := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{6, 7}, StartTime: "00:00:00", EndTime: "00:00:00", CreatedAt: old, UpdatedAt: old} + for _, at := range []string{"2026-10-17T00:00:00+08:00", "2026-10-18T00:00:00+08:00", "2026-10-18T23:59:59+08:00"} { + w := evaluateRuntimeScheduleWindow(weekend, calendarTestTime(at), boot) + if !w.active { + t.Fatalf("weekend unexpectedly inactive at %s: %+v", at, w) + } + } + weekday := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{1, 2, 3, 4, 5}, StartTime: "00:00:00", EndTime: "09:00:00", CreatedAt: old, UpdatedAt: old} + at := calendarTestTime("2026-10-19T00:00:00+08:00") + if evaluateRuntimeScheduleWindow(weekend, at, boot).active || !evaluateRuntimeScheduleWindow(weekday, at, boot).active { + t.Fatal("weekend-to-weekday boundary must keep the shared task union open") + } +} + +func TestRuntimeScheduleUsesBeijingWindowAcrossTimezones(t *testing.T) { + s := &db.TaskSchedule{Enabled: true, ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{5}, StartTime: "18:00:00", EndTime: "00:00:00", CreatedAt: calendarTestTime("2026-10-15T12:00:00+08:00")} + w := evaluateRuntimeScheduleWindow(s, calendarTestTime("2026-10-16T06:00:05-04:00"), calendarTestTime("2026-10-15T07:36:05-04:00")) + if !w.active || w.key != "active:2026-10-16T18:00" { + t.Fatalf("unexpected timezone result: %+v", w) + } +}