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

This commit is contained in:
AJ ONeal 2025-11-09 00:27:39 -07:00
parent a9821e9bd5
commit a71840581e
No known key found for this signature in database
5 changed files with 604 additions and 0 deletions

245
cmd/calendar/main.go Normal file
View File

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

41
cmd/weekly/main.go Normal file
View File

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

147
time/calendar/weekly.go Normal file
View File

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

View File

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

View File

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