From 0b68ec046c26292e368494e158cc36b342901a80 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 27 Jul 2026 12:21:15 +0200 Subject: feat(calendar): Compute — build celebrations, apply precedence + transfer, cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also: deterministic pick tiebreak by slug. --- internal/calendar/calendar.go | 167 +++++++++++++++++++++++++++++++++++++ internal/calendar/calendar_test.go | 39 +++++++++ internal/calendar/precedence.go | 12 ++- 3 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 internal/calendar/calendar.go create mode 100644 internal/calendar/calendar_test.go (limited to 'internal/calendar') diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go new file mode 100644 index 0000000..3227a5a --- /dev/null +++ b/internal/calendar/calendar.go @@ -0,0 +1,167 @@ +package calendar + +import ( + "sort" + "strings" + "time" +) + +// Compute returns the Ordinary Form 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. +func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay { + date = date.UTC().Truncate(24 * time.Hour) + 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 && cel.Rank < RankSolemnity { + continue + } + if td.Sunday && cel.Rank < RankFeast { + continue + } + effective := transferIfImpeded(cel, when, sel) + if sameDay(effective, date) { + cands = append(cands, candidate{Cel: 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), + SundayCycle: sundayCycle(liturgicalYearStart(date)), + WeekdayCycle: weekdayCycle(liturgicalYearStart(date).Year() + 1), + } + for _, o := range others { + 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)) +} + +// transferIfImpeded moves a solemnity off a band-1/2 temporal day to the next +// free day. Non-solemnities are never transferred (they yield instead). +func transferIfImpeded(cel Celebration, when time.Time, sel Selection) time.Time { + if cel.Rank != RankSolemnity { + return when + } + 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 +} + +// 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 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" +} diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go new file mode 100644 index 0000000..909f1d8 --- /dev/null +++ b/internal/calendar/calendar_test.go @@ -0,0 +1,39 @@ +package calendar + +import ( + "testing" +) + +func universalTest() Layer { + return Layer{ID: "universal", Type: "universal", Cels: map[string]RawCelebration{ + "assumption": {Fields: map[string]string{ + "date": "08-15", "rank": "solemnity", "class": "bvm", "colour": "white", + "name.en": "Assumption of the BVM", "reading.gospel": "Lk 1:39-56", + }, Variants: map[string]map[string]string{}}, + " st-monday-optional ": {Fields: map[string]string{}, Variants: map[string]map[string]string{}}, + }} +} + +func TestComputeSolemnityBeatsFeria(t *testing.T) { + got := Compute(d("2025-08-15"), DefaultSelection(), []Layer{universalTest()}) + if got.Observed.Slug != "assumption" { + t.Fatalf("2025-08-15 observed = %q want assumption", got.Observed.Slug) + } + if got.Colour != White || got.Observed.Rank != RankSolemnity { + t.Errorf("assumption colour/rank wrong: %s / %v", got.Colour, got.Observed.Rank) + } + if len(got.Observed.Masses) == 0 || got.Observed.Masses[0].Readings[0].Part != "gospel" { + t.Errorf("proper reading not surfaced: %+v", got.Observed.Masses) + } +} + +func TestComputeCycles(t *testing.T) { + // Advent 2024 opens liturgical year 2025 → Sunday cycle C, weekday cycle I. + got := Compute(d("2025-01-15"), DefaultSelection(), []Layer{universalTest()}) + if got.SundayCycle != "C" { + t.Errorf("2024-25 Sunday cycle = %s want C", got.SundayCycle) + } + if got.WeekdayCycle != "I" { + t.Errorf("2025 weekday cycle = %s want I", got.WeekdayCycle) + } +} diff --git a/internal/calendar/precedence.go b/internal/calendar/precedence.go index b013b77..7919879 100644 --- a/internal/calendar/precedence.go +++ b/internal/calendar/precedence.go @@ -62,10 +62,18 @@ func precedence(c candidate) int { } // pick returns the highest-precedence candidate as observed and the rest as -// others (commemorations / optional memorials), stably ordered by precedence. +// others (commemorations / optional memorials). Ordering is deterministic: +// by precedence band, then by slug, so equal-band collisions resolve the same +// way on every run. func pick(cands []candidate) (candidate, []candidate) { sorted := make([]candidate, len(cands)) copy(sorted, cands) - sort.SliceStable(sorted, func(i, j int) bool { return precedence(sorted[i]) < precedence(sorted[j]) }) + sort.SliceStable(sorted, func(i, j int) bool { + pi, pj := precedence(sorted[i]), precedence(sorted[j]) + if pi != pj { + return pi < pj + } + return sorted[i].Cel.Slug < sorted[j].Cel.Slug + }) return sorted[0], sorted[1:] } -- cgit v1.3