feat: shift recurring event date if lands on weekends

This commit is contained in:
juancwu 2026-05-10 13:28:06 +00:00
commit fb0cfb5a45
8 changed files with 191 additions and 66 deletions

View file

@ -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

View file

@ -88,8 +88,9 @@ func (h *recurringEventHandler) CreatePage(w http.ResponseWriter, r *http.Reques
Frequency: string(model.RecurringFrequencyMonthly), Frequency: string(model.RecurringFrequencyMonthly),
IntervalCount: "1", IntervalCount: "1",
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())),
@ -138,8 +139,9 @@ func (h *recurringEventHandler) EditPage(w http.ResponseWriter, r *http.Request)
Frequency: string(ev.Frequency), Frequency: string(ev.Frequency),
IntervalCount: strconv.Itoa(ev.IntervalCount), IntervalCount: strconv.Itoa(ev.IntervalCount),
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
@ -226,8 +228,9 @@ func (h *recurringEventHandler) HandleEdit(w http.ResponseWriter, r *http.Reques
MonthOfYear: parsed.MonthOfYear, MonthOfYear: parsed.MonthOfYear,
FireHour: parsed.FireHour, FireHour: parsed.FireHour,
FireMinute: parsed.FireMinute, FireMinute: parsed.FireMinute,
Timezone: parsed.Timezone, Timezone: parsed.Timezone,
StartDate: parsed.StartDate, BusinessDaysOnly: parsed.BusinessDaysOnly,
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)
formProps.GeneralErr = friendlyRecurringError(err) 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")) 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,
@ -314,19 +318,21 @@ func (h *recurringEventHandler) parseForm(r *http.Request, spaceID string) (serv
DayOfWeek: dowStr, DayOfWeek: dowStr,
DayOfMonth: domStr, DayOfMonth: domStr,
MonthOfYear: moyStr, MonthOfYear: moyStr,
FireTime: fireTime, FireTime: fireTime,
Timezone: tz, Timezone: tz,
StartDate: startDateStr, StartDate: startDateStr,
BusinessDaysOnly: businessDaysOnly,
} }
input := service.CreateRecurringEventInput{ input := service.CreateRecurringEventInput{
SpaceID: spaceID, SpaceID: spaceID,
Kind: model.RecurringEventKind(kind), Kind: model.RecurringEventKind(kind),
SourceAccountID: sourceID, SourceAccountID: sourceID,
Title: title, Title: title,
Description: descriptionStr, Description: descriptionStr,
Frequency: model.RecurringFrequency(frequency), Frequency: model.RecurringFrequency(frequency),
Timezone: tz, Timezone: tz,
BusinessDaysOnly: businessDaysOnly,
} }
if title == "" { if title == "" {

View file

@ -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"`

View file

@ -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 {

View file

@ -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
} }
@ -90,25 +94,26 @@ func (s *RecurringEventService) Create(input CreateRecurringEventInput) (*model.
now := time.Now().UTC() now := time.Now().UTC()
ev := &model.RecurringEvent{ ev := &model.RecurringEvent{
ID: uuid.NewString(), ID: uuid.NewString(),
SpaceID: input.SpaceID, SpaceID: input.SpaceID,
Kind: input.Kind, Kind: input.Kind,
SourceAccountID: input.SourceAccountID, SourceAccountID: input.SourceAccountID,
Title: title, Title: title,
Amount: input.Amount, Amount: input.Amount,
Description: description, Description: description,
Frequency: input.Frequency, Frequency: input.Frequency,
IntervalCount: input.IntervalCount, IntervalCount: input.IntervalCount,
DayOfWeek: input.DayOfWeek, DayOfWeek: input.DayOfWeek,
DayOfMonth: input.DayOfMonth, DayOfMonth: input.DayOfMonth,
MonthOfYear: input.MonthOfYear, MonthOfYear: input.MonthOfYear,
FireHour: input.FireHour, FireHour: input.FireHour,
FireMinute: input.FireMinute, FireMinute: input.FireMinute,
Timezone: input.Timezone, Timezone: input.Timezone,
NextRunAt: firstFire.UTC(), BusinessDaysOnly: input.BusinessDaysOnly,
Paused: false, NextRunAt: firstFire.UTC(),
CreatedAt: now, Paused: false,
UpdatedAt: now, CreatedAt: now,
UpdatedAt: now,
} }
if err := s.repo.Create(ev); err != nil { if err := s.repo.Create(ev); err != nil {
@ -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,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 // 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{
Frequency: freq, Frequency: freq,
IntervalCount: interval, IntervalCount: interval,
DayOfWeek: dow, DayOfWeek: dow,
DayOfMonth: dom, DayOfMonth: dom,
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)

View file

@ -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)

View file

@ -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"
@ -27,9 +28,10 @@ type RecurringEventFormProps struct {
DayOfWeek string DayOfWeek string
DayOfMonth string DayOfMonth string
MonthOfYear string MonthOfYear string
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

View file

@ -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)
} }