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.go124
1 files changed, 112 insertions, 12 deletions
diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go
index 33706d8..2d8611a 100644
--- a/internal/calendar/calendar.go
+++ b/internal/calendar/calendar.go
@@ -35,14 +35,43 @@ func TemporalSlug(date time.Time, sel Selection) string {
func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay {
td := temporalEF(date)
merged := mergeLayers(layers)
- cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season}}
+ year := date.Year()
+ // occupiedByRank reports whether some OTHER fixed-date sanctoral
+ // celebration whose rank passes `allowed` resolves onto d this year.
+ // transferIfImpededEF uses this at two different thresholds: class 1
+ // only, to decide whether a candidate is impeded in the first place (a
+ // class-2 occupant never impedes a class-1 feast -- class 1 always beats
+ // class 2 outright, no tie exists); and class 1 OR 2, for RG 96's "next
+ // day that is not I or II class" once a transfer is already under way
+ // (e.g. the Visitation, 2 July, blocking the Precious Blood's transfer
+ // off 1 July in 2011).
+ occupiedByRank := func(d time.Time, exceptSlug string, allowed func(Rank) bool) bool {
+ for slug2, rc2 := range merged {
+ if slug2 == exceptSlug {
+ continue
+ }
+ cel2 := buildCelebration(slug2, rc2)
+ if !allowed(cel2.Rank) {
+ continue
+ }
+ if when2, ok := celebrationDate(cel2, year, sel); ok && sameDay(when2, d) {
+ return true
+ }
+ }
+ return false
+ }
+ isClass1 := func(r Rank) bool { return r == RankClass1 }
+ isClass1Or2 := func(r Rank) bool { return r == RankClass1 || r == RankClass2 }
+ cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season, Sunday: td.Sunday}}
for slug, rc := range merged {
cel := buildCelebration(slug, rc)
- when, ok := celebrationDate(cel, date.Year(), sel)
+ when, ok := celebrationDate(cel, year, sel)
if !ok {
continue
}
- effective := transferIfImpededEF(cel, when)
+ 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) })
if sameDay(effective, date) {
cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season})
}
@@ -199,12 +228,54 @@ func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time
// 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 impeded by a I class
-// temporal day (Holy Week, the Easter octave) is pushed forward to the next free
-// day — for the Annunciation in Holy Week this lands on the Monday after Low
-// Sunday, past the whole privileged octave. Lower-rank feasts are only
-// commemorated, never transferred.
-func transferIfImpededEF(cel Celebration, when time.Time) time.Time {
+// transferred to the Monday (Nov 3); and a I class feast genuinely impeded is
+// pushed forward to the next day that is itself neither I nor II class (RG
+// 96: "the next following day that is not I or II class"). Lower-rank feasts
+// are only commemorated, never transferred (RG 95).
+//
+// occupiedByClass1 and occupiedByClass1Or2 are deliberately different
+// thresholds, not the same check reused twice:
+// - a candidate is impeded IN THE FIRST PLACE only by a genuine class-1
+// collision -- the temporal office of `when` is itself I class (a Sunday
+// of Advent/Lent/Passiontide, Holy Week, a named I-class feast), or
+// another FIXED I-class sanctoral feast already sits on `when` (RG
+// 97/98). A II-class occupant, temporal or sanctoral, never impedes a
+// I-class feast at all -- I class always outranks II class outright, no
+// tie-break is even reached -- so using the wider I-OR-II threshold here
+// would (and, before this fix, did: e.g. All Saints, 1 Nov, was wrongly
+// bumped to 3 Nov merely for landing on an ordinary II-class Sunday it
+// would have won outright) send an unimpeded I-class feast on an
+// unnecessary walk.
+// - once a transfer is under way, the DESTINATION must avoid landing on
+// ANY I- or II-class day (RG 96's own wider "not I or II class" text) --
+// e.g. the Visitation, 2 July, blocks the Precious Blood's transfer off 1
+// July in 2011, and 3 July's ordinary Sunday blocks it a second time.
+//
+// KNOWN GAP, not fixed here (recorded per RG 95, so the next person does not
+// have to rediscover it from scratch): this function resolves ONE
+// 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.
+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") {
return when.AddDate(0, 0, 1)
@@ -212,11 +283,40 @@ func transferIfImpededEF(cel Celebration, when time.Time) time.Time {
if cel.Rank != RankClass1 {
return when
}
- day := when
+ // Is cel actually impeded on `when` at all? Compare its OWN precedence
+ // (via precedenceEF, the same function pickEF uses to decide the day)
+ // against the temporal office's, rather than testing the temporal
+ // office's class in isolation -- a plain "is the temporal day I class"
+ // test wrongly impedes a candidate that would in fact WIN a tie against
+ // it: RG 91 entry 4 (the Immaculate Conception, the Assumption) sits
+ // ABOVE entry 6 (Sundays), so the Immaculate Conception is not impeded
+ // by the Advent Sunday it may fall on at all, unlike an ordinary I-class
+ // feast (entry 11, e.g. St Joseph), which is. occupiedByClass1 still
+ // covers the separate case of a competing FIXED I-class SANCTORAL
+ // feast, which this temporal-only comparison cannot see.
+ startTemporal := temporalEF(when)
+ tCand := candidate{Cel: startTemporal.Cel, Temporal: true, Season: startTemporal.Season, Sunday: startTemporal.Sunday}
+ sCand := candidate{Cel: cel, Temporal: false}
+ startImpeded := precedenceEF(tCand) < precedenceEF(sCand) || occupiedByClass1(when)
+ if !startImpeded {
+ return when
+ }
+ day := when.AddDate(0, 0, 1)
for i := 0; i < 30; i++ {
b := temporalEF(day)
- if precedenceEF(candidate{Cel: b.Cel, Temporal: true, Season: b.Season}) <= 2 {
- day = day.AddDate(0, 0, 1) // impeded by a I class temporal day; push forward
+ // A direct CLASS test, not precedenceEF's tie-break band: band
+ // encodes which of two EQUAL-class candidates wins a tie (e.g. a
+ // Sunday beats an equal-class feast, but a privileged Ember/late-
+ // Advent FERIA of the same class yields to one, defect 2) -- an
+ // unrelated question from RG 96's own "is this day itself I or II
+ // class" test. Reusing band here undercounted: a day within the
+ // Octave of the Nativity (26-31 Dec) is genuinely II class (RG 67),
+ // but as an ordinary, non-Sunday II-class temporal candidate its OWN
+ // band yields (5, not <=3) -- so band<=3 alone let an impeded I-class
+ // feast wrongly land there. isHighClass tests the class directly.
+ isHighClass := b.Cel.Rank == RankClass1 || b.Cel.Rank == RankClass2
+ if isHighClass || occupiedByClass1Or2(day) {
+ day = day.AddDate(0, 0, 1) // still I- or II-class; keep walking
continue
}
return day