package calendar import ( "strconv" "strings" "time" ) // Extraordinary Form (1962) season constants. Advent/Christmas/Lent/Easter are // shared with the OF; these are EF-specific. const ( Septuagesima Season = "septuagesima" Passiontide Season = "passiontide" TimeAfterEpiphany Season = "time-after-epiphany" TimeAfterPentecost Season = "time-after-pentecost" ) // efChristTheKing is the last Sunday of October (the 1962 date — unlike the OF's // last Sunday before Advent). func efChristTheKing(year int) time.Time { oct31 := time.Date(year, 10, 31, 0, 0, 0, 0, time.UTC) return oct31.AddDate(0, 0, -int(oct31.Weekday())) // Sunday on/before Oct 31 } // efColour is the default (Sunday/ferial) colour of an EF season. func efColour(s Season) Colour { switch s { case Advent, Septuagesima, Lent, Passiontide: return Violet case Christmas, Easter_: return White default: // TimeAfterEpiphany, TimeAfterPentecost return Green } } // efSeason returns the 1962 season for date, by chronological date boundaries. // efSlugSeason is the season used to NAME slugs and NUMBER weeks. It is not // always the liturgical season: 6-13 January is liturgically Christmas Time // (RG 72, see efSeason) while its days are still named and numbered from // Epiphany, which is what the lectionary keys and every downstream consumer // expect. Splitting the two is the whole point -- before this, one function // served both and the wrong boundary in one was the wrong boundary in both. func efSlugSeason(date time.Time, y int, easter time.Time) Season { adventThis := adventStart(y) christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC) jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC) septStart := easter.AddDate(0, 0, -63) // Septuagesima Sunday ashWed := easter.AddDate(0, 0, -46) passionSun := easter.AddDate(0, 0, -14) paschalEnd := easter.AddDate(0, 0, 55) // Saturday after Pentecost; Trinity = +56 switch { case !date.Before(adventThis) && date.Before(christmasThis): return Advent case !date.Before(christmasThis): // Dec 25..31 return Christmas case date.Before(jan6): // Jan 1..5 return Christmas case date.Before(septStart): // Epiphany .. before Septuagesima return TimeAfterEpiphany case date.Before(ashWed): return Septuagesima case date.Before(passionSun): return Lent case date.Before(easter): return Passiontide case !date.After(paschalEnd): return Easter_ default: return TimeAfterPentecost } } func efCel(season Season, slug string, col Colour, rank Rank, week int) temporalDay { return temporalDay{ Season: season, Colour: col, Week: week, Rank: rank, Sunday: false, Cel: Celebration{Slug: slug, Rank: rank, Colour: col, Layer: "temporal"}, } } // efSunday is efCel plus the Sunday flag, for the temporal candidates that // are actual Sundays -- precedenceEF's tie-break needs to tell a Sunday (RG // 91 entry 15, wins its tie) apart from an equal-class privileged FERIA // (entry 18, the Ember/late-Advent days, which yields). func efSunday(season Season, slug string, col Colour, rank Rank, week int) temporalDay { day := efCel(season, slug, col, rank, week) day.Sunday = true return day } // temporalEF computes the 1962 temporal identity of date. Season is exact // (oracle-validated); ranks/weeks are best-effort for display. // efSeason is the LITURGICAL season, which is what the colour follows. // // It differs from efSlugSeason on exactly one window. RG 72: "Tempus // natalicium decurrit a I Vesperis Nativitatis Domini usque ad diem 13 // ianuarii INCLUSIVE" -- Christmas Time runs to 13 January, so 6-13 January is // the Epiphany SECTION OF Christmas Time, not the start of Time after // Epiphany. RG 119(a) backs the same boundary from the colour side, white // "usque ad expletum tempus Epiphaniae". Those eight days were coming out // green because this distinction did not exist. // // The slug season is deliberately left alone there: the days keep their // Epiphany names and week numbers, which is what the lectionary keys and the // generated downstream tables are built on. colitur, which models the two // axes separately from the start, does the same -- season christmastide, // slug ef-time-after-epiphany-sunday-1. func efSeason(date time.Time, y int, easter time.Time) Season { if s := efSlugSeason(date, y, easter); s == TimeAfterEpiphany && date.Month() == time.January && date.Day() <= 13 { return Christmas } return efSlugSeason(date, y, easter) } func temporalEF(date time.Time) temporalDay { date = date.UTC().Truncate(24 * time.Hour) y := date.Year() easter := Easter(y) slugSeason := efSlugSeason(date, y, easter) season := efSeason(date, y, easter) col := efColour(season) // The Octave of Pentecost (Whit Monday through the Ember Saturday, easter+50 // to +55) is red, not the white of the rest of Paschaltide. if !date.Before(easter.AddDate(0, 0, 50)) && !date.After(easter.AddDate(0, 0, 55)) { col = Red } // Holy Thursday (the Mass of Chrism and the Mass in Cena Domini) is white, // a whole-Mass exception to Passiontide's violet -- RG 128(b)'s own named // exception list, and RG 122 stating the same fact affirmatively in the // White section. if sameDay(date, easter.AddDate(0, 0, -3)) { col = White } // Good Friday is BLACK. RG 128(b)'s exception list carries it in the same // sentence as Holy Thursday's -- "...Actione liturgica feria VI in Passione // et Morte Domini usque ad Communionem exclusive..." excepts the day from // Passiontide's violet -- and RG 132 assigns black to it. Adopted // 2026-08-18 from the sibling project colitur, which closed the same gap // against the Missal; this comment previously called it "a separate, // unmodelled gap" and it no longer is. // // The rubric is per-ACTION ("usque ad Communionem exclusive", violet // returns for the Communion rite) and this model carries one colour per // day, so black is the day's principal colour -- the same acknowledged // limit the Palm Sunday blessing already has. missalemeum's own colour set // for the day is "bv", black first, so even the upstream this file is // generated against agrees on the ordering. if sameDay(date, easter.AddDate(0, 0, -2)) { col = Black } sun := date.Weekday() == time.Sunday // Major feasts of the Lord (I class): nice titles + colour. switch { case date.Month() == time.December && date.Day() == 25: return efCel(Christmas, "ef-nativity", White, RankClass1, 0) case date.Month() == time.January && date.Day() == 1: return efCel(Christmas, "ef-circumcision", White, RankClass1, 0) case date.Month() == time.January && date.Day() == 6: // Christmas, not TimeAfterEpiphany: RG 72 puts Epiphany INSIDE Christmas // Time, which runs to 13 January. The slug is unchanged. return efCel(Christmas, "ef-epiphany", White, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, -46)): return efCel(Lent, "ef-ash-wednesday", Violet, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, -14)): return efCel(Passiontide, "ef-passion-sunday", Violet, RankClass1, 1) case sameDay(date, easter.AddDate(0, 0, -7)): return efCel(Passiontide, "ef-palm-sunday", Violet, RankClass1, 2) case sameDay(date, easter): return efCel(Easter_, "ef-easter-sunday", White, RankClass1, 1) case sameDay(date, easter.AddDate(0, 0, 7)): return efCel(Easter_, "ef-low-sunday", White, RankClass1, 2) case sameDay(date, easter.AddDate(0, 0, 39)): return efCel(Easter_, "ef-ascension", White, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, 49)): return efCel(Easter_, "ef-pentecost", Red, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, 56)): return efCel(TimeAfterPentecost, "ef-trinity", White, RankClass1, 1) case sameDay(date, easter.AddDate(0, 0, 60)): return efCel(TimeAfterPentecost, "ef-corpus-christi", White, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, 68)): return efCel(TimeAfterPentecost, "ef-sacred-heart", White, RankClass1, 0) case sameDay(date, efChristTheKing(y)): return efCel(TimeAfterPentecost, "ef-christ-the-king", White, RankClass1, 0) case sameDay(date, easter.AddDate(0, 0, 38)): // Wednesday: Vigil of the Ascension (II class) return efCel(Easter_, "ef-ascension-vigil", White, RankClass2, 0) case sameDay(date, easter.AddDate(0, 0, 48)): // Saturday: Vigil of Pentecost / Whitsun Eve (I class) return efCel(Easter_, "ef-pentecost-vigil", Red, RankClass1, 0) } // Sundays vs ferias of the current season. if sun { week := efWeek(date, slugSeason, y, easter) rank := RankClass2 sundayColour := col if season == Advent || season == Lent { // RG 11-12 / RG 91 entry 6: every Sunday of Advent and every Sunday // of Lent is I class -- not only Advent I, the sole case previously // handled (Passiontide's own two Sundays, Passion and Palm, are // already I class via the named-feast switch above; Low Sunday // likewise). rank = RankClass1 switch { case season == Advent && week == 3: sundayColour = Rose // Gaudete -- RG 131, that Sunday's Office/Mass only case season == Lent && week == 4: sundayColour = Rose // Laetare -- RG 131, that Sunday's Office/Mass only } } // Resumed Sundays after Pentecost (1960 Rubrics): when Easter is early // there are more than 24 Sundays after Pentecost. The LAST Sunday before // Advent always keeps the 24th (Last) Mass; the surplus Sundays between the // 23rd and the last resume the Sundays after Epiphany that Septuagesima cut // short -- the highest-numbered ones, so the 6th sits just before the Last. if season == TimeAfterPentecost { lastSun := adventStart(y).AddDate(0, 0, -7) if sameDay(date, lastSun) { return efSunday(TimeAfterPentecost, "ef-time-after-pentecost-sunday-24", col, rank, 24) } if week > 23 { p := daysBetween(easter.AddDate(0, 0, 49), lastSun) / 7 // total Sundays after Pentecost e := week - p + 7 // resumed Sunday after Epiphany // Season stays time-after-pentecost (calendrical); the Epiphany // slug only routes the readings to the resumed Mass. return efSunday(TimeAfterPentecost, "ef-time-after-epiphany-sunday-"+strconv.Itoa(e), Green, rank, e) } } // Season reported liturgically, slug named from the slug season -- the // same split the resumed-Sunday branch just above already makes for // its own reason ("Season stays time-after-pentecost; the Epiphany // slug only routes the readings"). return efSunday(season, "ef-"+string(slugSeason)+"-sunday-"+strconv.Itoa(week), sundayColour, rank, week) } // The days between Ash Wednesday and the 1st Sunday of Lent have their own // proper Masses (not part of a numbered Lenten week). if season == Lent && date.Before(easter.AddDate(0, 0, -42)) { return efCel(Lent, "ef-lent-after-ashes-"+weekdayLower(date), Violet, RankClass3, 0) } week := efWeek(date, slugSeason, y, easter) rank := RankClass4 // per-annum feria if season == Advent || season == Lent || season == Passiontide { // III class ferias (1960 rubrics): Advent to Dec 16, and Lent/Passiontide. // These outrank III class feasts (the saint is only commemorated). The // Septuagesima season's ferias are IV class per annum, so a III class // saint (e.g. the Conversion of St Paul, Jan 25) displaces them. rank = RankClass3 } if season == Advent && date.Month() == time.December && date.Day() >= 17 && date.Day() <= 23 { // RG 91 entry 18: the late-Advent ferias (17-23 Dec) are II class // privileged ferias, not the ordinary III class of the rest of Advent. rank = RankClass2 } if season == Christmas && date.Month() == time.December && date.Day() >= 26 { // RG 67/68: "Dies infra octavam sunt II classis" -- days within the // Octave of the Nativity (26-31 Dec) are II class; the octave day // itself (1 Jan) is I class, already handled above as a named feast. rank = RankClass2 } if efPrivilegedFeria(date, easter) { // The days of Holy Week and the privileged octaves of Easter and // Pentecost are I class; no saint's feast is admitted (the feast is // transferred or, in the octaves, omitted). Ranking them class-1 makes // the temporal office win, matching the 1962 occurrence rules. rank = RankClass1 } // The Ember Days of September, Advent and Lent (Wednesday, Friday, Saturday // after the anchor Sunday) are II class privileged ferias in violet; they // displace a III class saint (only commemorated) -- RG 91 entry 18. The // Whitsun Ember days fall inside the I class Pentecost octave handled above. if es, ok := efEmberSlug(date, y, easter); ok { return efCel(season, es, Violet, RankClass2, week) } // Unique ferial slug (season-week-weekday) so proper ferias (Lent, Advent, // Holy Week, Ember days) can key their own readings; green-season ferias // simply have no lectionary entry and fall back to the Sunday. return efCel(season, "ef-"+string(slugSeason)+"-"+strconv.Itoa(week)+"-"+weekdayLower(date), col, rank, week) } // thirdSundayOfSeptember returns the third Sunday in September of year y. func thirdSundayOfSeptember(y int) time.Time { d := time.Date(y, time.September, 1, 0, 0, 0, 0, time.UTC) for d.Weekday() != time.Sunday { d = d.AddDate(0, 0, 1) } return d.AddDate(0, 0, 14) } // efEmberSlug returns the Ember-day slug for date, if it is an Ember Wednesday, // Friday or Saturday of September, Advent or Lent (the day after the anchor // Sunday + 3/5/6: the third Sunday of September, Advent III, or Lent I -- // MR1962, "De anno et eius partibus", Quatuor Tempora). Returns ("", false) // otherwise. The Whitsun Ember days are not listed here: they fall inside the // I class Pentecost octave, handled separately by efPrivilegedFeria. func efEmberSlug(date time.Time, y int, easter time.Time) (string, bool) { for _, e := range []struct { anchor time.Time season string }{ {thirdSundayOfSeptember(y), "september"}, {adventStart(y).AddDate(0, 0, 14), "advent"}, // 3rd Sunday of Advent {easter.AddDate(0, 0, -42), "lent"}, // 1st Sunday of Lent } { switch { case sameDay(date, e.anchor.AddDate(0, 0, 3)): return "ef-" + e.season + "-ember-wed", true case sameDay(date, e.anchor.AddDate(0, 0, 5)): return "ef-" + e.season + "-ember-fri", true case sameDay(date, e.anchor.AddDate(0, 0, 6)): return "ef-" + e.season + "-ember-sat", true } } return "", false } func weekdayLower(d time.Time) string { return strings.ToLower(d.Weekday().String()) } // efPrivilegedFeria reports whether date is an I class temporal weekday that // impedes saints' feasts: the ferias of Holy Week (the six days before Easter) // and the weekdays within the privileged octaves of Easter (Easter Mon–Sat) and // Pentecost (Whit Mon–Sat). Easter/Pentecost Sundays and Low Sunday are already // handled as named I class feasts above; this covers only their octave weekdays. func efPrivilegedFeria(date, easter time.Time) bool { days := int(date.Sub(easter) / (24 * time.Hour)) switch { case days >= -6 && days <= -1: // Monday..Saturday of Holy Week (incl. Triduum) return true case days >= 1 && days <= 6: // Easter octave weekdays return true case days >= 50 && days <= 55: // Pentecost octave weekdays (Pentecost = easter+49) return true } return false } // efWeek is a best-effort week-within-season number for display (not // oracle-checked). Time after Pentecost counts Sundays from Pentecost. func efWeek(date time.Time, season Season, y int, easter time.Time) int { switch season { case Advent: return daysBetween(adventStart(y), date)/7 + 1 case TimeAfterPentecost: return daysBetween(easter.AddDate(0, 0, 49), date) / 7 // Sundays after Pentecost case Lent: return daysBetween(easter.AddDate(0, 0, -42), date)/7 + 1 // from 1st Sunday of Lent case TimeAfterEpiphany: jan6 := time.Date(y, 1, 6, 0, 0, 0, 0, time.UTC) return daysBetween(jan6, date)/7 + 1 case Septuagesima: return daysBetween(easter.AddDate(0, 0, -63), date)/7 + 1 case Easter_: return daysBetween(easter, date)/7 + 1 default: return 0 } }