golib/cmd/calendar/main.go

246 lines
6.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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