aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/calendar.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/calendar/calendar.go')
-rw-r--r--internal/calendar/calendar.go141
1 files changed, 119 insertions, 22 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
index 2d8611a..d918547 100644
--- a/internal/calendar/calendar.go
+++ b/internal/calendar/calendar.go
@@ -62,6 +62,12 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
}
isClass1 := func(r Rank) bool { return r == RankClass1 }
isClass1Or2 := func(r Rank) bool { return r == RankClass1 || r == RankClass2 }
+ occ1 := func(d time.Time, except string) bool { return occupiedByRank(d, except, isClass1) }
+ occ1Or2 := func(d time.Time, except string) bool { return occupiedByRank(d, except, isClass1Or2) }
+ // RG 97/98: the year's impeded I-class transfers are resolved as a set,
+ // not one at a time, so two feasts impeded by the same early Easter cannot
+ // both claim the same free day and lose one of themselves.
+ plan := efTransferPlan(year, merged, sel, occ1, occ1Or2)
cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season, Sunday: td.Sunday}}
for slug, rc := range merged {
cel := buildCelebration(slug, rc)
@@ -69,9 +75,18 @@ func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
if !ok {
continue
}
- effective := transferIfImpededEF(cel, when,
- func(d time.Time) bool { return occupiedByRank(d, cel.Slug, isClass1) },
- func(d time.Time) bool { return occupiedByRank(d, cel.Slug, isClass1Or2) })
+ // The Holy Family propers' own rubric: on a 13 January that IS the
+ // Sunday after Epiphany the Baptism is omitted "sine commemoratione",
+ // so the candidate is dropped rather than ranked. See efBaptismOmitted.
+ if efBaptismOmitted(date) && strings.Contains(cel.Slug, "commemoration-of-the-baptism") {
+ continue
+ }
+ effective, planned := plan[cel.Slug]
+ if !planned {
+ effective = transferIfImpededEF(cel, when,
+ func(d time.Time) bool { return occ1(d, cel.Slug) },
+ func(d time.Time) bool { return occ1Or2(d, cel.Slug) })
+ }
if sameDay(effective, date) {
cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season})
}
@@ -226,6 +241,98 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
return day
}
+// efTransferPlan resolves ALL of a year's impeded I-class transfers together,
+// which RG 97/98 require and which resolving them one at a time cannot do.
+//
+// RG 96(a) is applied first and is decisive for the only collision the
+// universal calendar actually produces: the Annunciation, when transferred
+// after Easter, has a SEDES PROPRIA -- "festum Annuntiationis B. Mariae Virg.,
+// quando est transferendum post Pascha, transfertur, tamquam in sedem propriam,
+// in feriam II post dominicam in albis". A proper seat is fixed by law, so it
+// does not queue and no tie-break decides it: it claims the Monday after Low
+// Sunday and everything else walks around it.
+//
+// The remainder then transfer in RG 98's order -- "servetur ordo quo in tabella
+// praecedentiae inscribuntur; in paritate autem Officium prius impeditum
+// praecedit" (table order; at equal position the office impeded FIRST goes
+// first) -- each claiming its target so the next walks past it.
+//
+// Worked example, 2035 (Easter 25 March, Low Sunday 1 April): the Annunciation
+// takes its proper seat on 2 April; St Joseph, impeded on the 19th, walks past
+// Holy Week, the Easter octave and that claimed Monday to 3 April. Before this,
+// St Joseph was observed on no day of 2008, 2035 or 2046 at all.
+func efTransferPlan(year int, merged map[string]RawCelebration, sel Selection,
+ occupiedByClass1, occupiedByClass1Or2 func(time.Time, string) bool) map[string]time.Time {
+
+ type pending struct {
+ slug string
+ cel Celebration
+ when time.Time // the date it is impeded ON
+ band int // RG 91 table position, lower = higher dignity
+ }
+ var queue []pending
+ plan := map[string]time.Time{}
+ claimed := map[string]bool{}
+ claim := func(d time.Time) { claimed[d.Format("2006-01-02")] = true }
+
+ easter := Easter(year)
+ lowSundayMonday := easter.AddDate(0, 0, 8) // feria II post dominicam in albis
+
+ for slug, rc := range merged {
+ cel := buildCelebration(slug, rc)
+ if cel.Rank != RankClass1 {
+ continue
+ }
+ when, ok := celebrationDate(cel, year, sel)
+ if !ok {
+ continue
+ }
+ st := temporalEF(when)
+ tCand := candidate{Cel: st.Cel, Temporal: true, Season: st.Season, Sunday: st.Sunday}
+ sCand := candidate{Cel: cel, Temporal: false}
+ if precedenceEF(tCand) >= precedenceEF(sCand) && !occupiedByClass1(when, cel.Slug) {
+ continue // not impeded; stays put
+ }
+ // RG 96(a): a proper seat, claimed before anything queues. Guarded to
+ // a transfer that is genuinely "post Pascha" -- 25 March inside Holy
+ // Week or the Easter octave -- not to any impediment whatever.
+ if strings.Contains(cel.Slug, "annunciation") &&
+ !when.Before(easter.AddDate(0, 0, -7)) && !when.After(easter.AddDate(0, 0, 7)) {
+ plan[cel.Slug] = lowSundayMonday
+ claim(lowSundayMonday)
+ continue
+ }
+ queue = append(queue, pending{slug: cel.Slug, cel: cel, when: when, band: precedenceEF(sCand)})
+ }
+
+ // RG 98: table order, then the office impeded first.
+ sort.Slice(queue, func(i, j int) bool {
+ if queue[i].band != queue[j].band {
+ return queue[i].band < queue[j].band
+ }
+ if !queue[i].when.Equal(queue[j].when) {
+ return queue[i].when.Before(queue[j].when)
+ }
+ return queue[i].slug < queue[j].slug
+ })
+
+ for _, p := range queue {
+ day := p.when.AddDate(0, 0, 1)
+ for i := 0; i < 60; i++ {
+ b := temporalEF(day)
+ isHighClass := b.Cel.Rank == RankClass1 || b.Cel.Rank == RankClass2
+ if isHighClass || occupiedByClass1Or2(day, p.cel.Slug) || claimed[day.Format("2006-01-02")] {
+ day = day.AddDate(0, 0, 1)
+ continue
+ }
+ break
+ }
+ plan[p.slug] = day
+ claim(day)
+ }
+ return plan
+}
+
// transferIfImpededEF moves an EF sanctoral feast off a date it cannot be kept
// on. Two rules cover the 1962 cases: All Souls, when Nov 2 is a Sunday, is
// transferred to the Monday (Nov 3); and a I class feast genuinely impeded is
@@ -256,25 +363,15 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
// candidate's own transfer walk in isolation. It has no way to notice that a
// SECOND, separately-transferred I-class candidate has landed on the exact
// same destination day. Concretely: St Joseph (19 March) and the
-// Annunciation (25 March) can both be walked, independently, to the Monday
-// after Low Sunday in the same year (2008, 2035, 2046 -- e.g. 2035-04-02:
-// `annunciation-of-the-blessed-virgin-mary … +joseph-spouse-of-the-bl-virgin-mary`).
-// RG 95 grants the right of translation "solummodo festis I classis" (to
-// I-class feasts ONLY) -- so BOTH have that right, and the day they land on
-// together is itself an ordinary occurrence collision RG 97/98 governs (the
-// higher table position is kept, the other transfers FURTHER): this
-// function does not re-walk the loser, it lets pickEF's plain alphabetical
-// slug tie-break settle it, so the loser is merely commemorated instead of
-// continuing its own walk one more day. RG 98 itself supplies the missing
-// determinism rule for the tie this collision even needs: "in paritate
-// autem Officium prius impeditum praecedit" -- at equal table position, the
-// office impeded FIRST takes precedence -- which is chronological (Joseph,
-// impeded on the 19th, before the Annunciation's own walk begins on the
-// 25th) and may favour Joseph over pickEF's alphabetical fallback. Neither
-// RG 98's tie rule nor the re-walk RG 97 implies is implemented; fixing this
-// properly means resolving occurrence between two ALREADY-TRANSFERRED
-// candidates, not a single one, which is out of this function's current
-// shape.
+// Annunciation (25 March) are both impeded in an early-Easter year (2008,
+// 2035, 2046) and, resolved independently, both walk to the Monday after Low
+// Sunday -- where one of them then loses pickEF and vanishes from the year
+// entirely. RG 95 grants the right of translation "solummodo festis I
+// classis", so both genuinely have it and both must land SOMEWHERE.
+//
+// That collision is no longer settled here: efTransferPlan resolves the year's
+// impeded I-class feasts together, as RG 97/98 require, and hands each one its
+// target. This function keeps the single-candidate walk and All Souls only.
func transferIfImpededEF(cel Celebration, when time.Time, occupiedByClass1, occupiedByClass1Or2 func(time.Time) bool) time.Time {
if when.Month() == time.November && when.Day() == 2 && when.Weekday() == time.Sunday &&
strings.Contains(cel.Slug, "all-souls") {