f: feat(calendar): calculate fixed and floating yearl and monthly events

This commit is contained in:
AJ ONeal 2025-11-09 01:16:09 -07:00
parent a71840581e
commit ef7c9fb209
No known key found for this signature in database

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -135,44 +136,49 @@ func parseWeekday(s string) (time.Weekday, bool) {
// ---------- 3. Next occurrence ---------- // ---------- 3. Next occurrence ----------
func (r Rule) NextAfter(after time.Time, cal calendar.MultiYearCalendar) (time.Time, error) { func (r Rule) NextAfter(after time.Time, cal calendar.MultiYearCalendar) (time.Time, error) {
// Start searching from the month *after* the reference date. candidate, ok := r.Next(after)
y, _, _ := after.AddDate(0, 0, 1).Date() if !ok {
startYear := y return time.Time{}, fmt.Errorf("no occurrence for rule")
endYear := startYear + 2 // give us enough room for “last-X” rules
for year := startYear; year <= endYear; year++ {
for month := time.January; month <= time.December; month++ {
candidate, ok := r.candidateInMonth(year, month)
if !ok {
continue
}
// Convert to the rules local zone and attach the time-of-day.
_, offset := candidate.In(r.Location).Zone()
candidate = time.Date(candidate.Year(), candidate.Month(), candidate.Day(),
0, 0, 0, 0, r.Location).Add(time.Duration(offset) * time.Second)
hour, min, err := parseHourMin(r.TimeOfDay)
if err != nil {
return time.Time{}, err
}
candidate = time.Date(candidate.Year(), candidate.Month(), candidate.Day(),
hour, min, 0, 0, r.Location)
if candidate.After(after) && cal.IsBusinessDay(candidate) {
return candidate, nil
}
}
} }
// Convert to the rules local zone and attach the time-of-day.
candidate = time.Date(candidate.Year(), candidate.Month(), candidate.Day(),
0, 0, 0, 0, r.Location)
hour, min, err := parseHourMin(r.TimeOfDay)
if err != nil {
return time.Time{}, err
}
candidate = time.Date(candidate.Year(), candidate.Month(), candidate.Day(),
hour, min, 0, 0, r.Location)
if candidate.After(after) {
return candidate, nil
}
return time.Time{}, fmt.Errorf("no occurrence found for %s after %s", r.Event, after) return time.Time{}, fmt.Errorf("no occurrence found for %s after %s", r.Event, after)
} }
// candidateInMonth returns the *date* (midnight UTC) for the rule in the given year/month. // Next returns the *date* (midnight UTC) for the rule in the given year/month.
// It respects the same semantics as GetNthWeekday. // It respects the same semantics as GetNthWeekday.
func (r Rule) candidateInMonth(year int, month time.Month) (time.Time, bool) { func (r Rule) Next(start time.Time) (time.Time, bool) {
if r.Nth != 0 { if r.Nth != 0 {
// Floating weekday rule // Floating weekday rule
t, ok := calendar.GetNthWeekday(year, month, r.Weekday, r.Nth) startYear := start.Year()
return t, ok endYear := startYear + 5 // up through leap year
startMonth := start.Month()
for year := startYear; year <= endYear; year++ {
for month := startMonth; month <= 12; month++ {
t, ok := calendar.GetNthWeekday(year, month, r.Weekday, r.Nth)
if ok {
if t.After(start) {
return t, ok
}
}
}
startMonth = 1
}
return time.Time{}, false
} }
// Fixed day of month // Fixed day of month
@ -183,7 +189,7 @@ func (r Rule) candidateInMonth(year int, month time.Month) (time.Time, bool) {
// time.Date will clamp invalid days (e.g. 31st of February → March 3rd) // time.Date will clamp invalid days (e.g. 31st of February → March 3rd)
// we simply reject months that cannot contain the day. // we simply reject months that cannot contain the day.
// the 0th day of the next month is the last day of the previous month // the 0th day of the next month is the last day of the previous month
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day() lastDay := time.Date(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, r.Location).Day()
if r.FixedDay > lastDay { if r.FixedDay > lastDay {
return time.Time{}, false return time.Time{}, false
} }
@ -199,7 +205,21 @@ func (r Rule) candidateInMonth(year int, month time.Month) (time.Time, bool) {
} }
} }
return time.Date(year, month, fixedDay, 0, 0, 0, 0, time.UTC), true // fmt.Fprintf(os.Stderr, "DEBUG event %s: %d-%d-%d\n", r.Event, start.Year(), start.Month(), fixedDay)
var t time.Time
startYear := start.Year()
endYear := startYear + 5 // up through leap year
startMonth := start.Month()
for year := startYear; year <= endYear; year++ {
for month := startMonth; month <= 12; month++ {
t = time.Date(year, month, fixedDay, 0, 0, 0, 0, r.Location)
// fmt.Fprintf(os.Stderr, "DEBUG candidate event time %s\n", t.Format(time.RFC3339))
if t.After(start) {
return t, true
}
}
}
return time.Time{}, false
} }
func parseHourMin(s string) (hour, min int, err error) { func parseHourMin(s string) (hour, min int, err error) {
@ -207,6 +227,11 @@ func parseHourMin(s string) (hour, min int, err error) {
return return
} }
type Event struct {
Rule Rule
Time time.Time
}
// ---------- 4. Example usage ---------- // ---------- 4. Example usage ----------
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {
@ -234,12 +259,31 @@ func main() {
cal := calendar.NewMultiYearCalendar(2025, 2026, calendar.FixedHolidays, calendar.FloatingHolidays) cal := calendar.NewMultiYearCalendar(2025, 2026, calendar.FixedHolidays, calendar.FloatingHolidays)
now := time.Now() now := time.Now()
var events []Event
for _, r := range rules { for _, r := range rules {
next, err := r.NextAfter(now, cal) t, err := r.NextAfter(now, cal)
if err != nil { if err != nil {
fmt.Printf("%s: %v\n", r.Event, err) fmt.Printf("%s: %v\n", r.Event, err)
continue continue
} }
fmt.Printf("%s → %s\n", next.Format(time.RFC3339), r.Event) events = append(events, Event{r, t})
}
slices.SortFunc(events, func(a, b Event) int {
if a.Time.Before(b.Time) {
return -1
}
if a.Time.After(b.Time) {
return 1
}
if a.Rule.Event > b.Rule.Event {
return 1
} else if a.Rule.Event < b.Rule.Event {
return -1
}
return 0
})
for _, ev := range events {
fmt.Printf("%s → %s\n", ev.Time.Format(time.RFC3339), ev.Rule.Event)
} }
} }