mirror of
https://github.com/therootcompany/golib.git
synced 2025-11-20 03:45:53 +00:00
wip: feat(calendar): calculate fixed and floating yearl and monthly events
This commit is contained in:
parent
a9821e9bd5
commit
a71840581e
245
cmd/calendar/main.go
Normal file
245
cmd/calendar/main.go
Normal 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 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)
|
||||
}
|
||||
}
|
||||
41
cmd/weekly/main.go
Normal file
41
cmd/weekly/main.go
Normal 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
147
time/calendar/weekly.go
Normal 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
|
||||
}
|
||||
35
time/calendar/weekly_data.go
Normal file
35
time/calendar/weekly_data.go
Normal 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
|
||||
}
|
||||
136
time/calendar/weekly_test.go
Normal file
136
time/calendar/weekly_test.go
Normal 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user