summaryrefslogtreecommitdiff
path: root/internal/calendar
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:21:15 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 12:21:15 +0200
commit0b68ec046c26292e368494e158cc36b342901a80 (patch)
tree87f6d9ba73c9ce827c263915e1ad511ba61084e8 /internal/calendar
parent4c08e94dbcce8dc7757afba3001abe509bd2b6a8 (diff)
downloadlectio-0b68ec046c26292e368494e158cc36b342901a80.tar.gz
lectio-0b68ec046c26292e368494e158cc36b342901a80.zip
feat(calendar): Compute — build celebrations, apply precedence + transfer, cycles
Also: deterministic pick tiebreak by slug.
Diffstat (limited to 'internal/calendar')
-rw-r--r--internal/calendar/calendar.go167
-rw-r--r--internal/calendar/calendar_test.go39
-rw-r--r--internal/calendar/precedence.go12
3 files changed, 216 insertions, 2 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
new file mode 100644
index 0000000..3227a5a
--- /dev/null
+++ b/internal/calendar/calendar.go
@@ -0,0 +1,167 @@
+package calendar
+
+import (
+ "sort"
+ "strings"
+ "time"
+)
+
+// Compute returns the Ordinary Form liturgical day for date under the given
+// Selection, with the ordered layer stack supplying celebrations. It is pure
+// and total: any valid Gregorian date yields a LiturgicalDay.
+func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
+ date = date.UTC().Truncate(24 * time.Hour)
+ td := temporal(date, sel)
+ merged := mergeLayers(layers)
+
+ cands := []candidate{{
+ Cel: td.Cel, Temporal: true, Privileged: td.Privileged,
+ PrivFeria: td.PrivFeria, Sunday: td.Sunday, Season: td.Season,
+ }}
+ for slug, rc := range merged {
+ cel := buildCelebration(slug, rc)
+ when, ok := celebrationDate(cel, date.Year(), sel)
+ if !ok {
+ continue
+ }
+ // On band-2 privileged days only solemnities survive (and they transfer);
+ // on Sundays (band 6) only feasts and solemnities compete.
+ if td.Privileged && cel.Rank < RankSolemnity {
+ continue
+ }
+ if td.Sunday && cel.Rank < RankFeast {
+ continue
+ }
+ effective := transferIfImpeded(cel, when, sel)
+ if sameDay(effective, date) {
+ cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season})
+ }
+ }
+
+ obs, others := pick(cands)
+ day := LiturgicalDay{
+ Date: date, Season: td.Season, Week: td.Week, Weekday: date.Weekday(),
+ Observed: obs.Cel, Colour: colourOf(obs, td),
+ SundayCycle: sundayCycle(liturgicalYearStart(date)),
+ WeekdayCycle: weekdayCycle(liturgicalYearStart(date).Year() + 1),
+ }
+ for _, o := range others {
+ day.Others = append(day.Others, o.Cel)
+ }
+ return day
+}
+
+func colourOf(obs candidate, td temporalDay) Colour {
+ if obs.Cel.Colour != "" {
+ return obs.Cel.Colour
+ }
+ return td.Colour
+}
+
+// celebrationDate resolves a sanctoral celebration's date for the year.
+func celebrationDate(cel Celebration, year int, sel Selection) (time.Time, bool) {
+ return resolveDate(cel.Date, year, Easter(year))
+}
+
+// transferIfImpeded moves a solemnity off a band-1/2 temporal day to the next
+// free day. Non-solemnities are never transferred (they yield instead).
+func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time {
+ if cel.Rank != RankSolemnity {
+ return when
+ }
+ day := when
+ for i := 0; i < 30; i++ {
+ b := temporal(day, sel)
+ band := precedence(candidate{
+ Cel: b.Cel, Temporal: true, Privileged: b.Privileged,
+ PrivFeria: b.PrivFeria, Sunday: b.Sunday, Season: b.Season,
+ })
+ if band <= 2 { // impeded by Triduum / a privileged day: push forward
+ day = day.AddDate(0, 0, 1)
+ continue
+ }
+ return day
+ }
+ return day
+}
+
+// buildCelebration types a RawCelebration.
+func buildCelebration(slug string, rc RawCelebration) Celebration {
+ c := Celebration{Slug: strings.TrimSpace(slug), Name: map[string]string{}, Layer: rc.Fields["layer"]}
+ for k, v := range rc.Fields {
+ switch {
+ case k == "rank":
+ c.Rank = ParseRank(v)
+ case k == "class":
+ c.Class = ParseClass(v)
+ case k == "colour":
+ c.Colour = ParseColour(v)
+ case k == "date":
+ c.Date = DateSpec(v)
+ case strings.HasPrefix(k, "name."):
+ c.Name[strings.TrimPrefix(k, "name.")] = v
+ }
+ }
+ if m := massFrom(rc.Fields); len(m.Readings) > 0 {
+ c.Masses = append(c.Masses, m)
+ }
+ var variants []string
+ for v := range rc.Variants {
+ variants = append(variants, v)
+ }
+ sort.Strings(variants)
+ for _, v := range variants {
+ mm := massFrom(rc.Variants[v])
+ mm.Variant = v
+ c.Masses = append(c.Masses, mm)
+ }
+ return c
+}
+
+// BuildCelebrationForTest exposes buildCelebration for cross-package data tests.
+func BuildCelebrationForTest(slug string, rc RawCelebration) Celebration {
+ return buildCelebration(slug, rc)
+}
+
+var readingParts = []string{"first", "psalm", "second", "acclamation", "gospel"}
+
+func massFrom(fields map[string]string) Mass {
+ var m Mass
+ for _, part := range readingParts {
+ if cit := fields["reading."+part]; cit != "" {
+ m.Readings = append(m.Readings, Reading{Part: part, Citation: cit})
+ }
+ }
+ return m
+}
+
+// liturgicalYearStart returns the First Sunday of Advent that opened the
+// liturgical year containing date.
+func liturgicalYearStart(date time.Time) time.Time {
+ a := adventStart(date.Year())
+ if date.Before(a) {
+ return adventStart(date.Year() - 1)
+ }
+ return a
+}
+
+// sundayCycle: the liturgical year's civil year (Advent year + 1) mod 3 →
+// C(0), A(1), B(2).
+func sundayCycle(start time.Time) string {
+ switch (start.Year() + 1) % 3 {
+ case 0:
+ return "C"
+ case 1:
+ return "A"
+ default:
+ return "B"
+ }
+}
+
+// weekdayCycle: odd liturgical-year civil year → I, even → II.
+func weekdayCycle(year int) string {
+ if year%2 == 1 {
+ return "I"
+ }
+ return "II"
+}
diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go
new file mode 100644
index 0000000..909f1d8
--- /dev/null
+++ b/internal/calendar/calendar_test.go
@@ -0,0 +1,39 @@
+package calendar
+
+import (
+ "testing"
+)
+
+func universalTest() Layer {
+ return Layer{ID: "universal", Type: "universal", Cels: map[string]RawCelebration{
+ "assumption": {Fields: map[string]string{
+ "date": "08-15", "rank": "solemnity", "class": "bvm", "colour": "white",
+ "name.en": "Assumption of the BVM", "reading.gospel": "Lk 1:39-56",
+ }, Variants: map[string]map[string]string{}},
+ " st-monday-optional ": {Fields: map[string]string{}, Variants: map[string]map[string]string{}},
+ }}
+}
+
+func TestComputeSolemnityBeatsFeria(t *testing.T) {
+ got := Compute(d("2025-08-15"), DefaultSelection(), []Layer{universalTest()})
+ if got.Observed.Slug != "assumption" {
+ t.Fatalf("2025-08-15 observed = %q want assumption", got.Observed.Slug)
+ }
+ if got.Colour != White || got.Observed.Rank != RankSolemnity {
+ t.Errorf("assumption colour/rank wrong: %s / %v", got.Colour, got.Observed.Rank)
+ }
+ if len(got.Observed.Masses) == 0 || got.Observed.Masses[0].Readings[0].Part != "gospel" {
+ t.Errorf("proper reading not surfaced: %+v", got.Observed.Masses)
+ }
+}
+
+func TestComputeCycles(t *testing.T) {
+ // Advent 2024 opens liturgical year 2025 → Sunday cycle C, weekday cycle I.
+ got := Compute(d("2025-01-15"), DefaultSelection(), []Layer{universalTest()})
+ if got.SundayCycle != "C" {
+ t.Errorf("2024-25 Sunday cycle = %s want C", got.SundayCycle)
+ }
+ if got.WeekdayCycle != "I" {
+ t.Errorf("2025 weekday cycle = %s want I", got.WeekdayCycle)
+ }
+}
diff --git a/internal/calendar/precedence.go b/internal/calendar/precedence.go
index b013b77..7919879 100644
--- a/internal/calendar/precedence.go
+++ b/internal/calendar/precedence.go
@@ -62,10 +62,18 @@ func precedence(c candidate) int {
}
// pick returns the highest-precedence candidate as observed and the rest as
-// others (commemorations / optional memorials), stably ordered by precedence.
+// others (commemorations / optional memorials). Ordering is deterministic:
+// by precedence band, then by slug, so equal-band collisions resolve the same
+// way on every run.
func pick(cands []candidate) (candidate, []candidate) {
sorted := make([]candidate, len(cands))
copy(sorted, cands)
- sort.SliceStable(sorted, func(i, j int) bool { return precedence(sorted[i]) < precedence(sorted[j]) })
+ sort.SliceStable(sorted, func(i, j int) bool {
+ pi, pj := precedence(sorted[i]), precedence(sorted[j])
+ if pi != pj {
+ return pi < pj
+ }
+ return sorted[i].Cel.Slug < sorted[j].Cel.Slug
+ })
return sorted[0], sorted[1:]
}