aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:13:07 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:13:07 +0200
commit4c76a397cc1395b207e106580897fa64ef039cef (patch)
tree9756473292dcd60a5d18e6935563e694afbfde6a
parentb4982d22be0c79949046c9d957354209996acb2e (diff)
downloadlectio-4c76a397cc1395b207e106580897fa64ef039cef.tar.gz
lectio-4c76a397cc1395b207e106580897fa64ef039cef.zip
feat(calendar): core types + Gregorian Computus
-rw-r--r--internal/calendar/computus.go23
-rw-r--r--internal/calendar/computus_test.go20
-rw-r--r--internal/calendar/types.go146
-rw-r--r--internal/calendar/types_test.go15
4 files changed, 204 insertions, 0 deletions
diff --git a/internal/calendar/computus.go b/internal/calendar/computus.go
new file mode 100644
index 0000000..13410cb
--- /dev/null
+++ b/internal/calendar/computus.go
@@ -0,0 +1,23 @@
+package calendar
+
+import "time"
+
+// Easter returns Gregorian Easter Sunday (UTC midnight) via the Anonymous
+// Gregorian algorithm (Meeus/Jones/Butcher).
+func Easter(year int) time.Time {
+ a := year % 19
+ b := year / 100
+ c := year % 100
+ d := b / 4
+ e := b % 4
+ f := (b + 8) / 25
+ g := (b - f + 1) / 3
+ h := (19*a + b - d - g + 15) % 30
+ i := c / 4
+ k := c % 4
+ l := (32 + 2*e + 2*i - h - k) % 7
+ m := (a + 11*h + 22*l) / 451
+ month := (h + l - 7*m + 114) / 31
+ day := ((h + l - 7*m + 114) % 31) + 1
+ return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
+}
diff --git a/internal/calendar/computus_test.go b/internal/calendar/computus_test.go
new file mode 100644
index 0000000..c524093
--- /dev/null
+++ b/internal/calendar/computus_test.go
@@ -0,0 +1,20 @@
+package calendar
+
+import (
+ "testing"
+)
+
+func TestEasterKnownDates(t *testing.T) {
+ // Published Gregorian Easter Sundays.
+ cases := map[int]string{
+ 2020: "2020-04-12", 2021: "2021-04-04", 2022: "2022-04-17",
+ 2024: "2024-03-31", 2025: "2025-04-20", 2027: "2027-03-28",
+ 2038: "2038-04-25", 2000: "2000-04-23",
+ }
+ for y, want := range cases {
+ got := Easter(y).Format("2006-01-02")
+ if got != want {
+ t.Errorf("Easter(%d) = %s, want %s", y, got, want)
+ }
+ }
+}
diff --git a/internal/calendar/types.go b/internal/calendar/types.go
new file mode 100644
index 0000000..f481441
--- /dev/null
+++ b/internal/calendar/types.go
@@ -0,0 +1,146 @@
+// Package calendar computes the Ordinary Form liturgical day for any Gregorian
+// date, offline and from first principles. It imports only the standard library.
+package calendar
+
+import "time"
+
+type Rank int
+
+const (
+ RankFerial Rank = iota // ordinary weekday
+ RankOptional // optional memorial
+ RankMemorial // obligatory memorial
+ RankFeast
+ RankSolemnity
+)
+
+type Class int
+
+const (
+ ClassNone Class = iota // temporal / ferial
+ ClassSaint // a saint
+ ClassBVM // the Blessed Virgin Mary
+ ClassLord // the Lord
+)
+
+type Colour string
+
+const (
+ White Colour = "white"
+ Red Colour = "red"
+ Green Colour = "green"
+ Violet Colour = "violet"
+ Rose Colour = "rose"
+ Black Colour = "black"
+)
+
+type Season string
+
+const (
+ Advent Season = "advent"
+ Christmas Season = "christmas"
+ Lent Season = "lent"
+ Triduum Season = "triduum"
+ Easter_ Season = "easter" // trailing underscore: Easter is the func name
+ Ordinary Season = "ordinary"
+)
+
+type Reading struct{ Part, Citation string }
+
+type Mass struct {
+ Variant string // "" = the day Mass; e.g. "vigil"
+ Readings []Reading
+}
+
+// DateSpec is a celebration's raw date expression, resolved by resolveDate:
+// "MM-DD" (fixed) | "easter±N" | "christmas±N" | "sunday-after MM-DD".
+type DateSpec string
+
+// RawCelebration is a celebration's fields exactly as parsed from a layer,
+// before typing/merging. Fields are raw INI values keyed by INI key
+// (e.g. "rank", "name.pl"); Variants holds "[slug/variant]" sub-sections.
+type RawCelebration struct {
+ Fields map[string]string
+ Variants map[string]map[string]string
+}
+
+// Celebration is the typed, built form used in results.
+type Celebration struct {
+ Slug string
+ Name map[string]string // lang -> name
+ Rank Rank
+ Class Class
+ Colour Colour
+ Date DateSpec
+ Masses []Mass
+ Layer string // provenance
+}
+
+// Layer is one calendar layer (the embedded universal base, or later an
+// override file). Cels is keyed by slug; insertion order is not significant.
+type Layer struct {
+ ID, Name, Type string
+ Cels map[string]RawCelebration
+}
+
+// Selection carries the config choices that steer computation.
+type Selection struct {
+ Form string // "new" (OF). "old" reserved.
+ Epiphany string // "fixed" | "sunday"
+ Ascension string // "thursday" | "sunday"
+ CorpusChristi string // "thursday" | "sunday"
+}
+
+// DefaultSelection is the Universal Roman Calendar default.
+func DefaultSelection() Selection {
+ return Selection{Form: "new", Epiphany: "fixed", Ascension: "thursday", CorpusChristi: "thursday"}
+}
+
+type LiturgicalDay struct {
+ Date time.Time
+ Season Season
+ Week int
+ Weekday time.Weekday
+ Observed Celebration
+ Others []Celebration
+ Colour Colour
+ SundayCycle string // "A" | "B" | "C"
+ WeekdayCycle string // "I" | "II"
+}
+
+func ParseRank(s string) Rank {
+ switch s {
+ case "solemnity":
+ return RankSolemnity
+ case "feast":
+ return RankFeast
+ case "memorial":
+ return RankMemorial
+ case "optional":
+ return RankOptional
+ default:
+ return RankFerial
+ }
+}
+
+func ParseClass(s string) Class {
+ switch s {
+ case "lord":
+ return ClassLord
+ case "bvm":
+ return ClassBVM
+ case "saint":
+ return ClassSaint
+ default:
+ return ClassNone
+ }
+}
+
+func ParseColour(s string) Colour {
+ switch Colour(s) {
+ case White, Red, Green, Violet, Rose, Black:
+ return Colour(s)
+ default:
+ return ""
+ }
+}
diff --git a/internal/calendar/types_test.go b/internal/calendar/types_test.go
new file mode 100644
index 0000000..c09b53e
--- /dev/null
+++ b/internal/calendar/types_test.go
@@ -0,0 +1,15 @@
+package calendar
+
+import "testing"
+
+func TestParseEnums(t *testing.T) {
+ if ParseRank("feast") != RankFeast || ParseRank("nonsense") != RankFerial {
+ t.Error("ParseRank")
+ }
+ if ParseClass("bvm") != ClassBVM || ParseClass("") != ClassNone {
+ t.Error("ParseClass")
+ }
+ if ParseColour("violet") != Violet || ParseColour("chartreuse") != "" {
+ t.Error("ParseColour")
+ }
+}