golib/cmd/calendar/main.go

351 lines
8.0 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"
"slices"
"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.
}
type DayType int
const (
FEDERAL DayType = iota
BANK
BUSINESS
)
// format is either "<days> <time>" or <days> or <time> or <duration>
type Reminder struct {
//Parent string
Days int
DayType DayType
Duration time.Duration
Time Time
}
type Time struct {
Hour int
Minute int
Second int
}
type Fields struct {
NAME int
NTH int
WKDAY int
DATE int
TIME int
TZ int
LABEL int
DETAIL int
R1 int
R2 int
R3 int
}
var FIELDS = Fields{
NAME: 0,
NTH: 1,
WKDAY: 2,
DATE: 3,
TIME: 4,
TZ: 5,
LABEL: 6,
DETAIL: 7,
R1: 8,
R2: 9,
R3: 10,
}
// ---------- 2. CSV → []Rule ----------
func LoadRules(rd *csv.Reader) ([]Rule, error) {
// first line is the header
// TODO create instance and map header names to column ints
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[FIELDS.NAME]
nthStr := rec[FIELDS.NTH]
dayStr := rec[FIELDS.WKDAY]
dateStr := rec[FIELDS.DATE]
timeStr := rec[FIELDS.TIME]
tz := rec[FIELDS.TZ]
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) {
candidate, ok := r.Next(after)
if !ok {
return time.Time{}, fmt.Errorf("no occurrence for rule")
}
// Convert to the rules local zone and attach the time-of-day.
candidate = time.Date(candidate.Year(), candidate.Month(), candidate.Day(),
0, 0, 0, 0, r.Location)
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) {
return candidate, nil
}
return time.Time{}, fmt.Errorf("no occurrence found for %s after %s", r.Event, after)
}
// Next returns the *date* (midnight UTC) for the rule in the given year/month.
// It respects the same semantics as GetNthWeekday.
func (r Rule) Next(start time.Time) (time.Time, bool) {
if r.Nth != 0 {
// Floating weekday rule
startYear := start.Year()
endYear := startYear + 5 // up through leap year
startMonth := start.Month()
for year := startYear; year <= endYear; year++ {
for month := startMonth; month <= 12; month++ {
t, ok := calendar.GetNthWeekday(year, month, r.Weekday, r.Nth)
if ok {
if t.After(start) {
return t, ok
}
}
}
startMonth = 1
}
return time.Time{}, false
}
// 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(start.Year(), start.Month()+1, 0, 0, 0, 0, 0, r.Location).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
}
}
// fmt.Fprintf(os.Stderr, "DEBUG event %s: %d-%d-%d\n", r.Event, start.Year(), start.Month(), fixedDay)
var t time.Time
startYear := start.Year()
endYear := startYear + 5 // up through leap year
startMonth := start.Month()
for year := startYear; year <= endYear; year++ {
for month := startMonth; month <= 12; month++ {
t = time.Date(year, month, fixedDay, 0, 0, 0, 0, r.Location)
// fmt.Fprintf(os.Stderr, "DEBUG candidate event time %s\n", t.Format(time.RFC3339))
if t.After(start) {
return t, true
}
}
}
return time.Time{}, false
}
func parseHourMin(s string) (hour, min int, err error) {
_, err = fmt.Sscanf(s, "%d:%d", &hour, &min)
return
}
type Event struct {
Rule Rule
Time time.Time
}
// ---------- 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()
var events []Event
for _, r := range rules {
t, err := r.NextAfter(now, cal)
if err != nil {
fmt.Printf("%s: %v\n", r.Event, err)
continue
}
events = append(events, Event{r, t})
}
slices.SortFunc(events, func(a, b Event) int {
if a.Time.Before(b.Time) {
return -1
}
if a.Time.After(b.Time) {
return 1
}
if a.Rule.Event > b.Rule.Event {
return 1
} else if a.Rule.Event < b.Rule.Event {
return -1
}
return 0
})
for _, ev := range events {
fmt.Printf("%s → %s\n", ev.Time.Format(time.RFC3339), ev.Rule.Event)
}
fmt.Println()
for _, ev := range events {
sleep := time.Until(ev.Time.Add(-2 * time.Hour))
fmt.Printf("waiting %s for %s (%s)\n", sleep, ev.Rule.Event, ev.Time.Format(time.RFC3339))
if sleep > 0 {
time.Sleep(sleep)
}
}
}