From 3bda15bfe7786ae97fac1cbf779b713cf4238846 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 13:58:19 +0200 Subject: feat(calendar): EF (1962) temporal cycle temporalEF + EF seasons 8 EF seasons by date boundary (Septuagesima, Passiontide, Time after Epiphany/ Pentecost + shared); major feasts of the Lord; Christ the King = last Sun of October. ParseRank accepts EF ranks. --- internal/calendar/temporal_ef.go | 146 ++++++++++++++++++++++++++++++++++ internal/calendar/temporal_ef_test.go | 40 ++++++++++ internal/calendar/types.go | 15 ++-- 3 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 internal/calendar/temporal_ef.go create mode 100644 internal/calendar/temporal_ef_test.go (limited to 'internal') diff --git a/internal/calendar/temporal_ef.go b/internal/calendar/temporal_ef.go new file mode 100644 index 0000000..725aac2 --- /dev/null +++ b/internal/calendar/temporal_ef.go @@ -0,0 +1,146 @@ +package calendar + +import "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. +func efSeason(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"}, + } +} + +// temporalEF computes the 1962 temporal identity of date. Season is exact +// (oracle-validated); ranks/weeks are best-effort for display. +func temporalEF(date time.Time) temporalDay { + date = date.UTC().Truncate(24 * time.Hour) + y := date.Year() + easter := Easter(y) + season := efSeason(date, y, easter) + col := efColour(season) + 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: + return efCel(TimeAfterEpiphany, "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) + } + + // Sundays vs ferias of the current season. + if sun { + rank := RankClass2 + if season == Advent && sameDay(date, adventStart(y)) { + rank = RankClass1 // 1st Sunday of Advent + } + return efCel(season, "ef-"+string(season)+"-sunday", col, rank, efWeek(date, season, y, easter)) + } + rank := RankClass4 // per-annum feria + if season == Advent || season == Lent || season == Passiontide || season == Septuagesima { + rank = RankClass3 // penitential ferias rank higher + } + return efCel(season, "ef-"+string(season)+"-feria", col, rank, efWeek(date, season, y, easter)) +} + +// 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 + default: + return 0 + } +} diff --git a/internal/calendar/temporal_ef_test.go b/internal/calendar/temporal_ef_test.go new file mode 100644 index 0000000..6d632e2 --- /dev/null +++ b/internal/calendar/temporal_ef_test.go @@ -0,0 +1,40 @@ +package calendar + +import "testing" + +func TestTemporalEFSeasons(t *testing.T) { + cases := []struct { + date string + season Season + }{ + {"2025-01-01", Christmas}, // Circumcision + {"2025-01-20", TimeAfterEpiphany}, // after II Sunday after Epiphany + {"2025-02-16", Septuagesima}, // Septuagesima Sunday (easter-63) + {"2025-03-05", Lent}, // Ash Wednesday + {"2025-04-06", Passiontide}, // Passion Sunday (easter-14) + {"2025-04-13", Passiontide}, // Palm Sunday + {"2025-04-20", Easter_}, // Easter + {"2025-06-08", Easter_}, // Pentecost (still Paschaltide) + {"2025-06-15", TimeAfterPentecost}, // Trinity + {"2025-06-22", TimeAfterPentecost}, // II Sunday after Pentecost + {"2025-10-26", TimeAfterPentecost}, // Christ the King + {"2025-11-30", Advent}, // I Sunday of Advent + {"2025-12-25", Christmas}, // Nativity + } + for _, c := range cases { + got := temporalEF(d(c.date)) + if got.Season != c.season { + t.Errorf("temporalEF(%s).Season = %s, want %s", c.date, got.Season, c.season) + } + } +} + +func TestTemporalEFChristTheKing(t *testing.T) { + // EF: last Sunday of October (2025-10-26), not the last before Advent. + if got := temporalEF(d("2025-10-26")); got.Cel.Slug != "ef-christ-the-king" { + t.Errorf("2025-10-26 slug = %q want ef-christ-the-king", got.Cel.Slug) + } + if got := efChristTheKing(2025).Format("2006-01-02"); got != "2025-10-26" { + t.Errorf("efChristTheKing(2025) = %s want 2025-10-26", got) + } +} diff --git a/internal/calendar/types.go b/internal/calendar/types.go index 0ccef8b..298e49d 100644 --- a/internal/calendar/types.go +++ b/internal/calendar/types.go @@ -137,16 +137,13 @@ type LiturgicalDay struct { WeekdayCycle string // "I" | "II" } +// ParseRank validates a rank token (OF or EF vocabulary), falling back to +// RankFerial for anything unknown. func ParseRank(s string) Rank { - switch s { - case "solemnity": - return RankSolemnity - case "feast": - return RankFeast - case "memorial": - return RankMemorial - case "optional": - return RankOptional + switch Rank(s) { + case RankSolemnity, RankFeast, RankMemorial, RankOptional, RankFerial, + RankClass1, RankClass2, RankClass3, RankClass4, RankCommemoration: + return Rank(s) default: return RankFerial } -- cgit v1.3