From fb0cfb5a4577d433d50dc1c41059e8b66e2859d5 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 10 May 2026 13:28:06 +0000 Subject: [PATCH] feat: shift recurring event date if lands on weekends --- ...business_days_only_to_recurring_events.sql | 11 +++ internal/handler/recurring_event.go | 38 ++++---- internal/model/financial_management.go | 2 + internal/repository/recurring_event.go | 16 ++-- internal/service/recurring_event.go | 92 +++++++++++++------ internal/service/recurring_event_test.go | 52 ++++++++++- internal/ui/forms/recurring_event.templ | 26 +++++- internal/ui/pages/recurring_event_helpers.go | 20 ++-- 8 files changed, 191 insertions(+), 66 deletions(-) create mode 100644 internal/db/migrations/00017_add_business_days_only_to_recurring_events.sql diff --git a/internal/db/migrations/00017_add_business_days_only_to_recurring_events.sql b/internal/db/migrations/00017_add_business_days_only_to_recurring_events.sql new file mode 100644 index 0000000..1d20b83 --- /dev/null +++ b/internal/db/migrations/00017_add_business_days_only_to_recurring_events.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE recurring_events + ADD COLUMN business_days_only BOOLEAN NOT NULL DEFAULT FALSE; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE recurring_events + DROP COLUMN business_days_only; +-- +goose StatementEnd diff --git a/internal/handler/recurring_event.go b/internal/handler/recurring_event.go index 6d1d94b..6b7c594 100644 --- a/internal/handler/recurring_event.go +++ b/internal/handler/recurring_event.go @@ -88,8 +88,9 @@ func (h *recurringEventHandler) CreatePage(w http.ResponseWriter, r *http.Reques Frequency: string(model.RecurringFrequencyMonthly), IntervalCount: "1", FireTime: "09:00", - Timezone: "UTC", - StartDate: now.Format("2006-01-02"), + Timezone: "UTC", + StartDate: now.Format("2006-01-02"), + BusinessDaysOnly: false, DayOfMonth: strconv.Itoa(now.Day()), DayOfWeek: strconv.Itoa(int(now.Weekday())), MonthOfYear: strconv.Itoa(int(now.Month())), @@ -138,8 +139,9 @@ func (h *recurringEventHandler) EditPage(w http.ResponseWriter, r *http.Request) Frequency: string(ev.Frequency), IntervalCount: strconv.Itoa(ev.IntervalCount), FireTime: formatTimeOfDay(ev.FireHour, ev.FireMinute), - Timezone: ev.Timezone, - StartDate: ev.NextRunAt.In(mustLoc(ev.Timezone)).Format("2006-01-02"), + Timezone: ev.Timezone, + StartDate: ev.NextRunAt.In(mustLoc(ev.Timezone)).Format("2006-01-02"), + BusinessDaysOnly: ev.BusinessDaysOnly, } if ev.Description != nil { formProps.Description = *ev.Description @@ -226,8 +228,9 @@ func (h *recurringEventHandler) HandleEdit(w http.ResponseWriter, r *http.Reques MonthOfYear: parsed.MonthOfYear, FireHour: parsed.FireHour, FireMinute: parsed.FireMinute, - Timezone: parsed.Timezone, - StartDate: parsed.StartDate, + Timezone: parsed.Timezone, + BusinessDaysOnly: parsed.BusinessDaysOnly, + StartDate: parsed.StartDate, }); err != nil { slog.Error("failed to update recurring event", "error", err, "event_id", eventID) formProps.GeneralErr = friendlyRecurringError(err) @@ -299,6 +302,7 @@ func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (serv fireTime := strings.TrimSpace(r.FormValue("fire_time")) tz := strings.TrimSpace(r.FormValue("timezone")) startDateStr := strings.TrimSpace(r.FormValue("start_date")) + businessDaysOnly := r.FormValue("business_days_only") != "" props := forms.RecurringEventFormProps{ SpaceID: spaceID, @@ -314,19 +318,21 @@ func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (serv DayOfWeek: dowStr, DayOfMonth: domStr, MonthOfYear: moyStr, - FireTime: fireTime, - Timezone: tz, - StartDate: startDateStr, + FireTime: fireTime, + Timezone: tz, + StartDate: startDateStr, + BusinessDaysOnly: businessDaysOnly, } input := service.CreateRecurringEventInput{ - SpaceID: spaceID, - Kind: model.RecurringEventKind(kind), - SourceAccountID: sourceID, - Title: title, - Description: descriptionStr, - Frequency: model.RecurringFrequency(frequency), - Timezone: tz, + SpaceID: spaceID, + Kind: model.RecurringEventKind(kind), + SourceAccountID: sourceID, + Title: title, + Description: descriptionStr, + Frequency: model.RecurringFrequency(frequency), + Timezone: tz, + BusinessDaysOnly: businessDaysOnly, } if title == "" { diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go index eb2f0a6..9647326 100644 --- a/internal/model/financial_management.go +++ b/internal/model/financial_management.go @@ -88,6 +88,8 @@ type RecurringEvent struct { FireMinute int `db:"fire_minute"` Timezone string `db:"timezone"` + BusinessDaysOnly bool `db:"business_days_only"` + NextRunAt time.Time `db:"next_run_at"` LastRunAt *time.Time `db:"last_run_at"` Paused bool `db:"paused"` diff --git a/internal/repository/recurring_event.go b/internal/repository/recurring_event.go index a4eeef9..fdd1cae 100644 --- a/internal/repository/recurring_event.go +++ b/internal/repository/recurring_event.go @@ -35,18 +35,18 @@ func (r *recurringEventRepository) Create(e *model.RecurringEvent) error { query := `INSERT INTO recurring_events ( id, space_id, kind, source_account_id, title, amount, description, frequency, interval_count, day_of_week, day_of_month, month_of_year, - fire_hour, fire_minute, timezone, + fire_hour, fire_minute, timezone, business_days_only, next_run_at, last_run_at, paused, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, - $13, $14, $15, - $16, $17, $18, $19, $20 + $13, $14, $15, $16, + $17, $18, $19, $20, $21 );` _, err := r.db.Exec(query, e.ID, e.SpaceID, e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description, e.Frequency, e.IntervalCount, e.DayOfWeek, e.DayOfMonth, e.MonthOfYear, - e.FireHour, e.FireMinute, e.Timezone, + e.FireHour, e.FireMinute, e.Timezone, e.BusinessDaysOnly, e.NextRunAt, e.LastRunAt, e.Paused, e.CreatedAt, e.UpdatedAt, ) return err @@ -89,13 +89,13 @@ func (r *recurringEventRepository) Update(e *model.RecurringEvent) error { query := `UPDATE recurring_events SET kind = $1, source_account_id = $2, title = $3, amount = $4, description = $5, frequency = $6, interval_count = $7, day_of_week = $8, day_of_month = $9, month_of_year = $10, - fire_hour = $11, fire_minute = $12, timezone = $13, - next_run_at = $14, paused = $15, updated_at = CURRENT_TIMESTAMP - WHERE id = $16;` + fire_hour = $11, fire_minute = $12, timezone = $13, business_days_only = $14, + next_run_at = $15, paused = $16, updated_at = CURRENT_TIMESTAMP + WHERE id = $17;` res, err := r.db.Exec(query, e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description, e.Frequency, e.IntervalCount, e.DayOfWeek, e.DayOfMonth, e.MonthOfYear, - e.FireHour, e.FireMinute, e.Timezone, + e.FireHour, e.FireMinute, e.Timezone, e.BusinessDaysOnly, e.NextRunAt, e.Paused, e.ID, ) if err != nil { diff --git a/internal/service/recurring_event.go b/internal/service/recurring_event.go index 06d49ef..a9e8b65 100644 --- a/internal/service/recurring_event.go +++ b/internal/service/recurring_event.go @@ -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) diff --git a/internal/service/recurring_event_test.go b/internal/service/recurring_event_test.go index 3ae5e77..7e14b50 100644 --- a/internal/service/recurring_event_test.go +++ b/internal/service/recurring_event_test.go @@ -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) diff --git a/internal/ui/forms/recurring_event.templ b/internal/ui/forms/recurring_event.templ index 35191d9..dd37e6a 100644 --- a/internal/ui/forms/recurring_event.templ +++ b/internal/ui/forms/recurring_event.templ @@ -4,6 +4,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/misc/timezone" import "git.juancwu.dev/juancwu/budgit/internal/model" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea" @@ -27,9 +28,10 @@ type RecurringEventFormProps struct { DayOfWeek string DayOfMonth string MonthOfYear string - FireTime string - Timezone string - StartDate string + FireTime string + Timezone string + StartDate string + BusinessDaysOnly bool TitleErr string KindErr string @@ -307,6 +309,24 @@ templ RecurringEventForm(props RecurringEventFormProps) { } } + @form.Item() { +
+ @checkbox.Checkbox(checkbox.Props{ + ID: "business_days_only", + Name: "business_days_only", + Value: "1", + Checked: props.BusinessDaysOnly, + }) +
+ + @form.Description() { + If a firing lands on Saturday or Sunday, push it to the following Monday. + } +
+
+ } @form.Item() { @form.Label(form.LabelProps{For: "description"}) { Description diff --git a/internal/ui/pages/recurring_event_helpers.go b/internal/ui/pages/recurring_event_helpers.go index b5266cc..621d2da 100644 --- a/internal/ui/pages/recurring_event_helpers.go +++ b/internal/ui/pages/recurring_event_helpers.go @@ -19,39 +19,43 @@ var weekdayLabels = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursd func recurrenceSummary(ev *model.RecurringEvent) string { timePart := fmt.Sprintf(" at %02d:%02d", ev.FireHour, ev.FireMinute) + suffix := "" + if ev.BusinessDaysOnly { + suffix = " (skips weekends)" + } switch ev.Frequency { case model.RecurringFrequencyDaily: if ev.IntervalCount == 1 { - return "Daily" + timePart + return "Daily" + timePart + suffix } - return fmt.Sprintf("Every %d days%s", ev.IntervalCount, timePart) + return fmt.Sprintf("Every %d days%s", ev.IntervalCount, timePart) + suffix case model.RecurringFrequencyWeekly: dow := "" if ev.DayOfWeek != nil && *ev.DayOfWeek >= 0 && *ev.DayOfWeek < len(weekdayLabels) { dow = " on " + weekdayLabels[*ev.DayOfWeek] } if ev.IntervalCount == 1 { - return "Weekly" + dow + timePart + return "Weekly" + dow + timePart + suffix } - return fmt.Sprintf("Every %d weeks%s%s", ev.IntervalCount, dow, timePart) + return fmt.Sprintf("Every %d weeks%s%s", ev.IntervalCount, dow, timePart) + suffix case model.RecurringFrequencyMonthly: dom := "" if ev.DayOfMonth != nil { dom = fmt.Sprintf(" on day %d", *ev.DayOfMonth) } if ev.IntervalCount == 1 { - return "Monthly" + dom + timePart + return "Monthly" + dom + timePart + suffix } - return fmt.Sprintf("Every %d months%s%s", ev.IntervalCount, dom, timePart) + return fmt.Sprintf("Every %d months%s%s", ev.IntervalCount, dom, timePart) + suffix case model.RecurringFrequencyYearly: date := "" if ev.MonthOfYear != nil && ev.DayOfMonth != nil { date = fmt.Sprintf(" on %s %d", time.Month(*ev.MonthOfYear).String(), *ev.DayOfMonth) } if ev.IntervalCount == 1 { - return "Yearly" + date + timePart + return "Yearly" + date + timePart + suffix } - return fmt.Sprintf("Every %d years%s%s", ev.IntervalCount, date, timePart) + return fmt.Sprintf("Every %d years%s%s", ev.IntervalCount, date, timePart) + suffix } return string(ev.Frequency) }