package calendar import ( "sort" "strings" "time" ) // Compute returns the 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. Selection.Form "old" computes the // Extraordinary Form (1962); anything else computes the Ordinary Form. func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay { date = date.UTC().Truncate(24 * time.Hour) if sel.Form == "old" { return computeEF(date, sel, layers) } return computeOF(date, sel, layers) } // TemporalSlug returns the slug of date's TEMPORAL day (the season's Sunday or // feria), ignoring any sanctoral celebration that outranks it. The EF's // green-season weekday-repeats-the-Sunday rule uses this so a Monday still finds // its Sunday Mass even when that Sunday was displaced by a feast (e.g. the // Purification on Feb 2). func TemporalSlug(date time.Time, sel Selection) string { date = date.UTC().Truncate(24 * time.Hour) if sel.Form == "old" { return temporalEF(date).Cel.Slug } return temporal(date, sel).Cel.Slug } // computeEF computes the Extraordinary Form (1962) day. func computeEF(date time.Time, sel Selection, layers []Layer) LiturgicalDay { td := temporalEF(date) merged := mergeLayers(layers) 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 } 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. // // efTransferPlan is pure in (year, merged content, sel) and identical for // every day of the year it is asked about -- computeEF runs once PER DAY, // so a multi-day view (mobile.Days's week, a month view) was rebuilding // it from scratch on every single one. efTransferPlanCached memoises it; // see transfer_plan_cache.go for the cache key and why each of its three // parts is load-bearing. plan := efTransferPlanCached(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) when, ok := celebrationDate(cel, year, sel) if !ok { continue } // 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}) } } obs, others := pickEF(cands) day := LiturgicalDay{ Date: date, Season: td.Season, Week: td.Week, Weekday: date.Weekday(), Observed: obs.Cel, Colour: colourOf(obs, td), ObservedBand: precedenceEF(obs), } for _, o := range others { if o.Temporal { continue } day.Others = append(day.Others, o.Cel) } return day } // computeOF computes the Ordinary Form day. func computeOF(date time.Time, sel Selection, layers []Layer) LiturgicalDay { 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 && ofRankOrder(cel.Rank) < ofRankOrder(RankSolemnity) { continue } if td.Sunday && ofRankOrder(cel.Rank) < ofRankOrder(RankFeast) { continue } effective := transferIfImpeded(cel, when, sel) if sameDay(effective, date) { cands = append(cands, candidate{Cel: cel, Temporal: false, Season: td.Season}) } } // Movable Marian memorials that have no fixed date (so they cannot live in the // sanctoral file): Mary, Mother of the Church, and the Immaculate Heart. for _, mm := range movableMemorialsOF(date.Year()) { if sameDay(mm.when, date) { cands = append(cands, candidate{Cel: mm.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), ObservedBand: precedence(obs), SundayCycle: sundayCycle(liturgicalYearStart(date)), WeekdayCycle: weekdayCycle(liturgicalYearStart(date).Year() + 1), } for _, o := range others { if o.Temporal { continue // a superseded ferial/Sunday is not a commemoration } 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)) } // movableCel is a celebration whose date is computed (relative to Easter), not // read from a fixed-date layer. type movableCel struct { when time.Time cel Celebration } // movableMemorialsOF returns the Ordinary Form's two movable Marian memorials, // which have no fixed calendar date: Mary, Mother of the Church (the Monday after // Pentecost) and the Immaculate Heart of Mary (the Saturday after the Sacred // Heart, i.e. easter+69). func movableMemorialsOF(y int) []movableCel { e := Easter(y) mk := func(off int, slug, en, pl string, first, psalm, gospel string) movableCel { return movableCel{when: e.AddDate(0, 0, off), cel: Celebration{ Slug: slug, Rank: RankMemorial, Class: ClassBVM, Colour: White, Layer: "universal", Name: map[string]string{"en": en, "pl": pl}, // Proper readings (these memorials have no fixed-date entry to key from). Masses: []Mass{{Readings: []Reading{ {Part: "first", Citation: first}, {Part: "psalm", Citation: psalm}, {Part: "gospel", Citation: gospel}, }}}, }} } return []movableCel{ mk(50, "mary-mother-of-the-church", "Mary, Mother of the Church", "Najświętszej Maryi Panny, Matki Kościoła", "Genesis 3:9-15,20", "Psalms 87:1-2,3+5,6-7", "John 19:25-34"), mk(69, "the-immaculate-heart-of-the-blessed-virgin-mary", "The Immaculate Heart of the Blessed Virgin Mary", "Niepokalanego Serca Najświętszej Maryi Panny", "Isaiah 61:9-11", "1 Samuel 2:1,4-5,6-7,8abcd", "Luke 2:41-51"), } } // transferIfImpeded moves a solemnity off a band-1/2 temporal day to the next // free day. Non-solemnities are never transferred (they yield instead). // // Two impeded solemnities are ANTICIPATED (moved earlier) rather than deferred, // matching the General Roman Calendar's published transfers: // - St Joseph (Mar 19) falling in Holy Week goes to the Saturday before Palm // Sunday. (The Annunciation, by contrast, defers past the octave via the // generic forward walk below -- to the Monday after the 2nd Sunday of Easter.) // - The Nativity of St John the Baptist (Jun 24), when the day is a solemnity // of the Lord (the Sacred Heart or Corpus Christi falling on Jun 24), goes // to Jun 23. func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time { if cel.Rank != RankSolemnity { return when } if strings.Contains(cel.Slug, "joseph-husband") { if palm := Easter(when.Year()).AddDate(0, 0, -7); !when.Before(palm) { return palm.AddDate(0, 0, -1) // Holy Week -> Saturday before Palm Sunday } } if strings.Contains(cel.Slug, "john-the-baptist") { if td := temporal(when, sel); td.Cel.Rank == RankSolemnity && td.Class == ClassLord && !td.Sunday { return when.AddDate(0, 0, -1) // Sacred Heart / Corpus Christi on Jun 24 -> Jun 23 } } 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 } // 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 // 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) 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") { return when.AddDate(0, 0, 1) } if cel.Rank != RankClass1 { return 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) // 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 } 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 c.Rank == "" { c.Rank = RankFerial } 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" }