From a71840581e98ef5413d7c8eaf234730476f0b61f Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sun, 9 Nov 2025 00:27:39 -0700 Subject: [PATCH] wip: feat(calendar): calculate fixed and floating yearl and monthly events --- cmd/calendar/main.go | 245 +++++++++++++++++++++++++++++++++++ cmd/weekly/main.go | 41 ++++++ time/calendar/weekly.go | 147 +++++++++++++++++++++ time/calendar/weekly_data.go | 35 +++++ time/calendar/weekly_test.go | 136 +++++++++++++++++++ 5 files changed, 604 insertions(+) create mode 100644 cmd/calendar/main.go create mode 100644 cmd/weekly/main.go create mode 100644 time/calendar/weekly.go create mode 100644 time/calendar/weekly_data.go create mode 100644 time/calendar/weekly_test.go diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go new file mode 100644 index 0000000..a8ae95b --- /dev/null +++ b/cmd/calendar/main.go @@ -0,0 +1,245 @@ +package main + +import ( + "encoding/csv" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/therootcompany/golib/time/calendar" +) + +// ---------- 1. Data structures for a row ---------- +type Rule struct { + Event string + Nth int // 0 = fixed day of month, -1 = last, … (same semantics as GetNthWeekday) + Weekday time.Weekday // ignored when Nth == 0 + FixedDay int // used only when Nth == 0 (e.g. “15” for the 15th) + TimeOfDay string // HH:MM in the event’s local zone (e.g. "19:00") + Location *time.Location + // Reminders are ignored for now – you can add them later. +} + +// ---------- 2. CSV → []Rule ---------- +func LoadRules(rd *csv.Reader) ([]Rule, error) { + // first line is the header + if _, err := rd.Read(); err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + + var rules []Rule + for { + rec, err := rd.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("read row: %w", err) + } + if len(rec) < 6 { + continue // malformed – skip + } + rule, err := parseRule(rec) + if err != nil { + // keep going but report the problem + fmt.Printf("WARN: skip row %q: %v\n", rec, err) + continue + } + rules = append(rules, rule) + } + return rules, nil +} + +func parseRule(rec []string) (Rule, error) { + event := rec[0] + nthStr := rec[1] + dayStr := rec[2] + dateStr := rec[3] + timeStr := rec[4] + tz := rec[5] + + var r Rule + r.Event = strings.TrimSpace(event) + + // ----- Nth ----- + nthStr = strings.TrimSpace(nthStr) + if nthStr == "" { + r.Nth = 0 // fixed day + } else { + // allow “-1” for “last” + n, err := strconv.Atoi(nthStr) + if err != nil { + return r, fmt.Errorf("invalid Nth %q", nthStr) + } + r.Nth = n + } + if r.Nth > 5 || r.Nth < -5 { + return r, fmt.Errorf("'Nth' value must be between -5 and 5, not %q", nthStr) + } + + // ----- Weekday ----- + dayStr = strings.TrimSpace(dayStr) + if dayStr != "" && r.Nth != 0 { + wd, ok := parseWeekday(dayStr) + if !ok { + return r, fmt.Errorf("unknown weekday %q", dayStr) + } + r.Weekday = wd + } + + // ----- Fixed day (only when Nth == 0) ----- + if r.Nth == 0 { + dateStr := strings.TrimSpace(dateStr) + if dateStr == "" { + return r, fmt.Errorf("missing fixed day for event %s", r.Event) + } + d, err := strconv.Atoi(dateStr) + if err != nil || d == 0 || d < -31 || d > 31 { + return r, fmt.Errorf("invalid fixed day %q", dateStr) + } + r.FixedDay = d + } + + // ----- Time ----- + r.TimeOfDay = strings.TrimSpace(timeStr) + if r.TimeOfDay == "" { + r.TimeOfDay = "00:00" + } + if _, err := time.Parse("15:04", r.TimeOfDay); err != nil { + return r, fmt.Errorf("bad time %q", r.TimeOfDay) + } + + // ----- Location (Address column is ignored for now) ----- + // We default to America/Denver – change if you have a column with TZ name. + loc, err := time.LoadLocation(tz) + if err != nil { + return r, err + } + r.Location = loc + + return r, nil +} + +func parseWeekday(s string) (time.Weekday, bool) { + m := map[string]time.Weekday{ + "SUN": time.Sunday, "MON": time.Monday, "TUE": time.Tuesday, + "WED": time.Wednesday, "THU": time.Thursday, + "FRI": time.Friday, "SAT": time.Saturday, + } + wd, ok := m[strings.ToUpper(s[:3])] + return wd, ok +} + +// ---------- 3. Next occurrence ---------- +func (r Rule) NextAfter(after time.Time, cal calendar.MultiYearCalendar) (time.Time, error) { + // Start searching from the month *after* the reference date. + y, _, _ := after.AddDate(0, 0, 1).Date() + startYear := y + 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 rule’s 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 + } + } + } + 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. +// It respects the same semantics as GetNthWeekday. +func (r Rule) candidateInMonth(year int, month time.Month) (time.Time, bool) { + if r.Nth != 0 { + // Floating weekday rule + t, ok := calendar.GetNthWeekday(year, month, r.Weekday, r.Nth) + return t, ok + } + + // Fixed day of month + if r.FixedDay < -31 || r.FixedDay > 31 { + return time.Time{}, false + } + + // time.Date will clamp invalid days (e.g. 31st of February → March 3rd) + // – we simply reject months that cannot contain the day. + // 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() + if r.FixedDay > lastDay { + return time.Time{}, false + } + + fixedDay := r.FixedDay + if r.FixedDay < 0 { + // -1 is last, -2 is second to last... -31 is first for a month with 31 days + // 1+ 31 -1 = 31 + // 1+ 31 -31 = 1 + fixedDay = 1 + lastDay + r.FixedDay + if fixedDay < 1 { + return time.Time{}, false + } + } + + return time.Date(year, month, fixedDay, 0, 0, 0, 0, time.UTC), true +} + +func parseHourMin(s string) (hour, min int, err error) { + _, err = fmt.Sscanf(s, "%d:%d", &hour, &min) + return +} + +// ---------- 4. Example usage ---------- +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: go run ./cmd/calendar/ ./path/to/events.csv\n") + os.Exit(1) + } + + csvpath := os.Args[1] + f, err := os.Open(csvpath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + csvr := csv.NewReader(f) + defer func() { + _ = f.Close() + }() + rules, err := LoadRules(csvr) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + + cal := calendar.NewMultiYearCalendar(2025, 2026, calendar.FixedHolidays, calendar.FloatingHolidays) + now := time.Now() + + for _, r := range rules { + next, err := r.NextAfter(now, cal) + if err != nil { + fmt.Printf("%s: %v\n", r.Event, err) + continue + } + fmt.Printf("%s → %s\n", next.Format(time.RFC3339), r.Event) + } +} diff --git a/cmd/weekly/main.go b/cmd/weekly/main.go new file mode 100644 index 0000000..aa76d1c --- /dev/null +++ b/cmd/weekly/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "flag" + "fmt" + "os" + "time" + + "github.com/therootcompany/golib/time/calendar" +) + +func main() { + t := time.Now() + dateStr := t.Format(time.RFC3339) + tz, _ := t.Local().Zone() // ignore offset for now + flag.StringVar(&tz, "timezone", tz, "timezone (e.g. America/Denver)") + flag.StringVar(&dateStr, "datetime", dateStr, "date (RFC3339, use 'Z' for timezone)") + flag.Parse() + + var err error + t, err = time.Parse(time.RFC3339, dateStr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid date: %v\n", err) + os.Exit(1) + } + + loc, err := time.LoadLocation(tz) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid tz: %v\n", err) + os.Exit(1) + } + t = t.In(loc) + + nth := calendar.NthWeekday(t) + fmt.Printf("%s is the %d%s %s of the month\n", + t.Format("2006-01-02 (Mon)"), + nth, + calendar.GetSuffixEnglish(nth), + t.Weekday(), + ) +} diff --git a/time/calendar/weekly.go b/time/calendar/weekly.go new file mode 100644 index 0000000..0481f48 --- /dev/null +++ b/time/calendar/weekly.go @@ -0,0 +1,147 @@ +package calendar + +import ( + "time" +) + +func GetSuffixEnglish(n int) string { + if n%100 >= 11 && n%100 <= 13 { + return "th" + } + switch n % 10 { + case 1: + return "st" + case 2: + return "nd" + case 3: + return "rd" + default: + return "th" + } +} + +// Returns which nth weekday of the month the target date is (e.g. 2nd Tuesday) +func NthWeekday(t time.Time) int { + _, _, date := t.Date() + nth := date / 7 + nthMod := date % 7 + if nthMod != 0 { + nth += 1 + } + + return nth +} + +func NthWeekdayFromEnd(t time.Time) int { + _, _, day := t.Date() + lastDay := time.Date(t.Year(), t.Month()+1, 0, 0, 0, 0, 0, t.Location()).Day() + return (lastDay-day)/7 + 1 +} + +// GetNthWeekday can find the 1st, 2nd, 3rd, 4th (and sometimes 5th) Monday, etc of the given month +func GetNthWeekday(year int, month time.Month, weekday time.Weekday, n int) (time.Time, bool) { + var mOffset time.Month + nOffset := 1 + if n < 0 { + mOffset = 1 + nOffset = 0 + } + + // First day of month + first := time.Date(year, mOffset+month, 1, 0, 0, 0, 0, time.UTC) + wd := first.Weekday() + + // Days to first target weekday + daysToAdd := int(weekday - wd) + if daysToAdd < 0 { + daysToAdd += 7 + } + + // First occurrence + firstOcc := first.AddDate(0, 0, daysToAdd) + + // nth occurrence + target := firstOcc.AddDate(0, 0, (n-nOffset)*7) + + // Check if still in same month + if target.Month() != month { + return time.Time{}, false + } + + return target, true +} + +type Year = int + +type YearlyDate struct { + Month time.Month + Day int +} + +type YearlyCalendar map[YearlyDate]struct{} + +type MultiYearCalendar map[Year]YearlyCalendar + +func NewMultiYearCalendar(startYear, endYear int, fixed []FixedDate, floating []FloatingDate) MultiYearCalendar { + var mcal = make(MultiYearCalendar) + for year := startYear; year <= endYear; year++ { + mcal[year] = make(YearlyCalendar) + for _, fixedDate := range fixed { + date := YearlyDate{fixedDate.Month, fixedDate.Day} + mcal[year][date] = struct{}{} + } + for _, floatingDate := range floating { + date := floatingDate.ToDate(year) + mcal[year][date] = struct{}{} + } + } + return mcal +} + +type FixedDate struct { + Month time.Month + Day int + Name string +} + +type FloatingDate struct { + Nth int + Weekday time.Weekday + Month time.Month + Name string +} + +func (d FloatingDate) ToDate(year int) YearlyDate { + t, _ := GetNthWeekday(year, d.Month, d.Weekday, d.Nth) + _, month, day := t.Date() + return YearlyDate{month, day} +} + +// Reserved. DO NOT USE. For the time being, Easter, Moon cycles, moveable feasts and such must be entered manually +type LunisolarHoliday struct{} + +func (h MultiYearCalendar) IsBusinessDay(t time.Time) bool { + if wd := t.Weekday(); wd == time.Saturday || wd == time.Sunday { + return false + } + _, ok := h[t.Year()][YearlyDate{t.Month(), t.Day()}] + return !ok +} + +// GetBankDaysBefore calculates business days is useful for calculating transactions that must occur such +// that they will complete by the target date. For example, if you want money to be in +// your account by the 15th each month, and the transfer takes 3 business days, and this +// month's 15th is a Monday that happens to be a holiday, this would return the previous +// Tuesday. +func (h MultiYearCalendar) GetNthBankDayBefore(t time.Time, n int) time.Time { + if !h.IsBusinessDay(t) { + n++ + } + for n > 0 { + t = t.AddDate(0, 0, -1) + if h.IsBusinessDay(t) { + n-- + } + } + return t +} diff --git a/time/calendar/weekly_data.go b/time/calendar/weekly_data.go new file mode 100644 index 0000000..19ad0da --- /dev/null +++ b/time/calendar/weekly_data.go @@ -0,0 +1,35 @@ +package calendar + +import ( + "time" +) + +// FixedHolidays contains US Federal holidays should include both fixed date and pre-calculated holidays. +// For example: Lunar Holidays, such as Easter, should be included manually here, +// as it is no simple feat to calculate them - they are not accurate to the day +// unless using sophisticated algorithms tracking gravitational pull of the Sun, +// Earth, Moon, and planets, etc - really. +// See https://en.wikipedia.org/wiki/Public_holidays_in_the_United_States#Federal_holidays +// See also https://calendarholidays.net/2025-citibank-holidays/ +var FixedHolidays = []FixedDate{ + {time.January, 1, "New Year's Day"}, + {time.June, 19, "Juneteenth"}, + {time.July, 4, "Independence Day"}, + {time.October, 11, "Veterans Day"}, + {time.December, 24, "Christmas Eve"}, // non-Federal, but observed by some banks + {time.December, 25, "Christmas"}, + {time.December, 31, "New Year's Eve"}, // non-Federal, but observed by some banks +} + +// FloatingHolidays are based on 1st, 2nd, 3rd, 4th, or last of a given weekday. +// See https://en.wikipedia.org/wiki/Public_holidays_in_the_United_States#Federal_holidays +// See also https://calendarholidays.net/2025-citibank-holidays/ +var FloatingHolidays = []FloatingDate{ + {3, time.Monday, time.January, "Martin Luther King Day"}, + {3, time.Monday, time.February, "Presidents Day"}, + {-1, time.Monday, time.May, "Memorial Day"}, + {1, time.Monday, time.September, "Labor Day"}, + {2, time.Monday, time.October, "Columbus Day"}, + {4, time.Thursday, time.November, "Thanksgiving Day"}, + {4, time.Friday, time.November, "Black Friday"}, // non-Federal, but some banks close early +} diff --git a/time/calendar/weekly_test.go b/time/calendar/weekly_test.go new file mode 100644 index 0000000..b81ee98 --- /dev/null +++ b/time/calendar/weekly_test.go @@ -0,0 +1,136 @@ +package calendar + +import ( + "fmt" + "strconv" + "testing" + "time" +) + +type testCase struct { + inputDate string + inputTZ string + expected string +} + +func TestNthWeekday(t *testing.T) { + tests := []testCase{ + {"2025-10-31T00:00:00Z", "UTC", "2025-10-31 (Fri) is the 5th Friday of the month"}, + {"2025-11-01T00:00:00Z", "UTC", "2025-11-01 (Sat) is the 1st Saturday of the month"}, + {"2025-11-02T00:00:00Z", "UTC", "2025-11-02 (Sun) is the 1st Sunday of the month"}, + {"2025-11-06T00:00:00Z", "UTC", "2025-11-06 (Thu) is the 1st Thursday of the month"}, + {"2025-11-07T00:00:00Z", "UTC", "2025-11-07 (Fri) is the 1st Friday of the month"}, + {"2025-11-08T00:00:00Z", "UTC", "2025-11-08 (Sat) is the 2nd Saturday of the month"}, + {"2025-10-31T00:00:00Z", "America/Denver", "2025-10-31 (Fri) is the 5th Friday of the month"}, + {"2025-11-01T00:00:00Z", "America/Denver", "2025-11-01 (Sat) is the 1st Saturday of the month"}, + {"2025-11-02T00:00:00Z", "America/Denver", "2025-11-02 (Sun) is the 1st Sunday of the month"}, + {"2025-11-06T00:00:00Z", "America/Denver", "2025-11-06 (Thu) is the 1st Thursday of the month"}, + {"2025-11-07T00:00:00Z", "America/Denver", "2025-11-07 (Fri) is the 1st Friday of the month"}, + {"2025-11-08T00:00:00Z", "America/Denver", "2025-11-08 (Sat) is the 2nd Saturday of the month"}, + {"2025-11-15T00:00:00Z", "UTC", "2025-11-15 (Sat) is the 3rd Saturday of the month"}, + {"2025-11-30T00:00:00Z", "UTC", "2025-11-30 (Sun) is the 5th Sunday of the month"}, + {"2025-02-28T00:00:00Z", "UTC", "2025-02-28 (Fri) is the 4th Friday of the month"}, + {"2024-02-29T00:00:00Z", "UTC", "2024-02-29 (Thu) is the 5th Thursday of the month"}, + } + + for _, tc := range tests { + t.Run(tc.inputDate+"_"+tc.inputTZ, func(t *testing.T) { + loc, _ := time.LoadLocation(tc.inputTZ) + date, _ := time.ParseInLocation(time.RFC3339, tc.inputDate, loc) + + nth := NthWeekday(date) + got := fmt.Sprintf("%s is the %d%s %s of the month", + date.Format("2006-01-02 (Mon)"), + nth, + GetSuffixEnglish(nth), + date.Weekday()) + + if got != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, got) + } + }) + } +} + +type nthWeekdayTest struct { + year int + month time.Month + weekday time.Weekday + n int + wantDate string + wantOK bool +} + +func TestGetNthWeekday(t *testing.T) { + tests := []nthWeekdayTest{ + {2025, time.November, time.Saturday, 1, "2025-11-01", true}, + {2025, time.November, time.Saturday, 2, "2025-11-08", true}, + {2025, time.November, time.Saturday, 3, "2025-11-15", true}, + {2025, time.November, time.Saturday, 4, "2025-11-22", true}, + {2025, time.November, time.Saturday, 5, "2025-11-29", true}, + {2025, time.November, time.Saturday, 6, "", false}, + {2025, time.November, time.Saturday, 0, "", false}, + {2025, time.November, time.Saturday, -1, "2025-11-29", true}, + {2025, time.November, time.Saturday, -2, "2025-11-22", true}, + {2025, time.November, time.Saturday, -3, "2025-11-15", true}, + {2025, time.November, time.Saturday, -4, "2025-11-08", true}, + {2025, time.November, time.Saturday, -5, "2025-11-01", true}, + {2025, time.November, time.Saturday, -6, "", false}, + + {2025, time.November, time.Sunday, 1, "2025-11-02", true}, + {2025, time.November, time.Friday, 1, "2025-11-07", true}, + {2025, time.November, time.Friday, 5, "", false}, // only 4 Fridays + + {2024, time.February, time.Monday, 5, "", false}, // Feb 2024 has only 4 Mondays + {2024, time.February, time.Thursday, 4, "2024-02-22", true}, + } + + for _, tt := range tests { + name := fmt.Sprintf("%d-%02d-%s-%d", tt.year, tt.month, tt.weekday, tt.n) + t.Run(name, func(t *testing.T) { + got, ok := GetNthWeekday(tt.year, tt.month, tt.weekday, tt.n) + if ok != tt.wantOK { + t.Fatalf("ok mismatch: want %v, got %v", tt.wantOK, ok) + } + if !ok { + return + } + if got.Format("2006-01-02") != tt.wantDate { + t.Errorf("date = %s, want %s", got.Format("2006-01-02"), tt.wantDate) + } + if got.Weekday() != tt.weekday { + t.Errorf("weekday = %s, want %s", got.Weekday(), tt.weekday) + } + }) + } +} + +type businessDaysBeforeTest struct { + start string + n int + want string +} + +func TestGetBankDaysBefore(t *testing.T) { + tests := []businessDaysBeforeTest{ + {"2025-11-10T12:00:01Z", 1, "2025-11-07T12:00:01Z"}, // Mon → Fri + {"2025-11-10T12:00:02Z", 2, "2025-11-06T12:00:02Z"}, // Mon → Thu + {"2025-11-08T12:00:03Z", 1, "2025-11-06T12:00:03Z"}, // Sat (non-biz) → count 2 → Thu + {"2025-11-11T12:00:04Z", 1, "2025-11-10T12:00:04Z"}, // Wed → Tue + {"2025-11-27T12:00:04Z", 1, "2025-11-25T12:00:04Z"}, // Thu (holiday) → count 2 → Tue + {"2025-12-26T12:00:05Z", 1, "2025-12-23T12:00:05Z"}, // Fri → Tue (skip Xmas, Xmas Eve) + {"2025-12-31T12:00:06Z", 1, "2025-12-29T12:00:06Z"}, // NYE (non-biz) → count 2 → Tue + } + cal := NewMultiYearCalendar(2025, 2025, FixedHolidays, FloatingHolidays) + + for _, tt := range tests { + t.Run(tt.start+"_"+strconv.Itoa(tt.n), func(t *testing.T) { + start, _ := time.Parse(time.RFC3339, tt.start) + got := cal.GetNthBankDayBefore(start, tt.n) + want, _ := time.Parse(time.RFC3339, tt.want) + if !got.Equal(want) { + t.Errorf("For %d days before %s got %s, want %s", tt.n, tt.start, got.Format(time.RFC3339), want.Format(time.RFC3339)) + } + }) + } +}