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
|
|
@ -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
|
||||||
|
|
@ -90,6 +90,7 @@ func (h *recurringEventHandler) CreatePage(w http.ResponseWriter, r *http.Reques
|
||||||
FireTime: "09:00",
|
FireTime: "09:00",
|
||||||
Timezone: "UTC",
|
Timezone: "UTC",
|
||||||
StartDate: now.Format("2006-01-02"),
|
StartDate: now.Format("2006-01-02"),
|
||||||
|
BusinessDaysOnly: false,
|
||||||
DayOfMonth: strconv.Itoa(now.Day()),
|
DayOfMonth: strconv.Itoa(now.Day()),
|
||||||
DayOfWeek: strconv.Itoa(int(now.Weekday())),
|
DayOfWeek: strconv.Itoa(int(now.Weekday())),
|
||||||
MonthOfYear: strconv.Itoa(int(now.Month())),
|
MonthOfYear: strconv.Itoa(int(now.Month())),
|
||||||
|
|
@ -140,6 +141,7 @@ func (h *recurringEventHandler) EditPage(w http.ResponseWriter, r *http.Request)
|
||||||
FireTime: formatTimeOfDay(ev.FireHour, ev.FireMinute),
|
FireTime: formatTimeOfDay(ev.FireHour, ev.FireMinute),
|
||||||
Timezone: ev.Timezone,
|
Timezone: ev.Timezone,
|
||||||
StartDate: ev.NextRunAt.In(mustLoc(ev.Timezone)).Format("2006-01-02"),
|
StartDate: ev.NextRunAt.In(mustLoc(ev.Timezone)).Format("2006-01-02"),
|
||||||
|
BusinessDaysOnly: ev.BusinessDaysOnly,
|
||||||
}
|
}
|
||||||
if ev.Description != nil {
|
if ev.Description != nil {
|
||||||
formProps.Description = *ev.Description
|
formProps.Description = *ev.Description
|
||||||
|
|
@ -227,6 +229,7 @@ func (h *recurringEventHandler) HandleEdit(w http.ResponseWriter, r *http.Reques
|
||||||
FireHour: parsed.FireHour,
|
FireHour: parsed.FireHour,
|
||||||
FireMinute: parsed.FireMinute,
|
FireMinute: parsed.FireMinute,
|
||||||
Timezone: parsed.Timezone,
|
Timezone: parsed.Timezone,
|
||||||
|
BusinessDaysOnly: parsed.BusinessDaysOnly,
|
||||||
StartDate: parsed.StartDate,
|
StartDate: parsed.StartDate,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Error("failed to update recurring event", "error", err, "event_id", eventID)
|
slog.Error("failed to update recurring event", "error", err, "event_id", eventID)
|
||||||
|
|
@ -299,6 +302,7 @@ func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (serv
|
||||||
fireTime := strings.TrimSpace(r.FormValue("fire_time"))
|
fireTime := strings.TrimSpace(r.FormValue("fire_time"))
|
||||||
tz := strings.TrimSpace(r.FormValue("timezone"))
|
tz := strings.TrimSpace(r.FormValue("timezone"))
|
||||||
startDateStr := strings.TrimSpace(r.FormValue("start_date"))
|
startDateStr := strings.TrimSpace(r.FormValue("start_date"))
|
||||||
|
businessDaysOnly := r.FormValue("business_days_only") != ""
|
||||||
|
|
||||||
props := forms.RecurringEventFormProps{
|
props := forms.RecurringEventFormProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
|
|
@ -317,6 +321,7 @@ func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (serv
|
||||||
FireTime: fireTime,
|
FireTime: fireTime,
|
||||||
Timezone: tz,
|
Timezone: tz,
|
||||||
StartDate: startDateStr,
|
StartDate: startDateStr,
|
||||||
|
BusinessDaysOnly: businessDaysOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
input := service.CreateRecurringEventInput{
|
input := service.CreateRecurringEventInput{
|
||||||
|
|
@ -327,6 +332,7 @@ func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (serv
|
||||||
Description: descriptionStr,
|
Description: descriptionStr,
|
||||||
Frequency: model.RecurringFrequency(frequency),
|
Frequency: model.RecurringFrequency(frequency),
|
||||||
Timezone: tz,
|
Timezone: tz,
|
||||||
|
BusinessDaysOnly: businessDaysOnly,
|
||||||
}
|
}
|
||||||
|
|
||||||
if title == "" {
|
if title == "" {
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,8 @@ type RecurringEvent struct {
|
||||||
FireMinute int `db:"fire_minute"`
|
FireMinute int `db:"fire_minute"`
|
||||||
Timezone string `db:"timezone"`
|
Timezone string `db:"timezone"`
|
||||||
|
|
||||||
|
BusinessDaysOnly bool `db:"business_days_only"`
|
||||||
|
|
||||||
NextRunAt time.Time `db:"next_run_at"`
|
NextRunAt time.Time `db:"next_run_at"`
|
||||||
LastRunAt *time.Time `db:"last_run_at"`
|
LastRunAt *time.Time `db:"last_run_at"`
|
||||||
Paused bool `db:"paused"`
|
Paused bool `db:"paused"`
|
||||||
|
|
|
||||||
|
|
@ -35,18 +35,18 @@ func (r *recurringEventRepository) Create(e *model.RecurringEvent) error {
|
||||||
query := `INSERT INTO recurring_events (
|
query := `INSERT INTO recurring_events (
|
||||||
id, space_id, kind, source_account_id, title, amount, description,
|
id, space_id, kind, source_account_id, title, amount, description,
|
||||||
frequency, interval_count, day_of_week, day_of_month, month_of_year,
|
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
|
next_run_at, last_run_at, paused, created_at, updated_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7,
|
$1, $2, $3, $4, $5, $6, $7,
|
||||||
$8, $9, $10, $11, $12,
|
$8, $9, $10, $11, $12,
|
||||||
$13, $14, $15,
|
$13, $14, $15, $16,
|
||||||
$16, $17, $18, $19, $20
|
$17, $18, $19, $20, $21
|
||||||
);`
|
);`
|
||||||
_, err := r.db.Exec(query,
|
_, err := r.db.Exec(query,
|
||||||
e.ID, e.SpaceID, e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description,
|
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.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,
|
e.NextRunAt, e.LastRunAt, e.Paused, e.CreatedAt, e.UpdatedAt,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|
@ -89,13 +89,13 @@ func (r *recurringEventRepository) Update(e *model.RecurringEvent) error {
|
||||||
query := `UPDATE recurring_events SET
|
query := `UPDATE recurring_events SET
|
||||||
kind = $1, source_account_id = $2, title = $3, amount = $4, description = $5,
|
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,
|
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,
|
fire_hour = $11, fire_minute = $12, timezone = $13, business_days_only = $14,
|
||||||
next_run_at = $14, paused = $15, updated_at = CURRENT_TIMESTAMP
|
next_run_at = $15, paused = $16, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $16;`
|
WHERE id = $17;`
|
||||||
res, err := r.db.Exec(query,
|
res, err := r.db.Exec(query,
|
||||||
e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description,
|
e.Kind, e.SourceAccountID, e.Title, e.Amount, e.Description,
|
||||||
e.Frequency, e.IntervalCount, e.DayOfWeek, e.DayOfMonth, e.MonthOfYear,
|
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,
|
e.NextRunAt, e.Paused, e.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ type CreateRecurringEventInput struct {
|
||||||
FireMinute int
|
FireMinute int
|
||||||
Timezone string
|
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
|
// 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,
|
// in the event's timezone. The first NextRunAt is computed from StartDate,
|
||||||
// FireHour, FireMinute, and Timezone — clamped to the recurrence anchors.
|
// 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")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -105,6 +109,7 @@ func (s *RecurringEventService) Create(input CreateRecurringEventInput) (*model.
|
||||||
FireHour: input.FireHour,
|
FireHour: input.FireHour,
|
||||||
FireMinute: input.FireMinute,
|
FireMinute: input.FireMinute,
|
||||||
Timezone: input.Timezone,
|
Timezone: input.Timezone,
|
||||||
|
BusinessDaysOnly: input.BusinessDaysOnly,
|
||||||
NextRunAt: firstFire.UTC(),
|
NextRunAt: firstFire.UTC(),
|
||||||
Paused: false,
|
Paused: false,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
|
|
@ -134,6 +139,8 @@ type UpdateRecurringEventInput struct {
|
||||||
FireMinute int
|
FireMinute int
|
||||||
Timezone string
|
Timezone string
|
||||||
|
|
||||||
|
BusinessDaysOnly bool
|
||||||
|
|
||||||
// StartDate, if non-zero, recomputes the next firing. If zero, the current
|
// StartDate, if non-zero, recomputes the next firing. If zero, the current
|
||||||
// cursor is kept (useful for purely cosmetic edits like renaming).
|
// cursor is kept (useful for purely cosmetic edits like renaming).
|
||||||
StartDate time.Time
|
StartDate time.Time
|
||||||
|
|
@ -163,7 +170,7 @@ func (s *RecurringEventService) Update(input UpdateRecurringEventInput) (*model.
|
||||||
|
|
||||||
nextRun := existing.NextRunAt
|
nextRun := existing.NextRunAt
|
||||||
if !input.StartDate.IsZero() {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -188,6 +195,7 @@ func (s *RecurringEventService) Update(input UpdateRecurringEventInput) (*model.
|
||||||
existing.FireHour = input.FireHour
|
existing.FireHour = input.FireHour
|
||||||
existing.FireMinute = input.FireMinute
|
existing.FireMinute = input.FireMinute
|
||||||
existing.Timezone = input.Timezone
|
existing.Timezone = input.Timezone
|
||||||
|
existing.BusinessDaysOnly = input.BusinessDaysOnly
|
||||||
existing.NextRunAt = nextRun
|
existing.NextRunAt = nextRun
|
||||||
|
|
||||||
if err := s.repo.Update(existing); err != nil {
|
if err := s.repo.Update(existing); err != nil {
|
||||||
|
|
@ -334,7 +342,7 @@ func validateRule(kind model.RecurringEventKind, src string, freq model.Recurrin
|
||||||
|
|
||||||
// firstFireOnOrAfter computes the first firing in `loc` at or after the local
|
// firstFireOnOrAfter computes the first firing in `loc` at or after the local
|
||||||
// midnight of startDate, snapped to the recurrence anchors and time-of-day.
|
// 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)
|
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
|
threshold := startLocal.Add(-time.Nanosecond) // nextFireAfter computes strictly-after; -1ns lets the first candidate land on startDate
|
||||||
ev := &model.RecurringEvent{
|
ev := &model.RecurringEvent{
|
||||||
|
|
@ -345,10 +353,24 @@ func firstFireOnOrAfter(freq model.RecurringFrequency, interval int, dow, dom, m
|
||||||
MonthOfYear: moy,
|
MonthOfYear: moy,
|
||||||
FireHour: hour,
|
FireHour: hour,
|
||||||
FireMinute: minute,
|
FireMinute: minute,
|
||||||
|
BusinessDaysOnly: businessDaysOnly,
|
||||||
}
|
}
|
||||||
return nextFireAfter(ev, threshold, loc)
|
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.
|
// nextFireAfter computes the next firing strictly after `after`, in UTC.
|
||||||
func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location) (time.Time, error) {
|
func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location) (time.Time, error) {
|
||||||
afterLocal := after.In(loc)
|
afterLocal := after.In(loc)
|
||||||
|
|
@ -358,6 +380,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
||||||
for !c.After(after) {
|
for !c.After(after) {
|
||||||
c = c.AddDate(0, 0, ev.IntervalCount)
|
c = c.AddDate(0, 0, ev.IntervalCount)
|
||||||
}
|
}
|
||||||
|
if ev.BusinessDaysOnly {
|
||||||
|
c = shiftToBusinessDay(c, loc)
|
||||||
|
}
|
||||||
return c.UTC(), nil
|
return c.UTC(), nil
|
||||||
|
|
||||||
case model.RecurringFrequencyWeekly:
|
case model.RecurringFrequencyWeekly:
|
||||||
|
|
@ -370,6 +395,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
||||||
for !c.After(after) {
|
for !c.After(after) {
|
||||||
c = c.AddDate(0, 0, 7*ev.IntervalCount)
|
c = c.AddDate(0, 0, 7*ev.IntervalCount)
|
||||||
}
|
}
|
||||||
|
if ev.BusinessDaysOnly {
|
||||||
|
c = shiftToBusinessDay(c, loc)
|
||||||
|
}
|
||||||
return c.UTC(), nil
|
return c.UTC(), nil
|
||||||
|
|
||||||
case model.RecurringFrequencyMonthly:
|
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)
|
y, m = addMonths(y, m, ev.IntervalCount)
|
||||||
c = monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
c = monthlyCandidate(y, m, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||||
}
|
}
|
||||||
|
if ev.BusinessDaysOnly {
|
||||||
|
c = shiftToBusinessDay(c, loc)
|
||||||
|
}
|
||||||
return c.UTC(), nil
|
return c.UTC(), nil
|
||||||
|
|
||||||
case model.RecurringFrequencyYearly:
|
case model.RecurringFrequencyYearly:
|
||||||
|
|
@ -395,6 +426,9 @@ func nextFireAfter(ev *model.RecurringEvent, after time.Time, loc *time.Location
|
||||||
y += ev.IntervalCount
|
y += ev.IntervalCount
|
||||||
c = monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
c = monthlyCandidate(y, moy, *ev.DayOfMonth, ev.FireHour, ev.FireMinute, loc)
|
||||||
}
|
}
|
||||||
|
if ev.BusinessDaysOnly {
|
||||||
|
c = shiftToBusinessDay(c, loc)
|
||||||
|
}
|
||||||
return c.UTC(), nil
|
return c.UTC(), nil
|
||||||
}
|
}
|
||||||
return time.Time{}, fmt.Errorf("unknown frequency: %s", ev.Frequency)
|
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) {
|
func TestFirstFireOnOrAfter_SameDayBeforeFire(t *testing.T) {
|
||||||
loc := mustLoad(t, "UTC")
|
loc := mustLoad(t, "UTC")
|
||||||
// Daily at 09:00, start date 2026-05-10 → first fire 2026-05-10 09:00.
|
// 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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +201,7 @@ func TestFirstFireOnOrAfter_SameDayBeforeFire(t *testing.T) {
|
||||||
func TestFirstFireOnOrAfter_WeeklyShiftsToTargetDayOfWeek(t *testing.T) {
|
func TestFirstFireOnOrAfter_WeeklyShiftsToTargetDayOfWeek(t *testing.T) {
|
||||||
loc := mustLoad(t, "UTC")
|
loc := mustLoad(t, "UTC")
|
||||||
// Start 2026-05-04 (Mon), target weekday Friday (5) → first fire 2026-05-08.
|
// 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)
|
want := time.Date(2026, 5, 8, 8, 0, 0, 0, loc)
|
||||||
if !got.Equal(want) {
|
if !got.Equal(want) {
|
||||||
t.Errorf("got %v want %v", got, want)
|
t.Errorf("got %v want %v", got, want)
|
||||||
|
|
|
||||||
|
|
@ -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/model"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
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/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/form"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea"
|
||||||
|
|
@ -30,6 +31,7 @@ type RecurringEventFormProps struct {
|
||||||
FireTime string
|
FireTime string
|
||||||
Timezone string
|
Timezone string
|
||||||
StartDate string
|
StartDate string
|
||||||
|
BusinessDaysOnly bool
|
||||||
|
|
||||||
TitleErr string
|
TitleErr string
|
||||||
KindErr string
|
KindErr string
|
||||||
|
|
@ -307,6 +309,24 @@ templ RecurringEventForm(props RecurringEventFormProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@form.Item() {
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
@checkbox.Checkbox(checkbox.Props{
|
||||||
|
ID: "business_days_only",
|
||||||
|
Name: "business_days_only",
|
||||||
|
Value: "1",
|
||||||
|
Checked: props.BusinessDaysOnly,
|
||||||
|
})
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label for="business_days_only" class="text-sm font-medium leading-none cursor-pointer">
|
||||||
|
Skip non-business days
|
||||||
|
</label>
|
||||||
|
@form.Description() {
|
||||||
|
If a firing lands on Saturday or Sunday, push it to the following Monday.
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@form.Item() {
|
@form.Item() {
|
||||||
@form.Label(form.LabelProps{For: "description"}) {
|
@form.Label(form.LabelProps{For: "description"}) {
|
||||||
Description
|
Description
|
||||||
|
|
|
||||||
|
|
@ -19,39 +19,43 @@ var weekdayLabels = []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursd
|
||||||
|
|
||||||
func recurrenceSummary(ev *model.RecurringEvent) string {
|
func recurrenceSummary(ev *model.RecurringEvent) string {
|
||||||
timePart := fmt.Sprintf(" at %02d:%02d", ev.FireHour, ev.FireMinute)
|
timePart := fmt.Sprintf(" at %02d:%02d", ev.FireHour, ev.FireMinute)
|
||||||
|
suffix := ""
|
||||||
|
if ev.BusinessDaysOnly {
|
||||||
|
suffix = " (skips weekends)"
|
||||||
|
}
|
||||||
switch ev.Frequency {
|
switch ev.Frequency {
|
||||||
case model.RecurringFrequencyDaily:
|
case model.RecurringFrequencyDaily:
|
||||||
if ev.IntervalCount == 1 {
|
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:
|
case model.RecurringFrequencyWeekly:
|
||||||
dow := ""
|
dow := ""
|
||||||
if ev.DayOfWeek != nil && *ev.DayOfWeek >= 0 && *ev.DayOfWeek < len(weekdayLabels) {
|
if ev.DayOfWeek != nil && *ev.DayOfWeek >= 0 && *ev.DayOfWeek < len(weekdayLabels) {
|
||||||
dow = " on " + weekdayLabels[*ev.DayOfWeek]
|
dow = " on " + weekdayLabels[*ev.DayOfWeek]
|
||||||
}
|
}
|
||||||
if ev.IntervalCount == 1 {
|
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:
|
case model.RecurringFrequencyMonthly:
|
||||||
dom := ""
|
dom := ""
|
||||||
if ev.DayOfMonth != nil {
|
if ev.DayOfMonth != nil {
|
||||||
dom = fmt.Sprintf(" on day %d", *ev.DayOfMonth)
|
dom = fmt.Sprintf(" on day %d", *ev.DayOfMonth)
|
||||||
}
|
}
|
||||||
if ev.IntervalCount == 1 {
|
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:
|
case model.RecurringFrequencyYearly:
|
||||||
date := ""
|
date := ""
|
||||||
if ev.MonthOfYear != nil && ev.DayOfMonth != nil {
|
if ev.MonthOfYear != nil && ev.DayOfMonth != nil {
|
||||||
date = fmt.Sprintf(" on %s %d", time.Month(*ev.MonthOfYear).String(), *ev.DayOfMonth)
|
date = fmt.Sprintf(" on %s %d", time.Month(*ev.MonthOfYear).String(), *ev.DayOfMonth)
|
||||||
}
|
}
|
||||||
if ev.IntervalCount == 1 {
|
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)
|
return string(ev.Frequency)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue