mirror of
https://github.com/therootcompany/golib.git
synced 2025-11-20 05:54:29 +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