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) cands := []candidate{{Cel: td.Cel, Temporal: true, Season: td.Season}} for slug, rc := range merged { cel := buildCelebration(slug, rc) when, ok := celebrationDate(cel, date.Year(), sel) if !ok { continue } effective := transferIfImpededEF(cel, when) 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 } // 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 { 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 } day := when 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 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" }