feat: shift recurring event date if lands on weekends
This commit is contained in:
parent
f7558c0eb5
commit
fb0cfb5a45
8 changed files with 191 additions and 66 deletions
|
|
@ -49,6 +49,10 @@ type CreateRecurringEventInput struct {
|
|||
FireMinute int
|
||||
Timezone string
|
||||
|
||||
// BusinessDaysOnly, if true, shifts any firing that lands on Saturday or
|
||||
// Sunday forward to the following Monday.
|
||||
BusinessDaysOnly bool
|
||||
|
||||
// StartDate is the local calendar date (Y-M-D) of the first intended firing
|
||||
// in the event's timezone. The first NextRunAt is computed from StartDate,
|
||||
// FireHour, FireMinute, and Timezone — clamped to the recurrence anchors.
|
||||
|
|
@ -78,7 +82,7 @@ func (s *RecurringEventService) Create(input CreateRecurringEventInput) (*model.
|
|||
return nil, fmt.Errorf("start date is required")
|
||||
}
|
||||
|
||||
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate)
|
||||
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate, input.BusinessDaysOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -90,25 +94,26 @@ func (s *RecurringEventService) Create(input CreateRecurringEventInput) (*model.
|
|||
|
||||
now := time.Now().UTC()
|
||||
ev := &model.RecurringEvent{
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: input.SpaceID,
|
||||
Kind: input.Kind,
|
||||
SourceAccountID: input.SourceAccountID,
|
||||
Title: title,
|
||||
Amount: input.Amount,
|
||||
Description: description,
|
||||
Frequency: input.Frequency,
|
||||
IntervalCount: input.IntervalCount,
|
||||
DayOfWeek: input.DayOfWeek,
|
||||
DayOfMonth: input.DayOfMonth,
|
||||
MonthOfYear: input.MonthOfYear,
|
||||
FireHour: input.FireHour,
|
||||
FireMinute: input.FireMinute,
|
||||
Timezone: input.Timezone,
|
||||
NextRunAt: firstFire.UTC(),
|
||||
Paused: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: uuid.NewString(),
|
||||
SpaceID: input.SpaceID,
|
||||
Kind: input.Kind,
|
||||
SourceAccountID: input.SourceAccountID,
|
||||
Title: title,
|
||||
Amount: input.Amount,
|
||||
Description: description,
|
||||
Frequency: input.Frequency,
|
||||
IntervalCount: input.IntervalCount,
|
||||
DayOfWeek: input.DayOfWeek,
|
||||
DayOfMonth: input.DayOfMonth,
|
||||
MonthOfYear: input.MonthOfYear,
|
||||
FireHour: input.FireHour,
|
||||
FireMinute: input.FireMinute,
|
||||
Timezone: input.Timezone,
|
||||
BusinessDaysOnly: input.BusinessDaysOnly,
|
||||
NextRunAt: firstFire.UTC(),
|
||||
Paused: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ev); err != nil {
|
||||
|
|
@ -134,6 +139,8 @@ type UpdateRecurringEventInput struct {
|
|||
FireMinute int
|
||||
Timezone string
|
||||
|
||||
BusinessDaysOnly bool
|
||||
|
||||
// StartDate, if non-zero, recomputes the next firing. If zero, the current
|
||||
// cursor is kept (useful for purely cosmetic edits like renaming).
|
||||
StartDate time.Time
|
||||
|
|
@ -163,7 +170,7 @@ func (s *RecurringEventService) Update(input UpdateRecurringEventInput) (*model.
|
|||
|
||||
nextRun := existing.NextRunAt
|
||||
if !input.StartDate.IsZero() {
|
||||
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate)
|
||||
firstFire, err := firstFireOnOrAfter(input.Frequency, input.IntervalCount, input.DayOfWeek, input.DayOfMonth, input.MonthOfYear, input.FireHour, input.FireMinute, loc, input.StartDate, input.BusinessDaysOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -188,6 +195,7 @@ func (s *RecurringEventService) Update(input UpdateRecurringEventInput) (*model.
|
|||
existing.FireHour = input.FireHour
|
||||
existing.FireMinute = input.FireMinute
|
||||
existing.Timezone = input.Timezone
|
||||
existing.BusinessDaysOnly = input.BusinessDaysOnly
|
||||
existing.NextRunAt = nextRun
|
||||
|
||||
if err := s.repo.Update(existing); err != nil {
|
||||
|
|
@ -334,21 +342,35 @@ func validateRule(kind model.RecurringEventKind, src string, freq model.Recurrin
|
|||
|
||||
// firstFireOnOrAfter computes the first firing in `loc` at or after the local
|
||||
// midnight of startDate, snapped to the recurrence anchors and time-of-day.
|
||||
func firstFireOnOrAfter(freq model.RecurringFrequency, interval int, dow, dom, moy *int, hour, minute int, loc *time.Location, startDate time.Time) (time.Time, error) {
|
||||
func firstFireOnOrAfter(freq model.RecurringFrequency, interval int, dow, dom, moy *int, hour, minute int, loc *time.Location, startDate time.Time, businessDaysOnly bool) (time.Time, error) {
|
||||
startLocal := time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, loc)
|
||||
threshold := startLocal.Add(-time.Nanosecond) // nextFireAfter computes strictly-after; -1ns lets the first candidate land on startDate
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: freq,
|
||||
IntervalCount: interval,
|
||||
DayOfWeek: dow,
|
||||
DayOfMonth: dom,
|
||||
MonthOfYear: moy,
|
||||
FireHour: hour,
|
||||
FireMinute: minute,
|
||||
Frequency: freq,
|
||||
IntervalCount: interval,
|
||||
DayOfWeek: dow,
|
||||
DayOfMonth: dom,
|
||||
MonthOfYear: moy,
|
||||
FireHour: hour,
|
||||
FireMinute: minute,
|
||||
BusinessDaysOnly: businessDaysOnly,
|
||||
}
|
||||
return nextFireAfter(ev, threshold, loc)
|
||||
}
|
||||
|
||||
// shiftToBusinessDay rolls Saturday/Sunday firings forward to the following
|
||||
// Monday, preserving the time-of-day in `loc`.
|
||||
func shiftToBusinessDay(t time.Time, loc *time.Location) time.Time {
|
||||
local := t.In(loc)
|
||||
switch local.Weekday() {
|
||||
case time.Saturday:
|
||||
return local.AddDate(0, 0, 2)
|
||||
case time.Sunday:
|
||||
return local.AddDate(0, 0, 1)
|
||||
}
|
||||
return local
|
||||
}
|
||||
|
||||
// nextFireAfter computes the next firing strictly after `after`, in UTC.
|
||||
func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location) (time.Time, error) {
|
||||
afterLocal := after.In(loc)
|
||||
|
|
@ -358,6 +380,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
|||
for !c.After(after) {
|
||||
c = c.AddDate(0, 0, ev.IntervalCount)
|
||||
}
|
||||
if ev.BusinessDaysOnly {
|
||||
c = shiftToBusinessDay(c, loc)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
|
||||
case model.RecurringFrequencyWeekly:
|
||||
|
|
@ -370,6 +395,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
|||
for !c.After(after) {
|
||||
c = c.AddDate(0, 0, 7*ev.IntervalCount)
|
||||
}
|
||||
if ev.BusinessDaysOnly {
|
||||
c = shiftToBusinessDay(c, loc)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
|
||||
case model.RecurringFrequencyMonthly:
|
||||
|
|
@ -382,6 +410,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
|||
y, m = addMonths(y, m, ev.IntervalCount)
|
||||
c = monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||
}
|
||||
if ev.BusinessDaysOnly {
|
||||
c = shiftToBusinessDay(c, loc)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
|
||||
case model.RecurringFrequencyYearly:
|
||||
|
|
@ -395,6 +426,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
|||
y += ev.IntervalCount
|
||||
c = monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||
}
|
||||
if ev.BusinessDaysOnly {
|
||||
c = shiftToBusinessDay(c, loc)
|
||||
}
|
||||
return c.UTC(), nil
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("unknown frequency: %s", ev.Frequency)
|
||||
|
|
|
|||
|
|
@ -137,10 +137,58 @@ func TestNextFireAfter_TimezoneCrossesUTCBoundary(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_MonthlyBusinessDaysOnlyShiftsWeekendToMonday(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Monthly on day 10 at 09:00, BusinessDaysOnly=true.
|
||||
// April 10 2026 is Friday → fires April 10.
|
||||
// May 10 2026 is Sunday → must shift to Monday May 11.
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyMonthly,
|
||||
IntervalCount: 1,
|
||||
DayOfMonth: intPtr(10),
|
||||
FireHour: 9,
|
||||
FireMinute: 0,
|
||||
BusinessDaysOnly: true,
|
||||
}
|
||||
got, err := nextFireAfter(ev, time.Date(2026, 4, 11, 0, 0, 0, 0, loc), loc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := time.Date(2026, 5, 11, 9, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("monthly shift: got %v want %v", got.In(loc), want)
|
||||
}
|
||||
|
||||
// Cursor advanced to May 11 (Mon); next firing should be June 10 (Wed) — no shift.
|
||||
got2, _ := nextFireAfter(ev, got, loc)
|
||||
want2 := time.Date(2026, 6, 10, 9, 0, 0, 0, loc)
|
||||
if !got2.Equal(want2) {
|
||||
t.Errorf("monthly next after shift: got %v want %v", got2.In(loc), want2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFireAfter_MonthlyBusinessDaysOnlySaturdayShiftsToMonday(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// 2026-08-01 is a Saturday — should shift to Monday Aug 3.
|
||||
ev := &model.RecurringEvent{
|
||||
Frequency: model.RecurringFrequencyMonthly,
|
||||
IntervalCount: 1,
|
||||
DayOfMonth: intPtr(1),
|
||||
FireHour: 9,
|
||||
FireMinute: 0,
|
||||
BusinessDaysOnly: true,
|
||||
}
|
||||
got, _ := nextFireAfter(ev, time.Date(2026, 7, 2, 0, 0, 0, 0, loc), loc)
|
||||
want := time.Date(2026, 8, 3, 9, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got.In(loc), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstFireOnOrAfter_SameDayBeforeFire(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Daily at 09:00, start date 2026-05-10 → first fire 2026-05-10 09:00.
|
||||
got, err := firstFireOnOrAfter(model.RecurringFrequencyDaily, 1, nil, nil, nil, 9, 0, loc, time.Date(2026, 5, 10, 0, 0, 0, 0, loc))
|
||||
got, err := firstFireOnOrAfter(model.RecurringFrequencyDaily, 1, nil, nil, nil, 9, 0, loc, time.Date(2026, 5, 10, 0, 0, 0, 0, loc), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
|
@ -153,7 +201,7 @@ func TestFirstFireOnOrAfter_SameDayBeforeFire(t *testing.T) {
|
|||
func TestFirstFireOnOrAfter_WeeklyShiftsToTargetDayOfWeek(t *testing.T) {
|
||||
loc := mustLoad(t, "UTC")
|
||||
// Start 2026-05-04 (Mon), target weekday Friday (5) → first fire 2026-05-08.
|
||||
got, _ := firstFireOnOrAfter(model.RecurringFrequencyWeekly, 1, intPtr(5), nil, nil, 8, 0, loc, time.Date(2026, 5, 4, 0, 0, 0, 0, loc))
|
||||
got, _ := firstFireOnOrAfter(model.RecurringFrequencyWeekly, 1, intPtr(5), nil, nil, 8, 0, loc, time.Date(2026, 5, 4, 0, 0, 0, 0, loc), false)
|
||||
want := time.Date(2026, 5, 8, 8, 0, 0, 0, loc)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v want %v", got, want)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue