summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 13:58:19 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 13:58:19 +0200
commit3bda15bfe7786ae97fac1cbf779b713cf4238846 (patch)
treed7d5d9b8a6a0183fdb732e4cc6289f65405fc6d5
parent5d96dd46ae8ed6b4f4cae6a0dd771a33cdc3a317 (diff)
downloadlectio-3bda15bfe7786ae97fac1cbf779b713cf4238846.tar.gz
lectio-3bda15bfe7786ae97fac1cbf779b713cf4238846.zip
feat(calendar): EF (1962) temporal cycle temporalEF + EF seasons
8 EF seasons by date boundary (Septuagesima, Passiontide, Time after Epiphany/ Pentecost + shared); major feasts of the Lord; Christ the King = last Sun of October. ParseRank accepts EF ranks.
-rw-r--r--internal/calendar/temporal_ef.go146
-rw-r--r--internal/calendar/temporal_ef_test.go40
-rw-r--r--internal/calendar/types.go15
3 files changed, 192 insertions, 9 deletions
diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go
new file mode 100644
index 0000000..725aac2
--- /dev/null
+++ b/internal/calendar/temporal_ef.go
@@ -0,0 +1,146 @@
+package calendar
+
+import "time"
+
+// Extraordinary Form (1962) season constants. Advent/Christmas/Lent/Easter are
+// shared with the OF; these are EF-specific.
+const (
+ Septuagesima Season = "septuagesima"
+ Passiontide Season = "passiontide"
+ TimeAfterEpiphany Season = "time-after-epiphany"
+ TimeAfterPentecost Season = "time-after-pentecost"
+)
+
+// efChristTheKing is the last Sunday of October (the 1962 date — unlike the OF's
+// last Sunday before Advent).
+func efChristTheKing(year int) time.Time {
+ oct31 := time.Date(year, 10, 31, 0, 0, 0, 0, time.UTC)
+ return oct31.AddDate(0, 0, -int(oct31.Weekday())) // Sunday on/before Oct 31
+}
+
+// efColour is the default (Sunday/ferial) colour of an EF season.
+func efColour(s Season) Colour {
+ switch s {
+ case Advent, Septuagesima, Lent, Passiontide:
+ return Violet
+ case Christmas, Easter_:
+ return White
+ default: // TimeAfterEpiphany, TimeAfterPentecost
+ return Green
+ }
+}
+
+// efSeason returns the 1962 season for date, by chronological date boundaries.
+func efSeason(date time.Time, y int, easter time.Time) Season {
+ adventThis := adventStart(y)
+ christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC)
+ jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC)
+ septStart := easter.AddDate(0, 0, -63) // Septuagesima Sunday
+ ashWed := easter.AddDate(0, 0, -46)
+ passionSun := easter.AddDate(0, 0, -14)
+ paschalEnd := easter.AddDate(0, 0, 55) // Saturday after Pentecost; Trinity = +56
+
+ switch {
+ case !date.Before(adventThis) && date.Before(christmasThis):
+ return Advent
+ case !date.Before(christmasThis): // Dec 25..31
+ return Christmas
+ case date.Before(jan6): // Jan 1..5
+ return Christmas
+ case date.Before(septStart): // Epiphany .. before Septuagesima
+ return TimeAfterEpiphany
+ case date.Before(ashWed):
+ return Septuagesima
+ case date.Before(passionSun):
+ return Lent
+ case date.Before(easter):
+ return Passiontide
+ case !date.After(paschalEnd):
+ return Easter_
+ default:
+ return TimeAfterPentecost
+ }
+}
+
+func efCel(season Season, slug string, col Colour, rank Rank, week int) temporalDay {
+ return temporalDay{
+ Season: season, Colour: col, Week: week, Rank: rank,
+ Sunday: false,
+ Cel: Celebration{Slug: slug, Rank: rank, Colour: col, Layer: "temporal"},
+ }
+}
+
+// temporalEF computes the 1962 temporal identity of date. Season is exact
+// (oracle-validated); ranks/weeks are best-effort for display.
+func temporalEF(date time.Time) temporalDay {
+ date = date.UTC().Truncate(24 * time.Hour)
+ y := date.Year()
+ easter := Easter(y)
+ season := efSeason(date, y, easter)
+ col := efColour(season)
+ sun := date.Weekday() == time.Sunday
+
+ // Major feasts of the Lord (I class): nice titles + colour.
+ switch {
+ case date.Month() == time.December && date.Day() == 25:
+ return efCel(Christmas, "ef-nativity", White, RankClass1, 0)
+ case date.Month() == time.January && date.Day() == 1:
+ return efCel(Christmas, "ef-circumcision", White, RankClass1, 0)
+ case date.Month() == time.January && date.Day() == 6:
+ return efCel(TimeAfterEpiphany, "ef-epiphany", White, RankClass1, 0)
+ case sameDay(date, easter.AddDate(0, 0, -46)):
+ return efCel(Lent, "ef-ash-wednesday", Violet, RankClass1, 0)
+ case sameDay(date, easter.AddDate(0, 0, -14)):
+ return efCel(Passiontide, "ef-passion-sunday", Violet, RankClass1, 1)
+ case sameDay(date, easter.AddDate(0, 0, -7)):
+ return efCel(Passiontide, "ef-palm-sunday", Violet, RankClass1, 2)
+ case sameDay(date, easter):
+ return efCel(Easter_, "ef-easter-sunday", White, RankClass1, 1)
+ case sameDay(date, easter.AddDate(0, 0, 7)):
+ return efCel(Easter_, "ef-low-sunday", White, RankClass1, 2)
+ case sameDay(date, easter.AddDate(0, 0, 39)):
+ return efCel(Easter_, "ef-ascension", White, RankClass1, 0)
+ case sameDay(date, easter.AddDate(0, 0, 49)):
+ return efCel(Easter_, "ef-pentecost", Red, RankClass1, 0)
+ case sameDay(date, easter.AddDate(0, 0, 56)):
+ return efCel(TimeAfterPentecost, "ef-trinity", White, RankClass1, 1)
+ case sameDay(date, easter.AddDate(0, 0, 60)):
+ return efCel(TimeAfterPentecost, "ef-corpus-christi", White, RankClass1, 0)
+ case sameDay(date, easter.AddDate(0, 0, 68)):
+ return efCel(TimeAfterPentecost, "ef-sacred-heart", White, RankClass1, 0)
+ case sameDay(date, efChristTheKing(y)):
+ return efCel(TimeAfterPentecost, "ef-christ-the-king", White, RankClass1, 0)
+ }
+
+ // Sundays vs ferias of the current season.
+ if sun {
+ rank := RankClass2
+ if season == Advent && sameDay(date, adventStart(y)) {
+ rank = RankClass1 // 1st Sunday of Advent
+ }
+ return efCel(season, "ef-"+string(season)+"-sunday", col, rank, efWeek(date, season, y, easter))
+ }
+ rank := RankClass4 // per-annum feria
+ if season == Advent || season == Lent || season == Passiontide || season == Septuagesima {
+ rank = RankClass3 // penitential ferias rank higher
+ }
+ return efCel(season, "ef-"+string(season)+"-feria", col, rank, efWeek(date, season, y, easter))
+}
+
+// efWeek is a best-effort week-within-season number for display (not
+// oracle-checked). Time after Pentecost counts Sundays from Pentecost.
+func efWeek(date time.Time, season Season, y int, easter time.Time) int {
+ switch season {
+ case Advent:
+ return daysBetween(adventStart(y), date)/7 + 1
+ case TimeAfterPentecost:
+ return daysBetween(easter.AddDate(0, 0, 49), date) / 7 // Sundays after Pentecost
+ case Lent:
+ return daysBetween(easter.AddDate(0, 0, -42), date)/7 + 1 // from 1st Sunday of Lent
+ case TimeAfterEpiphany:
+ jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC)
+ return daysBetween(jan6, date)/7 + 1
+ default:
+ return 0
+ }
+}
diff --git a/internal/calendar/temporal_ef_test.go b/internal/calendar/temporal_ef_test.go
new file mode 100644
index 0000000..6d632e2
--- /dev/null
+++ b/internal/calendar/temporal_ef_test.go
@@ -0,0 +1,40 @@
+package calendar
+
+import "testing"
+
+func TestTemporalEFSeasons(t *testing.T) {
+ cases := []struct {
+ date string
+ season Season
+ }{
+ {"2025-01-01", Christmas}, // Circumcision
+ {"2025-01-20", TimeAfterEpiphany}, // after II Sunday after Epiphany
+ {"2025-02-16", Septuagesima}, // Septuagesima Sunday (easter-63)
+ {"2025-03-05", Lent}, // Ash Wednesday
+ {"2025-04-06", Passiontide}, // Passion Sunday (easter-14)
+ {"2025-04-13", Passiontide}, // Palm Sunday
+ {"2025-04-20", Easter_}, // Easter
+ {"2025-06-08", Easter_}, // Pentecost (still Paschaltide)
+ {"2025-06-15", TimeAfterPentecost}, // Trinity
+ {"2025-06-22", TimeAfterPentecost}, // II Sunday after Pentecost
+ {"2025-10-26", TimeAfterPentecost}, // Christ the King
+ {"2025-11-30", Advent}, // I Sunday of Advent
+ {"2025-12-25", Christmas}, // Nativity
+ }
+ for _, c := range cases {
+ got := temporalEF(d(c.date))
+ if got.Season != c.season {
+ t.Errorf("temporalEF(%s).Season = %s, want %s", c.date, got.Season, c.season)
+ }
+ }
+}
+
+func TestTemporalEFChristTheKing(t *testing.T) {
+ // EF: last Sunday of October (2025-10-26), not the last before Advent.
+ if got := temporalEF(d("2025-10-26")); got.Cel.Slug != "ef-christ-the-king" {
+ t.Errorf("2025-10-26 slug = %q want ef-christ-the-king", got.Cel.Slug)
+ }
+ if got := efChristTheKing(2025).Format("2006-01-02"); got != "2025-10-26" {
+ t.Errorf("efChristTheKing(2025) = %s want 2025-10-26", got)
+ }
+}
diff --git a/internal/calendar/types.go b/internal/calendar/types.go
index 0ccef8b..298e49d 100644
--- a/internal/calendar/types.go
+++ b/internal/calendar/types.go
@@ -137,16 +137,13 @@ type LiturgicalDay struct {
WeekdayCycle string // "I" | "II"
}
+// ParseRank validates a rank token (OF or EF vocabulary), falling back to
+// RankFerial for anything unknown.
func ParseRank(s string) Rank {
- switch s {
- case "solemnity":
- return RankSolemnity
- case "feast":
- return RankFeast
- case "memorial":
- return RankMemorial
- case "optional":
- return RankOptional
+ switch Rank(s) {
+ case RankSolemnity, RankFeast, RankMemorial, RankOptional, RankFerial,
+ RankClass1, RankClass2, RankClass3, RankClass4, RankCommemoration:
+ return Rank(s)
default:
return RankFerial
}