aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/temporal.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:18:01 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:18:01 +0200
commit3677ad490c9f18020d68349c9e3a71bcf389178f (patch)
tree3a4b135ca139a2acca15ac0f9f5cf851d88a45dd /internal/calendar/temporal.go
parent7e2d958f4855a92b8f349e553979e098fdb0a04c (diff)
downloadlectio-3677ad490c9f18020d68349c9e3a71bcf389178f.tar.gz
lectio-3677ad490c9f18020d68349c9e3a71bcf389178f.zip
feat(calendar): temporal cycle (seasons, Sundays, movable solemnities)
Diffstat (limited to 'internal/calendar/temporal.go')
-rw-r--r--internal/calendar/temporal.go241
1 files changed, 241 insertions, 0 deletions
diff --git a/internal/calendar/temporal.go b/internal/calendar/temporal.go
new file mode 100644
index 0000000..011e023
--- /dev/null
+++ b/internal/calendar/temporal.go
@@ -0,0 +1,241 @@
+package calendar
+
+import (
+ "strconv"
+ "time"
+)
+
+// temporalDay is the temporal (season/movable) identity of a date, before any
+// sanctoral celebration is considered. Privileged marks band 1-2 days (Sundays
+// of Advent/Lent/Easter, Holy Week, Triduum, Easter octave, the great
+// solemnities); PrivFeria marks band-9 privileged ferias (Advent 17-24,
+// Lenten weekdays); Sunday marks an Ordinary/Christmas Sunday (band 6).
+type temporalDay struct {
+ Season Season
+ Week int
+ Colour Colour
+ Cel Celebration
+ Rank Rank
+ Class Class
+ Privileged bool
+ PrivFeria bool
+ Sunday bool
+}
+
+// adventStart returns the First Sunday of Advent that opens the liturgical year
+// containing Christmas of the given calendar year: the 4th Sunday before Dec 25.
+func adventStart(year int) time.Time {
+ xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC)
+ sun := xmas.AddDate(0, 0, -int(xmas.Weekday())) // Sunday on/before Christmas (Sunday=0)
+ return sun.AddDate(0, 0, -21)
+}
+
+func sameDay(a, b time.Time) bool {
+ return a.Year() == b.Year() && a.YearDay() == b.YearDay()
+}
+
+// daysBetween is the whole-day count from..to (both UTC midnight).
+func daysBetween(from, to time.Time) int {
+ return int(to.Sub(from).Hours()/24 + 0.5)
+}
+
+func weekdayAbbr(wd time.Weekday) string {
+ return [...]string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}[wd]
+}
+
+// epiphany is fixed Jan 6, or (sel.Epiphany=="sunday") the Sunday between Jan 2 and Jan 8.
+func epiphany(year int, sel Selection) time.Time {
+ if sel.Epiphany == "sunday" {
+ return nextWeekday(time.Date(year, 1, 2, 0, 0, 0, 0, time.UTC), time.Sunday)
+ }
+ return time.Date(year, 1, 6, 0, 0, 0, 0, time.UTC)
+}
+
+// baptismOfLord is the Sunday after Epiphany; when Epiphany (transferred to a
+// Sunday) is Jan 7 or 8, Baptism is the next day.
+func baptismOfLord(year int, sel Selection) time.Time {
+ epi := epiphany(year, sel)
+ if sel.Epiphany == "sunday" && (epi.Day() == 7 || epi.Day() == 8) {
+ return epi.AddDate(0, 0, 1)
+ }
+ return nextWeekday(epi.AddDate(0, 0, 1), time.Sunday)
+}
+
+// holyFamily is the Sunday within the Christmas octave (Dec 26-31), or Dec 30
+// when Christmas itself is a Sunday.
+func holyFamily(year int) time.Time {
+ xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC)
+ if xmas.Weekday() == time.Sunday {
+ return time.Date(year, 12, 30, 0, 0, 0, 0, time.UTC)
+ }
+ return nextWeekday(xmas.AddDate(0, 0, 1), time.Sunday)
+}
+
+// ascension: Thursday (easter+39) or, where transferred, the 7th Sunday of Easter (easter+42).
+func ascension(y int, sel Selection) time.Time {
+ e := Easter(y)
+ if sel.Ascension == "sunday" {
+ return e.AddDate(0, 0, 42)
+ }
+ return e.AddDate(0, 0, 39)
+}
+
+// corpusChristi: Thursday (easter+60) or, where transferred, Sunday (easter+63).
+func corpusChristi(y int, sel Selection) time.Time {
+ e := Easter(y)
+ if sel.CorpusChristi == "sunday" {
+ return e.AddDate(0, 0, 63)
+ }
+ return e.AddDate(0, 0, 60)
+}
+
+// --- small constructors ---
+
+func solemn(season Season, slug string, col Colour, privileged bool) temporalDay {
+ return temporalDay{Season: season, Colour: col, Rank: RankSolemnity, Class: ClassLord, Privileged: privileged,
+ Cel: Celebration{Slug: slug, Rank: RankSolemnity, Class: ClassLord, Colour: col, Layer: "temporal"}}
+}
+
+func feastOfLord(season Season, slug string, col Colour) temporalDay {
+ return temporalDay{Season: season, Colour: col, Rank: RankFeast, Class: ClassLord,
+ Cel: Celebration{Slug: slug, Rank: RankFeast, Class: ClassLord, Colour: col, Layer: "temporal"}}
+}
+
+func sundayDay(season Season, slug string, col Colour, week int) temporalDay {
+ priv := season == Advent || season == Lent || season == Easter_
+ td := temporalDay{Season: season, Colour: col, Week: week, Rank: RankSolemnity, Privileged: priv, Sunday: !priv,
+ Cel: Celebration{Slug: slug, Rank: RankSolemnity, Colour: col, Layer: "temporal"}}
+ return td
+}
+
+func ferialDay(season Season, slug string, col Colour, week int, privFeria bool) temporalDay {
+ return temporalDay{Season: season, Colour: col, Week: week, Rank: RankFerial, PrivFeria: privFeria,
+ Cel: Celebration{Slug: slug, Rank: RankFerial, Colour: col, Layer: "temporal"}}
+}
+
+// temporal computes the temporal identity of date under the given Selection.
+func temporal(date time.Time, sel Selection) temporalDay {
+ date = date.UTC().Truncate(24 * time.Hour)
+ y := date.Year()
+ easter := Easter(y)
+ ashWed := easter.AddDate(0, 0, -46)
+ palmSunday := easter.AddDate(0, 0, -7)
+ holyThu := easter.AddDate(0, 0, -3)
+ pentecost := easter.AddDate(0, 0, 49)
+ adventThis := adventStart(y)
+ christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC)
+ baptismThis := baptismOfLord(y, sel)
+ christTheKing := adventThis.AddDate(0, 0, -7)
+ wd := date.Weekday()
+ sun := wd == time.Sunday
+
+ // 1. Movable / dated solemnities and feasts (highest identity first).
+ switch {
+ case sameDay(date, easter):
+ return solemn(Easter_, "easter-sunday", White, true)
+ case date.After(easter) && !date.After(easter.AddDate(0, 0, 7)):
+ // Octave of Easter (Mon..Sun); easter+7 = 2nd Sunday of Easter.
+ td := solemn(Easter_, "easter-octave-"+weekdayAbbr(wd), White, true)
+ td.Week = 1
+ return td
+ case sameDay(date, ascension(y, sel)):
+ return solemn(Easter_, "ascension", White, true)
+ case sameDay(date, pentecost):
+ return solemn(Easter_, "pentecost", Red, true)
+ case sameDay(date, easter.AddDate(0, 0, 56)):
+ return solemn(Ordinary, "trinity-sunday", White, false)
+ case sameDay(date, corpusChristi(y, sel)):
+ return solemn(Ordinary, "corpus-christi", White, false)
+ case sameDay(date, easter.AddDate(0, 0, 68)):
+ return solemn(Ordinary, "sacred-heart", White, false)
+ case sameDay(date, christTheKing):
+ return solemn(Ordinary, "christ-the-king", White, false)
+ case sameDay(date, epiphany(y, sel)):
+ return solemn(Christmas, "epiphany", White, true)
+ case sameDay(date, baptismThis):
+ return feastOfLord(Christmas, "baptism-of-the-lord", White)
+ case sameDay(date, holyFamily(y)):
+ return feastOfLord(Christmas, "holy-family", White)
+ }
+
+ // 2. Seasons by span (chronological within the calendar year).
+ switch {
+ case !date.Before(holyThu) && date.Before(easter): // Triduum: Thu, Fri, Sat
+ col := White
+ if wd == time.Friday {
+ col = Red
+ } else if wd == time.Saturday {
+ col = Violet
+ }
+ td := solemn(Triduum, "triduum-"+weekdayAbbr(wd), col, true)
+ return td
+
+ case !date.Before(palmSunday) && date.Before(holyThu): // Palm Sunday + Holy Week Mon-Wed
+ if sun {
+ return sundayDay(Lent, "palm-sunday", Red, 6)
+ }
+ return ferialDay(Lent, "holy-week-"+weekdayAbbr(wd), Violet, 6, true)
+
+ case !date.Before(ashWed) && date.Before(palmSunday): // Lent
+ if !date.Before(ashWed) && date.Before(ashWed.AddDate(0, 0, 4)) {
+ // Ash Wednesday through the Saturday after (before Lent I).
+ return ferialDay(Lent, "lent-after-ashes-"+weekdayAbbr(wd), Violet, 0, true)
+ }
+ lent1 := nextWeekday(ashWed, time.Sunday)
+ week := daysBetween(lent1, date)/7 + 1
+ col := Violet
+ if week == 4 {
+ col = Rose // Laetare
+ }
+ if sun {
+ return sundayDay(Lent, "lent-sunday-"+itoa(week), col, week)
+ }
+ return ferialDay(Lent, "lent-"+itoa(week)+"-"+weekdayAbbr(wd), Violet, week, true)
+
+ case !date.Before(easter) && !date.After(pentecost): // Easter season
+ week := daysBetween(easter, date)/7 + 1
+ if sun {
+ return sundayDay(Easter_, "easter-sunday-"+itoa(week), White, week)
+ }
+ return ferialDay(Easter_, "easter-"+itoa(week)+"-"+weekdayAbbr(wd), White, week, false)
+
+ case !date.Before(adventThis) && date.Before(christmasThis): // Advent
+ week := daysBetween(adventThis, date)/7 + 1
+ col := Violet
+ if week == 3 {
+ col = Rose // Gaudete
+ }
+ privFeria := date.Month() == time.December && date.Day() >= 17
+ if sun {
+ return sundayDay(Advent, "advent-sunday-"+itoa(week), col, week)
+ }
+ return ferialDay(Advent, "advent-"+itoa(week)+"-"+weekdayAbbr(wd), Violet, week, privFeria)
+
+ case !date.Before(christmasThis): // Dec 25..Dec 31 — Christmas octave
+ return ferialDay(Christmas, "christmas-octave-"+weekdayAbbr(wd), White, 0, false)
+
+ case !date.After(baptismThis): // Jan 1..Baptism — Christmas season
+ if sun {
+ return sundayDay(Christmas, "christmas-sunday-"+weekdayAbbr(wd), White, 0)
+ }
+ return ferialDay(Christmas, "christmas-"+weekdayAbbr(wd), White, 0, false)
+
+ default: // Ordinary Time (span 1: after Baptism..before Lent; span 2: after Pentecost..before Advent)
+ var week int
+ if date.Before(ashWed) {
+ week = daysBetween(baptismThis, date)/7 + 1
+ } else {
+ // count back from Christ the King = the 34th week.
+ week = 34 - daysBetween(date, christTheKing)/7
+ }
+ if week < 1 {
+ week = 1
+ }
+ if sun {
+ return sundayDay(Ordinary, "ordinary-sunday-"+itoa(week), Green, week)
+ }
+ return ferialDay(Ordinary, "ordinary-"+itoa(week)+"-"+weekdayAbbr(wd), Green, week, false)
+ }
+}
+
+func itoa(n int) string { return strconv.Itoa(n) }