From 7e2d958f4855a92b8f349e553979e098fdb0a04c Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 12:13:35 +0200 Subject: feat(calendar): DateSpec resolution (fixed + easter/christmas offsets + sunday-after) --- internal/calendar/datespec.go | 52 ++++++++++++++++++++++++++++++++++++++ internal/calendar/datespec_test.go | 24 ++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 internal/calendar/datespec.go create mode 100644 internal/calendar/datespec_test.go (limited to 'internal') diff --git a/internal/calendar/datespec.go b/internal/calendar/datespec.go new file mode 100644 index 0000000..d0040cc --- /dev/null +++ b/internal/calendar/datespec.go @@ -0,0 +1,52 @@ +package calendar + +import ( + "strconv" + "strings" + "time" +) + +// resolveDate returns the date (UTC midnight) that spec names in year, or +// ok=false if the spec is unparseable. +func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool) { + s := strings.TrimSpace(string(spec)) + switch { + case len(s) == 5 && s[2] == '-': // MM-DD + mo, err1 := strconv.Atoi(s[0:2]) + da, err2 := strconv.Atoi(s[3:5]) + if err1 != nil || err2 != nil { + return time.Time{}, false + } + return time.Date(year, time.Month(mo), da, 0, 0, 0, 0, time.UTC), true + case strings.HasPrefix(s, "easter"): + return offset(easter, s[len("easter"):]) + case strings.HasPrefix(s, "christmas"): + xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC) + return offset(xmas, s[len("christmas"):]) + case strings.HasPrefix(s, "sunday-after "): + base, ok := resolveDate(DateSpec(strings.TrimPrefix(s, "sunday-after ")), year, easter) + if !ok { + return time.Time{}, false + } + return nextWeekday(base.AddDate(0, 0, 1), time.Sunday), true + } + return time.Time{}, false +} + +// offset parses "±N" and adds N days to base. +func offset(base time.Time, pm string) (time.Time, bool) { + if pm == "" { + return base, true + } + n, err := strconv.Atoi(pm) // strconv.Atoi handles a leading '+' and '-' + if err != nil { + return time.Time{}, false + } + return base.AddDate(0, 0, n), true +} + +// nextWeekday returns the first day >= from whose weekday is wd. +func nextWeekday(from time.Time, wd time.Weekday) time.Time { + delta := (int(wd) - int(from.Weekday()) + 7) % 7 + return from.AddDate(0, 0, delta) +} diff --git a/internal/calendar/datespec_test.go b/internal/calendar/datespec_test.go new file mode 100644 index 0000000..e5e34c0 --- /dev/null +++ b/internal/calendar/datespec_test.go @@ -0,0 +1,24 @@ +package calendar + +import ( + "testing" +) + +func TestResolveDate(t *testing.T) { + y := 2025 + e := Easter(y) // 2025-04-20 + check := func(spec, want string) { + got, ok := resolveDate(DateSpec(spec), y, e) + if !ok || got.Format("2006-01-02") != want { + t.Errorf("resolveDate(%q) = %s ok=%v, want %s", spec, got.Format("2006-01-02"), ok, want) + } + } + check("08-15", "2025-08-15") // fixed + check("easter+60", "2025-06-19") // Corpus Christi (Thursday) + check("easter-46", "2025-03-05") // Ash Wednesday + check("christmas+7", "2026-01-01") + check("sunday-after 01-06", "2025-01-12") + if _, ok := resolveDate("garbage", y, e); ok { + t.Error("garbage should not resolve") + } +} -- cgit v1.3