aboutsummaryrefslogtreecommitdiff
path: root/internal/calendar/calendar.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-14 13:22:16 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-14 13:22:16 +0200
commit913974b10a993af251b25125d1a417c452ad785c (patch)
tree5dd82811c100de6daa995bebd115a12434296508 /internal/calendar/calendar.go
parentd7da4b09f7775276231d0241cfe2700d247728ee (diff)
parent3b32c002d3eddda5ece9422442717657b9fee63b (diff)
downloadlectio-913974b10a993af251b25125d1a417c452ad785c.tar.gz
lectio-913974b10a993af251b25125d1a417c452ad785c.zip
Merge branch 'polish-ui-and-calendar': the gomobile facade and the EF calendar fixes
Two bodies of work that shared a branch. The gomobile facade (2026-08-03..05): mobile.PartLabels, Days, and the observed rank on DayInfo, so dlectio stops hardcoding part IDs and rank strings; the 1962 part labels become i18n data; the documented gomobile bind command is corrected so it reproduces the shipped .aar. The EF calendar fixes (2026-08-12): seven defects found by differencing this engine against colitur, a second 1962 implementation built from the Missal's General Rubrics rather than from this codebase. RG 96 transfers were not skipping II-class days; a II-class privileged feria was not yielding to a feast; Sunday ranks, the two Rose Sundays and Holy Thursday's colour were wrong; and scripts/gen-sanctoral-ef inferred ranks, deduped and tagged classes wrongly, which put 15 III-class feasts into the shipped tridentine-calendar.ini as bare commemorations and dropped four entries outright. Holy Thursday was violet in both engines, which is how a shared lineage hides a defect: this project's ini is generated from missalemeum and colitur's data was bootstrapped from here, so an error inherited by both is invisible to a differential. It took the Missal itself to see it. The EF oracle test now asserts rank and colour, not season alone. One known gap is recorded in the source rather than fixed, as out of scope: RG 95 chained transfers (calendar.go).
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