diff options
Diffstat (limited to 'internal/calendar')
| -rw-r--r-- | internal/calendar/temporal.go | 241 | ||||
| -rw-r--r-- | internal/calendar/temporal_test.go | 53 |
2 files changed, 294 insertions, 0 deletions
diff --git a/internal/calendar/temporal.go b/internal/calendar/temporal.go new file mode 100644 index 0000000..011e023 --- /dev/null +++ b/internal/calendar/temporal.go @@ -0,0 +1,241 @@ +package calendar + +import ( + "strconv" + "time" +) + +// temporalDay is the temporal (season/movable) identity of a date, before any +// sanctoral celebration is considered. Privileged marks band 1-2 days (Sundays +// of Advent/Lent/Easter, Holy Week, Triduum, Easter octave, the great +// solemnities); PrivFeria marks band-9 privileged ferias (Advent 17-24, +// Lenten weekdays); Sunday marks an Ordinary/Christmas Sunday (band 6). +type temporalDay struct { + Season Season + Week int + Colour Colour + Cel Celebration + Rank Rank + Class Class + Privileged bool + PrivFeria bool + Sunday bool +} + +// adventStart returns the First Sunday of Advent that opens the liturgical year +// containing Christmas of the given calendar year: the 4th Sunday before Dec 25. +func adventStart(year int) time.Time { + xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC) + sun := xmas.AddDate(0, 0, -int(xmas.Weekday())) // Sunday on/before Christmas (Sunday=0) + return sun.AddDate(0, 0, -21) +} + +func sameDay(a, b time.Time) bool { + return a.Year() == b.Year() && a.YearDay() == b.YearDay() +} + +// daysBetween is the whole-day count from..to (both UTC midnight). +func daysBetween(from, to time.Time) int { + return int(to.Sub(from).Hours()/24 + 0.5) +} + +func weekdayAbbr(wd time.Weekday) string { + return [...]string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}[wd] +} + +// epiphany is fixed Jan 6, or (sel.Epiphany=="sunday") the Sunday between Jan 2 and Jan 8. +func epiphany(year int, sel Selection) time.Time { + if sel.Epiphany == "sunday" { + return nextWeekday(time.Date(year, 1, 2, 0, 0, 0, 0, time.UTC), time.Sunday) + } + return time.Date(year, 1, 6, 0, 0, 0, 0, time.UTC) +} + +// baptismOfLord is the Sunday after Epiphany; when Epiphany (transferred to a +// Sunday) is Jan 7 or 8, Baptism is the next day. +func baptismOfLord(year int, sel Selection) time.Time { + epi := epiphany(year, sel) + if sel.Epiphany == "sunday" && (epi.Day() == 7 || epi.Day() == 8) { + return epi.AddDate(0, 0, 1) + } + return nextWeekday(epi.AddDate(0, 0, 1), time.Sunday) +} + +// holyFamily is the Sunday within the Christmas octave (Dec 26-31), or Dec 30 +// when Christmas itself is a Sunday. +func holyFamily(year int) time.Time { + xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC) + if xmas.Weekday() == time.Sunday { + return time.Date(year, 12, 30, 0, 0, 0, 0, time.UTC) + } + return nextWeekday(xmas.AddDate(0, 0, 1), time.Sunday) +} + +// ascension: Thursday (easter+39) or, where transferred, the 7th Sunday of Easter (easter+42). +func ascension(y int, sel Selection) time.Time { + e := Easter(y) + if sel.Ascension == "sunday" { + return e.AddDate(0, 0, 42) + } + return e.AddDate(0, 0, 39) +} + +// corpusChristi: Thursday (easter+60) or, where transferred, Sunday (easter+63). +func corpusChristi(y int, sel Selection) time.Time { + e := Easter(y) + if sel.CorpusChristi == "sunday" { + return e.AddDate(0, 0, 63) + } + return e.AddDate(0, 0, 60) +} + +// --- small constructors --- + +func solemn(season Season, slug string, col Colour, privileged bool) temporalDay { + return temporalDay{Season: season, Colour: col, Rank: RankSolemnity, Class: ClassLord, Privileged: privileged, + Cel: Celebration{Slug: slug, Rank: RankSolemnity, Class: ClassLord, Colour: col, Layer: "temporal"}} +} + +func feastOfLord(season Season, slug string, col Colour) temporalDay { + return temporalDay{Season: season, Colour: col, Rank: RankFeast, Class: ClassLord, + Cel: Celebration{Slug: slug, Rank: RankFeast, Class: ClassLord, Colour: col, Layer: "temporal"}} +} + +func sundayDay(season Season, slug string, col Colour, week int) temporalDay { + priv := season == Advent || season == Lent || season == Easter_ + td := temporalDay{Season: season, Colour: col, Week: week, Rank: RankSolemnity, Privileged: priv, Sunday: !priv, + Cel: Celebration{Slug: slug, Rank: RankSolemnity, Colour: col, Layer: "temporal"}} + return td +} + +func ferialDay(season Season, slug string, col Colour, week int, privFeria bool) temporalDay { + return temporalDay{Season: season, Colour: col, Week: week, Rank: RankFerial, PrivFeria: privFeria, + Cel: Celebration{Slug: slug, Rank: RankFerial, Colour: col, Layer: "temporal"}} +} + +// temporal computes the temporal identity of date under the given Selection. +func temporal(date time.Time, sel Selection) temporalDay { + date = date.UTC().Truncate(24 * time.Hour) + y := date.Year() + easter := Easter(y) + ashWed := easter.AddDate(0, 0, -46) + palmSunday := easter.AddDate(0, 0, -7) + holyThu := easter.AddDate(0, 0, -3) + pentecost := easter.AddDate(0, 0, 49) + adventThis := adventStart(y) + christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC) + baptismThis := baptismOfLord(y, sel) + christTheKing := adventThis.AddDate(0, 0, -7) + wd := date.Weekday() + sun := wd == time.Sunday + + // 1. Movable / dated solemnities and feasts (highest identity first). + switch { + case sameDay(date, easter): + return solemn(Easter_, "easter-sunday", White, true) + case date.After(easter) && !date.After(easter.AddDate(0, 0, 7)): + // Octave of Easter (Mon..Sun); easter+7 = 2nd Sunday of Easter. + td := solemn(Easter_, "easter-octave-"+weekdayAbbr(wd), White, true) + td.Week = 1 + return td + case sameDay(date, ascension(y, sel)): + return solemn(Easter_, "ascension", White, true) + case sameDay(date, pentecost): + return solemn(Easter_, "pentecost", Red, true) + case sameDay(date, easter.AddDate(0, 0, 56)): + return solemn(Ordinary, "trinity-sunday", White, false) + case sameDay(date, corpusChristi(y, sel)): + return solemn(Ordinary, "corpus-christi", White, false) + case sameDay(date, easter.AddDate(0, 0, 68)): + return solemn(Ordinary, "sacred-heart", White, false) + case sameDay(date, christTheKing): + return solemn(Ordinary, "christ-the-king", White, false) + case sameDay(date, epiphany(y, sel)): + return solemn(Christmas, "epiphany", White, true) + case sameDay(date, baptismThis): + return feastOfLord(Christmas, "baptism-of-the-lord", White) + case sameDay(date, holyFamily(y)): + return feastOfLord(Christmas, "holy-family", White) + } + + // 2. Seasons by span (chronological within the calendar year). + switch { + case !date.Before(holyThu) && date.Before(easter): // Triduum: Thu, Fri, Sat + col := White + if wd == time.Friday { + col = Red + } else if wd == time.Saturday { + col = Violet + } + td := solemn(Triduum, "triduum-"+weekdayAbbr(wd), col, true) + return td + + case !date.Before(palmSunday) && date.Before(holyThu): // Palm Sunday + Holy Week Mon-Wed + if sun { + return sundayDay(Lent, "palm-sunday", Red, 6) + } + return ferialDay(Lent, "holy-week-"+weekdayAbbr(wd), Violet, 6, true) + + case !date.Before(ashWed) && date.Before(palmSunday): // Lent + if !date.Before(ashWed) && date.Before(ashWed.AddDate(0, 0, 4)) { + // Ash Wednesday through the Saturday after (before Lent I). + return ferialDay(Lent, "lent-after-ashes-"+weekdayAbbr(wd), Violet, 0, true) + } + lent1 := nextWeekday(ashWed, time.Sunday) + week := daysBetween(lent1, date)/7 + 1 + col := Violet + if week == 4 { + col = Rose // Laetare + } + if sun { + return sundayDay(Lent, "lent-sunday-"+itoa(week), col, week) + } + return ferialDay(Lent, "lent-"+itoa(week)+"-"+weekdayAbbr(wd), Violet, week, true) + + case !date.Before(easter) && !date.After(pentecost): // Easter season + week := daysBetween(easter, date)/7 + 1 + if sun { + return sundayDay(Easter_, "easter-sunday-"+itoa(week), White, week) + } + return ferialDay(Easter_, "easter-"+itoa(week)+"-"+weekdayAbbr(wd), White, week, false) + + case !date.Before(adventThis) && date.Before(christmasThis): // Advent + week := daysBetween(adventThis, date)/7 + 1 + col := Violet + if week == 3 { + col = Rose // Gaudete + } + privFeria := date.Month() == time.December && date.Day() >= 17 + if sun { + return sundayDay(Advent, "advent-sunday-"+itoa(week), col, week) + } + return ferialDay(Advent, "advent-"+itoa(week)+"-"+weekdayAbbr(wd), Violet, week, privFeria) + + case !date.Before(christmasThis): // Dec 25..Dec 31 — Christmas octave + return ferialDay(Christmas, "christmas-octave-"+weekdayAbbr(wd), White, 0, false) + + case !date.After(baptismThis): // Jan 1..Baptism — Christmas season + if sun { + return sundayDay(Christmas, "christmas-sunday-"+weekdayAbbr(wd), White, 0) + } + return ferialDay(Christmas, "christmas-"+weekdayAbbr(wd), White, 0, false) + + default: // Ordinary Time (span 1: after Baptism..before Lent; span 2: after Pentecost..before Advent) + var week int + if date.Before(ashWed) { + week = daysBetween(baptismThis, date)/7 + 1 + } else { + // count back from Christ the King = the 34th week. + week = 34 - daysBetween(date, christTheKing)/7 + } + if week < 1 { + week = 1 + } + if sun { + return sundayDay(Ordinary, "ordinary-sunday-"+itoa(week), Green, week) + } + return ferialDay(Ordinary, "ordinary-"+itoa(week)+"-"+weekdayAbbr(wd), Green, week, false) + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/internal/calendar/temporal_test.go b/internal/calendar/temporal_test.go new file mode 100644 index 0000000..da871a2 --- /dev/null +++ b/internal/calendar/temporal_test.go @@ -0,0 +1,53 @@ +package calendar + +import ( + "testing" + "time" +) + +func d(s string) time.Time { + t, _ := time.Parse("2006-01-02", s) + return t.UTC() +} + +func TestTemporalSeasons(t *testing.T) { + sel := DefaultSelection() + cases := []struct { + date string + season Season + }{ + {"2025-12-01", Advent}, // Mon after Advent I (2025 Advent I = Nov 30) + {"2025-12-25", Christmas}, // Christmas + {"2025-03-05", Lent}, // Ash Wednesday 2025 + {"2025-04-20", Easter_}, // Easter Sunday 2025 + {"2025-07-15", Ordinary}, // deep Ordinary Time + } + for _, c := range cases { + got := temporal(d(c.date), sel) + if got.Season != c.season { + t.Errorf("temporal(%s).Season = %s, want %s", c.date, got.Season, c.season) + } + } +} + +func TestTemporalPrivilegedSunday(t *testing.T) { + // 2nd Sunday of Advent 2025 (Dec 7) is privileged and violet. + td := temporal(d("2025-12-07"), DefaultSelection()) + if !td.Privileged { + t.Error("Sunday of Advent must be privileged") + } + if td.Colour != Violet { + t.Errorf("Advent colour = %s want violet", td.Colour) + } +} + +func TestMovableSolemnities(t *testing.T) { + // Corpus Christi 2025 (Thursday, easter+60) = 2025-06-19. + td := temporal(d("2025-06-19"), DefaultSelection()) + if td.Cel.Slug != "corpus-christi" { + t.Errorf("2025-06-19 temporal slug = %q want corpus-christi", td.Cel.Slug) + } + if td.Rank != RankSolemnity { + t.Error("Corpus Christi must be a solemnity") + } +} |
