diff options
| author | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 12:09:56 +0200 |
|---|---|---|
| committer | Lukasz Kasprzak <lukas@labunix.xyz> | 2026-07-27 12:09:56 +0200 |
| commit | c5d4388215713324a6cfdebc20aaa5ef594f0174 (patch) | |
| tree | 701ab931add71fe82a8a9b7d009eb9f16bf9c9e8 /docs/superpowers | |
| parent | 3a46ec31b4b20fe58f1b24f0bb5b47af67ae99fa (diff) | |
| download | lectio-c5d4388215713324a6cfdebc20aaa5ef594f0174.tar.gz lectio-c5d4388215713324a6cfdebc20aaa5ef594f0174.zip | |
docs: implementation plan for the OF calendar engine (sub-project #1)
12 TDD tasks: internal/ini reader, calendar types+Computus, DateSpec,
temporal cycle, layer merge, Universal-Norms precedence, Compute, caldata
(embedded owned Roman calendar), TOML->INI config migration, regression
oracle vs calapi 2020-2040, 'lectio calendar' command, version bump.
Engine is pure stdlib; oracle is the correctness gate.
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md | 1963 |
1 files changed, 1963 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md b/docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md new file mode 100644 index 0000000..63ccaa2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-lectio-calendar-engine.md @@ -0,0 +1,1963 @@ +# OF Liturgical Calendar Engine Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A pure, offline, stdlib-only Go engine that computes the Ordinary Form liturgical day (season, celebrations, rank, colour, the observed celebration, inline proper readings) from a Gregorian date, replacing the niedziela.pl/missalemeum scrape for calendar computation. + +**Architecture:** A dependency-free `internal/calendar` package (types + Computus + temporal cycle + sanctoral date resolution + layer merge + Universal-Norms precedence + `Compute`) fed by an `internal/caldata` package that embeds the universal General Roman Calendar as INI records, plus a shared `internal/ini` reader and a config migration to INI. Correctness is proven by a regression oracle diffing against calapi/romcal over 2020–2040. Front-ends stay on the scraper; a new `lectio calendar [DATE]` command is the engine's demo/validation surface. + +**Tech Stack:** Go 1.24, standard library only for the engine (`time`, `strings`, `strconv`, `embed`, `bufio`, `sort`, `fmt`). Module path `github.com/lukaszkasprzak/lectio`. + +## Global Constraints + +- **Engine purity:** `internal/calendar` and `internal/ini` import **only the Go standard library** — no third-party, no other internal packages. `internal/caldata` may import `internal/calendar` (for types) + `internal/ini` only. +- **No network in the core, ever.** `Compute` is total: every valid Gregorian date returns a `LiturgicalDay`; it never returns an error and never performs I/O. +- **Formats:** config = INI; celebration data = INI `[slug]` sections; scripture corpora stay TSV. No TOML/YAML in new code (the existing go-toml dependency is retained **only** for one-shot config migration). +- **Longevity:** boring stdlib, plain-text data, focused files, frequent commits. Data is owned plain text. +- **Precedence authority:** the *Universal Norms on the Liturgical Year and the Calendar* (1969) "Table of Liturgical Days" governs which celebration is observed. +- **Scope of this plan (from the spec):** universal OF calendar only. OUT: temporal reading cycle (Sundays A/B/C, weekday I/II), user override-file loading + `use =` stacking, JSON/iCal API, EF engine, `bt`, UI migration. The engine's `Compute(date, sel, layers []Layer)` signature accepts an ordered layer stack so #2 slots in, but this plan ships only the embedded universal layer. +- **Pre-1970 dates** apply the modern rules retroactively (documented, matches romcal). + +--- + +## File Structure + +- `internal/ini/ini.go` — shared zero-dep INI reader/writer. Parses ordered sections (incl. `[a/b]` subsections), `key = value`, dotted keys, `#`/`;` comments; a comma-list helper. One responsibility: INI ↔ ordered data. +- `internal/calendar/types.go` — the vocabulary types (`Rank`, `Class`, `Colour`, `Season`, `Reading`, `Mass`, `DateSpec`, `Celebration`, `RawCelebration`, `Layer`, `Selection`, `LiturgicalDay`) + parse helpers for enums. +- `internal/calendar/computus.go` — `Easter(year) time.Time`. +- `internal/calendar/datespec.go` — `resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool)`. +- `internal/calendar/temporal.go` — `temporal(date time.Time, sel Selection) temporalDay` (season, week, weekday, temporal celebration + its precedence inputs, colour). +- `internal/calendar/merge.go` — `mergeLayers(layers []Layer) map[string]RawCelebration`. +- `internal/calendar/precedence.go` — `precedence(kind precedenceInput) int` (Table of Liturgical Days) + `pick(cands []candidate) (observed candidate, others []candidate)` + `transfer` of impeded solemnities. +- `internal/calendar/calendar.go` — `Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay`, `buildCelebration`, cycle helpers `sundayCycle`/`weekdayCycle`. +- `internal/caldata/caldata.go` — `//go:embed roman-calendar.ini`, `Universal() calendar.Layer`. +- `internal/caldata/roman-calendar.ini` — the owned universal sanctoral data (bootstrapped from romcal, verified). +- `internal/config/config.go` — modified: read/write INI, one-shot TOML→INI migration, `[calendar]` keys, `Selection()` accessor. +- `internal/config/config.ini` — new embedded INI seed (replaces `config.toml`). +- `internal/calendar/oracle_test.go` + `internal/calendar/testdata/oracle-2020-2040.json` — regression oracle. +- `scripts/build-oracle.sh` — one-time (network) snapshot generator; not run in tests. +- `internal/cli/calendar.go` — the `lectio calendar [DATE]` command. + +Each `calendar/*.go` file is small and single-purpose; the package as a whole is the 25–50 year artifact. + +--- + +## Task 1: Shared INI reader (`internal/ini`) + +**Files:** +- Create: `internal/ini/ini.go` +- Test: `internal/ini/ini_test.go` + +**Interfaces:** +- Produces: `type Section struct { Name string; Pairs []Pair }`; `type Pair struct { Key, Val string }`; `func Parse(data []byte) ([]Section, error)`; `func List(val string) []string` (splits a comma value, trims, drops empties). + +- [ ] **Step 1: Write the failing test** + +```go +package ini + +import "testing" + +func TestParseSectionsAndPairs(t *testing.T) { + in := []byte(` +# a comment +lectionary = new ; trailing comment + +[assumption] +date = 08-15 +name.pl = Wniebowzięcie ; unicode ok +rank= solemnity + +[assumption/vigil] +reading.gospel = Łk 11,27-28 +`) + secs, err := Parse(in) + if err != nil { + t.Fatal(err) + } + // top-level pairs land in a section with empty name, first. + if secs[0].Name != "" || len(secs[0].Pairs) != 1 || secs[0].Pairs[0] != (Pair{"lectionary", "new"}) { + t.Fatalf("top-level = %+v", secs[0]) + } + if secs[1].Name != "assumption" || secs[1].Pairs[1] != (Pair{"name.pl", "Wniebowzięcie"}) { + t.Fatalf("assumption = %+v", secs[1]) + } + if secs[1].Pairs[2] != (Pair{"rank", "solemnity"}) { + t.Fatalf("no-space key = %+v", secs[1].Pairs[2]) + } + if secs[2].Name != "assumption/vigil" { + t.Fatalf("subsection = %q", secs[2].Name) + } +} + +func TestList(t *testing.T) { + got := List("bt, wuj ,, vul") + want := []string{"bt", "wuj", "vul"} + if len(got) != len(want) { + t.Fatalf("List = %v", got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("List[%d] = %q want %q", i, got[i], want[i]) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/ini/` +Expected: FAIL (`undefined: Parse` / `undefined: List`). + +- [ ] **Step 3: Write minimal implementation** + +```go +// Package ini is a tiny dependency-free reader for the boring INI files lectio +// uses for config and calendar data: [sections] (including "a/b" subsections), +// "key = value" pairs (keys may be dotted, e.g. name.pl), and "#"/";" comments. +// It preserves order and never interprets values beyond trimming. +package ini + +import ( + "bufio" + "bytes" + "fmt" + "strings" +) + +type Pair struct{ Key, Val string } + +type Section struct { + Name string + Pairs []Pair +} + +// Parse reads INI bytes into ordered sections. Pairs before the first [section] +// go into a leading section with an empty Name. +func Parse(data []byte) ([]Section, error) { + secs := []Section{{Name: ""}} + sc := bufio.NewScanner(bytes.NewReader(data)) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + line := 0 + for sc.Scan() { + line++ + raw := stripComment(sc.Text()) + s := strings.TrimSpace(raw) + if s == "" { + continue + } + if strings.HasPrefix(s, "[") { + if !strings.HasSuffix(s, "]") { + return nil, fmt.Errorf("ini: line %d: unclosed section header %q", line, s) + } + name := strings.TrimSpace(s[1 : len(s)-1]) + secs = append(secs, Section{Name: name}) + continue + } + eq := strings.IndexByte(s, '=') + if eq < 0 { + return nil, fmt.Errorf("ini: line %d: expected key = value, got %q", line, s) + } + key := strings.TrimSpace(s[:eq]) + val := strings.TrimSpace(s[eq+1:]) + cur := &secs[len(secs)-1] + cur.Pairs = append(cur.Pairs, Pair{key, val}) + } + return secs, sc.Err() +} + +// stripComment removes a trailing "#" or ";" comment (not inside the value's +// meaningful text — our values never contain unescaped # or ;). +func stripComment(line string) string { + for i := 0; i < len(line); i++ { + if line[i] == '#' || line[i] == ';' { + return line[:i] + } + } + return line +} + +// List splits a comma-separated value, trimming spaces and dropping empties. +func List(val string) []string { + var out []string + for _, p := range strings.Split(val, ",") { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/ini/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/ini/ +git commit -m "feat(ini): dependency-free INI reader for config and calendar data" +``` + +--- + +## Task 2: Calendar types + Computus (`internal/calendar`) + +**Files:** +- Create: `internal/calendar/types.go`, `internal/calendar/computus.go` +- Test: `internal/calendar/computus_test.go`, `internal/calendar/types_test.go` + +**Interfaces:** +- Produces the whole type vocabulary (below) and `func Easter(year int) time.Time` (UTC midnight, Gregorian Easter Sunday). `func ParseRank(string) Rank`, `func ParseClass(string) Class`, `func ParseColour(string) Colour` (each returns a zero value for unknown input). + +- [ ] **Step 1: Write the failing Easter test** + +```go +package calendar + +import ( + "testing" + "time" +) + +func TestEasterKnownDates(t *testing.T) { + // Published Gregorian Easter Sundays. + cases := map[int]string{ + 2020: "2020-04-12", 2021: "2021-04-04", 2022: "2022-04-17", + 2024: "2024-03-31", 2025: "2025-04-20", 2027: "2027-03-28", + 2038: "2038-04-25", 2000: "2000-04-23", + } + for y, want := range cases { + got := Easter(y).Format("2006-01-02") + if got != want { + t.Errorf("Easter(%d) = %s, want %s", y, got, want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/calendar/ -run TestEaster` +Expected: FAIL (`undefined: Easter`). + +- [ ] **Step 3: Write the types** + +```go +// Package calendar computes the Ordinary Form liturgical day for any Gregorian +// date, offline and from first principles. It imports only the standard library. +package calendar + +import "time" + +type Rank int + +const ( + RankFerial Rank = iota // ordinary weekday + RankOptional // optional memorial + RankMemorial // obligatory memorial + RankFeast + RankSolemnity +) + +type Class int + +const ( + ClassNone Class = iota // temporal / ferial + ClassSaint // a saint + ClassBVM // the Blessed Virgin Mary + ClassLord // the Lord +) + +type Colour string + +const ( + White Colour = "white" + Red Colour = "red" + Green Colour = "green" + Violet Colour = "violet" + Rose Colour = "rose" + Black Colour = "black" +) + +type Season string + +const ( + Advent Season = "advent" + Christmas Season = "christmas" + Lent Season = "lent" + Triduum Season = "triduum" + Easter_ Season = "easter" // trailing underscore: Easter is the func name + Ordinary Season = "ordinary" +) + +type Reading struct{ Part, Citation string } + +type Mass struct { + Variant string // "" = the day Mass; e.g. "vigil" + Readings []Reading +} + +// DateSpec is a celebration's raw date expression, resolved by resolveDate: +// "MM-DD" (fixed) | "easter±N" | "christmas±N" | "advent-sunday-N" | +// "sunday-after MM-DD". +type DateSpec string + +// RawCelebration is a celebration's fields exactly as parsed from a layer, +// before typing/merging. Fields are raw INI values keyed by INI key +// (e.g. "rank", "name.pl"); Variants holds "[slug/variant]" sub-sections. +type RawCelebration struct { + Fields map[string]string + Variants map[string]map[string]string +} + +// Celebration is the typed, built form used in results. +type Celebration struct { + Slug string + Name map[string]string // lang -> name + Rank Rank + Class Class + Colour Colour + Date DateSpec + Masses []Mass + Layer string // provenance +} + +// Layer is one calendar layer (the embedded universal base, or later an +// override file). Cels is keyed by slug; insertion order is not significant. +type Layer struct { + ID, Name, Type string + Cels map[string]RawCelebration +} + +// Selection carries the config choices that steer computation. +type Selection struct { + Form string // "new" (OF). "old" reserved. + Epiphany string // "fixed" | "sunday" + Ascension string // "thursday" | "sunday" + CorpusChristi string // "thursday" | "sunday" +} + +// DefaultSelection is the Universal Roman Calendar default. +func DefaultSelection() Selection { + return Selection{Form: "new", Epiphany: "fixed", Ascension: "thursday", CorpusChristi: "thursday"} +} + +type LiturgicalDay struct { + Date time.Time + Season Season + Week int + Weekday time.Weekday + Observed Celebration + Others []Celebration + Colour Colour + SundayCycle string // "A" | "B" | "C" + WeekdayCycle string // "I" | "II" +} + +func ParseRank(s string) Rank { + switch s { + case "solemnity": + return RankSolemnity + case "feast": + return RankFeast + case "memorial": + return RankMemorial + case "optional": + return RankOptional + default: + return RankFerial + } +} + +func ParseClass(s string) Class { + switch s { + case "lord": + return ClassLord + case "bvm": + return ClassBVM + case "saint": + return ClassSaint + default: + return ClassNone + } +} + +func ParseColour(s string) Colour { + switch Colour(s) { + case White, Red, Green, Violet, Rose, Black: + return Colour(s) + default: + return "" + } +} +``` + +- [ ] **Step 4: Write Computus** + +```go +package calendar + +import "time" + +// Easter returns Gregorian Easter Sunday (UTC midnight) via the Anonymous +// Gregorian algorithm (Meeus/Jones/Butcher). +func Easter(year int) time.Time { + a := year % 19 + b := year / 100 + c := year % 100 + d := b / 4 + e := b % 4 + f := (b + 8) / 25 + g := (b - f + 1) / 3 + h := (19*a + b - d - g + 15) % 30 + i := c / 4 + k := c % 4 + l := (32 + 2*e + 2*i - h - k) % 7 + m := (a + 11*h + 22*l) / 451 + month := (h + l - 7*m + 114) / 31 + day := ((h + l - 7*m + 114) % 31) + 1 + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) +} +``` + +- [ ] **Step 5: Add the enum test** + +```go +package calendar + +import "testing" + +func TestParseEnums(t *testing.T) { + if ParseRank("feast") != RankFeast || ParseRank("nonsense") != RankFerial { + t.Error("ParseRank") + } + if ParseClass("bvm") != ClassBVM || ParseClass("") != ClassNone { + t.Error("ParseClass") + } + if ParseColour("violet") != Violet || ParseColour("chartreuse") != "" { + t.Error("ParseColour") + } +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/calendar/` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/calendar/types.go internal/calendar/computus.go internal/calendar/computus_test.go internal/calendar/types_test.go +git commit -m "feat(calendar): core types + Gregorian Computus" +``` + +--- + +## Task 3: DateSpec resolution (`internal/calendar/datespec.go`) + +**Files:** +- Create: `internal/calendar/datespec.go` +- Test: `internal/calendar/datespec_test.go` + +**Interfaces:** +- Consumes: `Easter(year)` (Task 2), `DateSpec` (Task 2). +- Produces: `func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool)`. Returns the date (UTC midnight) the spec names in `year`, or `ok=false` if unparseable. + +- [ ] **Step 1: Write the failing test** + +```go +package calendar + +import ( + "testing" + "time" +) + +func TestResolveDate(t *testing.T) { + y := 2025 + e := Easter(y) // 2025-04-20 + check := func(spec, want string) { + got, ok := resolveDate(DateSpec(spec), y, e) + if !ok || got.Format("2006-01-02") != want { + t.Errorf("resolveDate(%q) = %s ok=%v, want %s", spec, got.Format("2006-01-02"), ok, want) + } + } + check("08-15", "2025-08-15") // fixed + check("easter+60", "2025-06-19") // Corpus Christi (Thursday) + check("easter-46", "2025-03-05") // Ash Wednesday + check("christmas+7", "2026-01-01") // year rolls over + check("sunday-after 01-06", "2025-01-12") + if _, ok := resolveDate("garbage", y, e); ok { + t.Error("garbage should not resolve") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/calendar/ -run TestResolveDate` +Expected: FAIL (`undefined: resolveDate`). + +- [ ] **Step 3: Write the implementation** + +```go +package calendar + +import ( + "strconv" + "strings" + "time" +) + +func resolveDate(spec DateSpec, year int, easter time.Time) (time.Time, bool) { + s := strings.TrimSpace(string(spec)) + switch { + case len(s) == 5 && s[2] == '-': // MM-DD + mo, err1 := strconv.Atoi(s[0:2]) + da, err2 := strconv.Atoi(s[3:5]) + if err1 != nil || err2 != nil { + return time.Time{}, false + } + return time.Date(year, time.Month(mo), da, 0, 0, 0, 0, time.UTC), true + case strings.HasPrefix(s, "easter"): + return offset(easter, s[len("easter"):]) + case strings.HasPrefix(s, "christmas"): + xmas := time.Date(year, 12, 25, 0, 0, 0, 0, time.UTC) + return offset(xmas, s[len("christmas"):]) + case strings.HasPrefix(s, "sunday-after "): + base, ok := resolveDate(DateSpec(strings.TrimPrefix(s, "sunday-after ")), year, easter) + if !ok { + return time.Time{}, false + } + return nextWeekday(base.AddDate(0, 0, 1), time.Sunday), true + } + return time.Time{}, false +} + +// offset parses "±N" and adds N days to base. +func offset(base time.Time, pm string) (time.Time, bool) { + if pm == "" { + return base, true + } + n, err := strconv.Atoi(pm) // strconv.Atoi handles a leading '+' and '-' + if err != nil { + return time.Time{}, false + } + return base.AddDate(0, 0, n), true +} + +// nextWeekday returns the first day >= from whose weekday is wd. +func nextWeekday(from time.Time, wd time.Weekday) time.Time { + delta := (int(wd) - int(from.Weekday()) + 7) % 7 + return from.AddDate(0, 0, delta) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/calendar/ -run TestResolveDate` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/calendar/datespec.go internal/calendar/datespec_test.go +git commit -m "feat(calendar): DateSpec resolution (fixed + easter/christmas offsets + sunday-after)" +``` + +--- + +## Task 4: Temporal cycle (`internal/calendar/temporal.go`) + +**Files:** +- Create: `internal/calendar/temporal.go` +- Test: `internal/calendar/temporal_test.go` + +**Interfaces:** +- Consumes: `Easter`, `Selection`, `Season`, `Colour`, `Rank`, `Class`, `nextWeekday` (Task 3). +- Produces: `type temporalDay struct { Season Season; Week int; Colour Colour; Cel Celebration; Rank Rank; Class Class; Privileged bool }` and `func temporal(date time.Time, sel Selection) temporalDay`. `Cel` is the temporal celebration (a Sunday, a ferial weekday, or a movable solemnity/feast) with a stable `Slug`; `Privileged` marks days no sanctoral may override (Sundays of Advent/Lent/Easter, Holy Week, Easter octave, Triduum). Also `func adventStart(year int) time.Time` (1st Sunday of Advent for the liturgical year *ending* Dec of `year`). + +- [ ] **Step 1: Write the failing test** + +```go +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") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/calendar/ -run 'TestTemporal|TestMovable'` +Expected: FAIL (`undefined: temporal`). + +- [ ] **Step 3: Write the implementation** + +The temporal cycle is derived entirely from Easter and the Advent anchor. This +implementation covers the seasons, Sundays/weekdays, and the movable +solemnities/feasts of the General Roman Calendar. Weeks are numbered per the +liturgical convention (Ordinary Time weeks bridge across Lent/Easter). + +```go +package calendar + +import "time" + +type temporalDay struct { + Season Season + Week int + Colour Colour + Cel Celebration + Rank Rank + Class Class + Privileged 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) + // Sunday on/before Christmas, then back 3 more Sundays. + sun := xmas.AddDate(0, 0, -int(xmas.Weekday())) // Sunday <= 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() +} + +func weeksBetween(from, to time.Time) int { + return int(to.Sub(from).Hours()/24) / 7 +} + +// temporal computes the temporal (season/movable) identity of date. +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) + pentecost := easter.AddDate(0, 0, 49) + triduumStart := easter.AddDate(0, 0, -3) // Holy Thursday evening; treat Thu-Sat as Triduum + palmSunday := easter.AddDate(0, 0, -7) + adventThis := adventStart(y) + adventPrev := adventStart(y - 1) + christmasThis := time.Date(y, 12, 25, 0, 0, 0, 0, time.UTC) + baptism := baptismOfLord(y, sel) + + // --- movable solemnities/feasts keyed to Easter (highest first) --- + switch { + case sameDay(date, easter): + return sol(date, Easter_, "easter-sunday", White, true) + case date.After(easter) && date.Before(easter.AddDate(0, 0, 7)): + return octave(date, Easter_, "easter-octave", White, weeksBetween(easter, date)) + case sameDay(date, ascension(y, sel)): + return sol(date, Easter_, "ascension", White, true) + case sameDay(date, pentecost): + return sol(date, Easter_, "pentecost", Red, true) + case sameDay(date, easter.AddDate(0, 0, 56)): // Trinity, easter+56 + return sol(date, Ordinary, "trinity", White, false) + case sameDay(date, corpusChristi(y, sel)): + return sol(date, Ordinary, "corpus-christi", White, false) + case sameDay(date, easter.AddDate(0, 0, 68)): // Sacred Heart, easter+68 + return sol(date, Ordinary, "sacred-heart", White, false) + } + + // --- seasons by span --- + switch { + case !date.Before(triduumStart) && date.Before(easter): + return sol(date, Triduum, "triduum", Red, true) + case !date.Before(palmSunday) && date.Before(triduumStart): // Holy Week Mon–Wed + Palm Sunday + return privWeek(date, Lent, "holy-week", Violet) + case !date.Before(ashWed) && date.Before(palmSunday): + return lent(date, ashWed) + case (!date.Before(easter) && date.Before(pentecost.AddDate(0, 0, 1))): + return eastertide(date, easter) + case !date.Before(adventThis): // Advent of the new liturgical year (late this year) + return advent(date, adventThis, sel) + case date.Before(baptism.AddDate(0, 0, 1)): // Christmas season into early Jan (opened last year) + return christmasSeason(date, adventPrev, christmasPrev(y), baptism, sel) + case !date.Before(christmasThis): // Christmas Day..Dec 31 of this year + return christmasSeason(date, adventThis, christmasThis, baptismOfLord(y+1, sel), sel) + default: + return ordinary(date, baptism, adventThis) + } +} +``` + +Supporting helpers (same file): + +```go +// small constructors keep temporal() readable. +func sol(date time.Time, s Season, slug string, col Colour, privileged bool) temporalDay { + return temporalDay{Season: s, Colour: col, Rank: RankSolemnity, Class: ClassLord, Privileged: privileged, + Cel: Celebration{Slug: slug, Rank: RankSolemnity, Class: ClassLord, Colour: col, Layer: "temporal"}} +} +func privWeek(date time.Time, s Season, slug string, col Colour) temporalDay { + r := RankFerial + priv := true + if date.Weekday() == time.Sunday { + r = RankSolemnity // Palm Sunday ranks with Sundays of Lent + } + return temporalDay{Season: s, Colour: col, Rank: r, Privileged: priv, + Cel: Celebration{Slug: slug, Rank: r, Colour: col, Layer: "temporal"}} +} +func octave(date time.Time, s Season, slug string, col Colour, day int) temporalDay { + return temporalDay{Season: s, Colour: col, Rank: RankSolemnity, Privileged: true, Week: day, + Cel: Celebration{Slug: slug, Rank: RankSolemnity, Colour: col, Layer: "temporal"}} +} + +// 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) +} +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) +} +``` + +> **Implementer note:** `lent`, `eastertide`, `advent`, `christmasSeason`, `ordinary`, +> `baptismOfLord`, `christmasPrev`, `Weekday`, `(temporalDay).norm` are the remaining +> season builders. Each sets `Season`, `Week` (count of Sundays since the season start, +> or the Ordinary-Time week bridged across Lent/Easter), `Colour` (Advent/Lent violet, +> Gaudete/Laetare 3rd-Advent/4th-Lent Sunday rose, Christmas/Easter white, else green), +> a ferial or Sunday `Cel` with slug `"<season>-<week>-<weekday>"` / `"<season>-sunday-<week>"`, +> `Rank` (`RankSolemnity` for privileged Sundays, else `RankFerial`), and `Privileged` +> (true for Sundays of Advent/Lent/Easter). Baptism of the Lord = the Sunday after Epiphany +> (`sel.Epiphany=="fixed"` → Epiphany Jan 6, Baptism the following Sunday; `"sunday"` → +> Epiphany the Sunday between Jan 2–8, Baptism the next Sunday, or Monday Jan 9 when Epiphany +> falls on Jan 7/8). Write each builder with its own failing test first (Step 3 is TDD-internal: +> add a test per builder, run red, implement, run green) before wiring them into `temporal`. +> The season boundaries and week numbers are validated wholesale by the regression oracle +> (Task 10); use it to drive the fiddly cases rather than guessing. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/calendar/ -run 'TestTemporal|TestMovable'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/calendar/temporal.go internal/calendar/temporal_test.go +git commit -m "feat(calendar): temporal cycle (seasons, Sundays, movable solemnities)" +``` + +--- + +## Task 5: Layer merge (`internal/calendar/merge.go`) + +**Files:** +- Create: `internal/calendar/merge.go` +- Test: `internal/calendar/merge_test.go` + +**Interfaces:** +- Consumes: `Layer`, `RawCelebration` (Task 2). +- Produces: `func mergeLayers(layers []Layer) map[string]RawCelebration`. Later layers override earlier field-by-field; `Fields["suppress"]=="true"` removes the slug; variant sub-maps merge per-variant. + +- [ ] **Step 1: Write the failing test** + +```go +package calendar + +import "testing" + +func raw(fields map[string]string) RawCelebration { + return RawCelebration{Fields: fields, Variants: map[string]map[string]string{}} +} + +func TestMergeLayers(t *testing.T) { + base := Layer{ID: "universal", Cels: map[string]RawCelebration{ + "assumption": raw(map[string]string{"rank": "solemnity", "colour": "white", "name.en": "Assumption"}), + "st-x": raw(map[string]string{"rank": "optional", "date": "01-02"}), + "drop-me": raw(map[string]string{"rank": "memorial", "date": "03-03"}), + }} + local := Layer{ID: "krakow", Cels: map[string]RawCelebration{ + "st-x": raw(map[string]string{"rank": "feast"}), // elevate; keep date + "drop-me": raw(map[string]string{"suppress": "true"}), // remove + "st-new": raw(map[string]string{"rank": "solemnity", "date": "05-08"}), // add + }} + got := mergeLayers([]Layer{base, local}) + + if got["st-x"].Fields["rank"] != "feast" || got["st-x"].Fields["date"] != "01-02" { + t.Errorf("st-x merge = %+v", got["st-x"].Fields) + } + if _, ok := got["drop-me"]; ok { + t.Error("drop-me should be suppressed") + } + if got["st-new"].Fields["date"] != "05-08" { + t.Error("st-new should be added") + } + if got["assumption"].Fields["name.en"] != "Assumption" { + t.Error("untouched base entry lost") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/calendar/ -run TestMergeLayers` +Expected: FAIL (`undefined: mergeLayers`). + +- [ ] **Step 3: Write the implementation** + +```go +package calendar + +// mergeLayers folds an ordered layer stack into one raw celebration set. Later +// layers override earlier ones field-by-field; a slug with Fields["suppress"] +// == "true" is removed; variant sub-sections merge per variant. +func mergeLayers(layers []Layer) map[string]RawCelebration { + out := map[string]RawCelebration{} + for _, layer := range layers { + for slug, rc := range layer.Cels { + if rc.Fields["suppress"] == "true" { + delete(out, slug) + continue + } + cur, ok := out[slug] + if !ok { + cur = RawCelebration{Fields: map[string]string{}, Variants: map[string]map[string]string{}} + } else { + cur = cloneRaw(cur) + } + for k, v := range rc.Fields { + cur.Fields[k] = v + } + for variant, fields := range rc.Variants { + if cur.Variants[variant] == nil { + cur.Variants[variant] = map[string]string{} + } + for k, v := range fields { + cur.Variants[variant][k] = v + } + } + cur.Fields["layer"] = layer.ID // provenance: last writer + out[slug] = cur + } + } + return out +} + +func cloneRaw(rc RawCelebration) RawCelebration { + nf := make(map[string]string, len(rc.Fields)) + for k, v := range rc.Fields { + nf[k] = v + } + nv := make(map[string]map[string]string, len(rc.Variants)) + for variant, fields := range rc.Variants { + m := make(map[string]string, len(fields)) + for k, v := range fields { + m[k] = v + } + nv[variant] = m + } + return RawCelebration{Fields: nf, Variants: nv} +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/calendar/ -run TestMergeLayers` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/calendar/merge.go internal/calendar/merge_test.go +git commit -m "feat(calendar): ordered-stack layer merge (override/suppress/add)" +``` + +--- + +## Task 6: Precedence + transfer (`internal/calendar/precedence.go`) + +**Files:** +- Create: `internal/calendar/precedence.go` +- Test: `internal/calendar/precedence_test.go` + +**Interfaces:** +- Consumes: `Rank`, `Class`, `Season` (Task 2), `temporalDay` (Task 4). +- Produces: `type candidate struct { Cel Celebration; Temporal bool; Privileged bool; Season Season }`, `func precedence(c candidate) int` (Table of Liturgical Days — **lower = higher precedence**), and `func pick(cands []candidate) (observed candidate, others []candidate)`. Transfer of impeded solemnities is handled in `Compute` (Task 7) using `precedence`. + +The Table of Liturgical Days (Universal Norms, 1969), encoded as bands: + +| Band | Days | +|---|---| +| 1 | Triduum | +| 2 | Christmas, Epiphany, Ascension, Pentecost; Sundays of Advent/Lent/Easter; Ash Wednesday; Holy Week; Easter octave | +| 3 | Solemnities (Lord, BVM, saints) in the General Calendar; All Souls | +| 4 | Proper solemnities | +| 5 | Feasts of the Lord in the General Calendar | +| 6 | Sundays of Christmas and Ordinary Time | +| 7 | Feasts of the BVM/saints in the General Calendar | +| 8 | Proper feasts | +| 9 | Privileged ferias: Advent Dec 17–24, Christmas octave, Lenten ferias | +| 10 | Obligatory memorials, General Calendar | +| 11 | Proper obligatory memorials | +| 12 | Optional memorials | +| 13 | Ordinary ferias | + +- [ ] **Step 1: Write the failing test** + +```go +package calendar + +import "testing" + +func tempCand(rank Rank, class Class, priv bool, s Season) candidate { + return candidate{Cel: Celebration{Rank: rank, Class: class}, Temporal: true, Privileged: priv, Season: s} +} +func saintCand(rank Rank, class Class) candidate { + return candidate{Cel: Celebration{Rank: rank, Class: class}, Temporal: false, Season: Ordinary} +} + +func TestPrecedenceBands(t *testing.T) { + sundayLent := tempCand(RankSolemnity, ClassNone, true, Lent) + genSolemnity := saintCand(RankSolemnity, ClassSaint) + genFeast := saintCand(RankFeast, ClassSaint) + genMemorial := saintCand(RankMemorial, ClassSaint) + // A Sunday of Lent is band 2; a general solemnity is band 3 → the Sunday wins. + if !(precedence(sundayLent) < precedence(genSolemnity)) { + t.Error("Sunday of Lent (band 2) must outrank a general solemnity (band 3)") + } + if !(precedence(genSolemnity) < precedence(genFeast) && precedence(genFeast) < precedence(genMemorial)) { + t.Error("solemnity > feast > memorial ordering broken") + } +} + +func TestPick(t *testing.T) { + sunday := tempCand(RankSolemnity, ClassNone, true, Ordinary) // band 6 + memorial := saintCand(RankMemorial, ClassSaint) // band 10 + obs, others := pick([]candidate{memorial, sunday}) + if obs.Cel.Rank != RankSolemnity || !obs.Temporal { + t.Errorf("Sunday must win, got %+v", obs.Cel) + } + if len(others) != 1 || others[0].Cel.Rank != RankMemorial { + t.Errorf("memorial should be a commemoration, got %+v", others) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/calendar/ -run 'TestPrecedence|TestPick'` +Expected: FAIL (`undefined: candidate`). + +- [ ] **Step 3: Write the implementation** + +```go +package calendar + +import "sort" + +type candidate struct { + Cel Celebration + Temporal bool + Privileged bool + Season Season +} + +// precedence maps a candidate to its Table-of-Liturgical-Days band +// (lower = higher precedence). "Proper" (non-General-Calendar) celebrations use +// the layer provenance: a non-empty, non-"universal"/"temporal" layer is proper. +func precedence(c candidate) int { + proper := c.Cel.Layer != "" && c.Cel.Layer != "universal" && c.Cel.Layer != "temporal" + if c.Temporal { + switch { + case c.Season == Triduum: + return 1 + case c.Privileged: // privileged Sundays, Holy Week, Easter octave, top solemnities of the Lord + return 2 + case c.Cel.Rank == RankSolemnity: // Trinity/Corpus Christi/Sacred Heart etc. + return 3 + case c.Cel.Rank == RankFeast: + return 5 + case isSunday(c): // ordinary/Christmas Sundays + return 6 + case c.privilegedFeria(): + return 9 + default: + return 13 + } + } + switch c.Cel.Rank { + case RankSolemnity: + if proper { + return 4 + } + return 3 + case RankFeast: + if proper { + return 8 + } + return 7 + case RankMemorial: + if proper { + return 11 + } + return 10 + case RankOptional: + return 12 + default: + return 13 + } +} + +func isSunday(c candidate) bool { return c.Cel.Slug != "" && c.Privileged == false && c.Cel.Rank == RankSolemnity } + +// privilegedFeria is set by the temporal builder via Rank/slug; encoded on the +// candidate by Compute (Advent 17–24, Christmas octave, Lenten ferias). +func (c candidate) privilegedFeria() bool { return c.Cel.Slug == "privileged-feria" } + +// pick returns the highest-precedence candidate as observed and the rest as +// others (commemorations / optional memorials), stably ordered by precedence. +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]) }) + return sorted[0], sorted[1:] +} +``` + +> **Implementer note:** the `isSunday`/`privilegedFeria` predicates above are a first +> cut wired to slug/flag conventions the temporal builder sets; refine them so bands 6 and 9 +> match the oracle. Encoding proper-vs-general via layer provenance is exact for #1 (only the +> universal layer ships). **Transfer of impeded solemnities** (band-3/4 solemnity colliding +> with band-1/2) is applied in `Compute` (Task 7): when a solemnity is impeded, move it to the +> nearest following day not itself impeded. Drive band 6/9 and transfer edge cases with the +> oracle (Task 10). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/calendar/ -run 'TestPrecedence|TestPick'` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/calendar/precedence.go internal/calendar/precedence_test.go +git commit -m "feat(calendar): Universal Norms precedence bands + pick" +``` + +--- + +## Task 7: Compute — build + wire + cycles (`internal/calendar/calendar.go`) + +**Files:** +- Create: `internal/calendar/calendar.go` +- Test: `internal/calendar/calendar_test.go` + +**Interfaces:** +- Consumes: everything from Tasks 2–6. +- Produces: `func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay`; `func buildCelebration(slug string, rc RawCelebration) Celebration`; `func sundayCycle(liturgicalYearStart time.Time) string`; `func weekdayCycle(liturgicalYearStartYear int) string`. + +- [ ] **Step 1: Write the failing test** + +```go +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) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/calendar/ -run TestCompute` +Expected: FAIL (`undefined: Compute`). + +- [ ] **Step 3: Write the implementation** + +```go +package calendar + +import ( + "sort" + "strings" + "time" +) + +func Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay { + date = date.UTC().Truncate(24 * time.Hour) + td := temporal(date, sel) + merged := mergeLayers(layers) + + // candidates: the temporal day + every sanctoral celebration landing today + // (after transfer of impeded solemnities). + cands := []candidate{{Cel: td.Cel, Temporal: true, Privileged: td.Privileged, Season: td.Season}} + for slug, rc := range merged { + cel := buildCelebration(slug, rc) + when, ok := celebrationDate(cel, date.Year(), sel) + if !ok { + continue + } + if td.Privileged && cel.Rank < RankSolemnity { + continue // nothing below a solemnity survives a privileged day + } + 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 higher-precedence 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 < 8; i++ { + blocker := temporal(day, sel) + blockerBand := precedence(candidate{Cel: blocker.Cel, Temporal: true, Privileged: blocker.Privileged, Season: blocker.Season}) + if blockerBand <= 2 { // impeded by Triduum/privileged day: push forward + day = day.AddDate(0, 0, 1) + continue + } + return day + } + return day +} + +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) + } + // deterministic variant order + 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 +} + +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: Advent-year %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 civil year of the (Jan–) part → I, even → II. +func weekdayCycle(year int) string { + if year%2 == 1 { + return "I" + } + return "II" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/calendar/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/calendar/calendar.go internal/calendar/calendar_test.go +git commit -m "feat(calendar): Compute — build celebrations, apply precedence + transfer, cycles" +``` + +--- + +## Task 8: Base data package (`internal/caldata`) + +**Files:** +- Create: `internal/caldata/caldata.go`, `internal/caldata/roman-calendar.ini` +- Test: `internal/caldata/caldata_test.go` + +**Interfaces:** +- Consumes: `ini.Parse` (Task 1), `calendar.Layer`/`RawCelebration` (Task 2). +- Produces: `func Universal() calendar.Layer` (parses the embedded INI into a Layer; panics only on a malformed *embedded* file, which tests catch). + +- [ ] **Step 1: Seed a real (initial) data file** + +Create `internal/caldata/roman-calendar.ini` with the `[layer]` header and an +initial, real set of General Roman Calendar sanctoral entries (the fixed +solemnities/feasts + a representative set of memorials). This file grows to full +coverage in Step 5; it must be non-empty and valid now. + +```ini +[layer] +id = universal +name = General Roman Calendar +type = universal + +[mary-mother-of-god] +date = 01-01 +rank = solemnity +class = bvm +colour = white +name.en = Mary, the Holy Mother of God +name.pl = Świętej Bożej Rodzicielki Maryi +name.la = Sanctae Dei Genetricis Mariae + +[annunciation] +date = 03-25 +rank = solemnity +class = lord +colour = white +name.en = The Annunciation of the Lord +name.pl = Zwiastowanie Pańskie + +[assumption] +date = 08-15 +rank = solemnity +class = bvm +colour = white +name.en = The Assumption of the Blessed Virgin Mary +name.pl = Wniebowzięcie Najświętszej Maryi Panny +name.la = In Assumptione Beatae Mariae Virginis + +[all-saints] +date = 11-01 +rank = solemnity +class = saint +colour = white +name.en = All Saints +name.pl = Wszystkich Świętych + +[immaculate-conception] +date = 12-08 +rank = solemnity +class = bvm +colour = white +name.en = The Immaculate Conception of the Blessed Virgin Mary +``` + +- [ ] **Step 2: Write the failing test** + +```go +package caldata + +import ( + "testing" + + "github.com/lukaszkasprzak/lectio/internal/calendar" +) + +func TestUniversalLoads(t *testing.T) { + l := Universal() + if l.ID != "universal" { + t.Fatalf("layer id = %q", l.ID) + } + rc, ok := l.Cels["assumption"] + if !ok || rc.Fields["rank"] != "solemnity" || rc.Fields["date"] != "08-15" { + t.Fatalf("assumption = %+v ok=%v", rc, ok) + } + // every entry must build into a typed Celebration with a resolvable date. + for slug, rc := range l.Cels { + c := calendar.BuildCelebrationForTest(slug, rc) // exported test shim, Task 8 step 3 + if c.Rank == calendar.RankFerial && rc.Fields["rank"] != "" { + t.Errorf("%s: rank did not parse (%q)", slug, rc.Fields["rank"]) + } + } +} +``` + +- [ ] **Step 3: Add a test shim in the calendar package** + +Because `buildCelebration` is unexported, add to `internal/calendar/calendar.go`: + +```go +// BuildCelebrationForTest exposes buildCelebration for cross-package data tests. +func BuildCelebrationForTest(slug string, rc RawCelebration) Celebration { return buildCelebration(slug, rc) } +``` + +- [ ] **Step 4: Write the loader** + +```go +// Package caldata embeds and parses lectio's owned universal General Roman +// Calendar data into a calendar.Layer. It imports only internal/ini and +// internal/calendar (types). +package caldata + +import ( + _ "embed" + "fmt" + + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/ini" +) + +//go:embed roman-calendar.ini +var romanCalendar []byte + +// Universal parses the embedded universal calendar. It panics on a malformed +// embedded file (a build-time bug caught by tests), never at the request layer. +func Universal() calendar.Layer { + l, err := parse(romanCalendar) + if err != nil { + panic(fmt.Sprintf("caldata: embedded roman-calendar.ini invalid: %v", err)) + } + return l +} + +func parse(data []byte) (calendar.Layer, error) { + secs, err := ini.Parse(data) + if err != nil { + return calendar.Layer{}, err + } + layer := calendar.Layer{Cels: map[string]calendar.RawCelebration{}} + for _, s := range secs { + fields := map[string]string{} + for _, p := range s.Pairs { + fields[p.Key] = p.Val + } + switch { + case s.Name == "" && len(fields) == 0: + continue + case s.Name == "layer": + layer.ID, layer.Name, layer.Type = fields["id"], fields["name"], fields["type"] + default: + base, variant, isVariant := cutVariant(s.Name) + rc := layer.Cels[base] + if rc.Fields == nil { + rc = calendar.RawCelebration{Fields: map[string]string{}, Variants: map[string]map[string]string{}} + } + if isVariant { + rc.Variants[variant] = fields + } else { + for k, v := range fields { + rc.Fields[k] = v + } + } + layer.Cels[base] = rc + } + } + return layer, nil +} + +func cutVariant(name string) (base, variant string, ok bool) { + for i := 0; i < len(name); i++ { + if name[i] == '/' { + return name[:i], name[i+1:], true + } + } + return name, "", false +} +``` + +- [ ] **Step 5: Populate full coverage (data)** + +Bootstrap the full universal sanctoral into `roman-calendar.ini` from romcal's +MIT-licensed General Roman Calendar data, then verify against the official +calendar. Process: + +```bash +# reference data (read-only; do NOT vendor romcal into the module) +git clone --depth 1 https://github.com/romcal/romcal /tmp/romcal +# map each GRC entry → [slug]/date/rank/class/colour into roman-calendar.ini +# (slugs kebab-case; rank ∈ solemnity|feast|memorial|optional; class ∈ lord|bvm|saint) +``` + +Add a `NOTICE` entry crediting romcal (MIT). Acceptance for the data itself is +the regression oracle (Task 10) — do not hand-verify hundreds of dates. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/caldata/ ./internal/calendar/` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/caldata/ internal/calendar/calendar.go NOTICE +git commit -m "feat(caldata): embed + parse the owned universal Roman calendar" +``` + +--- + +## Task 9: Config migration to INI (`internal/config`) + +**Files:** +- Create: `internal/config/config.ini` +- Modify: `internal/config/config.go` (Load/Save/seed → INI; add `[calendar]` keys + `Selection()`; one-shot TOML→INI convert) +- Test: `internal/config/config_ini_test.go` + +**Interfaces:** +- Consumes: `ini.Parse`/`ini.List` (Task 1), `calendar.Selection`/`DefaultSelection` (Task 2). +- Produces: `func (c Config) Selection() calendar.Selection`; new `Config` fields `LectionaryPlacement` are read from `[calendar]`. `Load()`/`Save()` operate on `config.ini`; existing `config.toml` is auto-converted once. + +- [ ] **Step 1: Write the failing test** + +```go +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadINIAndSelection(t *testing.T) { + dir := t.TempDir() + t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini")) + must := []byte("lectionary = new\n[calendar]\nepiphany = sunday\nascension = sunday\n") + if err := os.WriteFile(filepath.Join(dir, "config.ini"), must, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + sel := cfg.Selection() + if sel.Epiphany != "sunday" || sel.Ascension != "sunday" || sel.CorpusChristi != "thursday" { + t.Fatalf("selection = %+v", sel) + } +} + +func TestMigrateTOML(t *testing.T) { + dir := t.TempDir() + t.Setenv("LECTIO_CONFIG", filepath.Join(dir, "config.ini")) + // only the old TOML exists → Load must convert it once and write config.ini. + old := []byte("lectionary = \"new\"\nui_language = \"pl\"\n") + if err := os.WriteFile(filepath.Join(dir, "config.toml"), old, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.UILanguage != "pl" { + t.Errorf("migrated ui_language = %q", cfg.UILanguage) + } + if _, err := os.Stat(filepath.Join(dir, "config.ini")); err != nil { + t.Error("config.ini not written on migration") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run 'TestLoadINI|TestMigrate'` +Expected: FAIL (`undefined: (Config).Selection` / INI path not honored). + +- [ ] **Step 3: Implement INI load/save + migration + Selection** + +Modify `internal/config/config.go`: +- Change `configPath()` to return `.../lectio/config.ini`. +- Add `readINI(data []byte) (Config, error)` using `ini.Parse`: scalars by key; lists via `ini.List` (`versions`, `web_versions`); the `[calendar]` section fills placement fields; `[parts.<lectionary>]` sections fill `Parts`. +- Add `writeINI(c Config) []byte` (deterministic key order; lists comma-joined; `[calendar]` + `[parts.*]` sections). +- In `Load()`: if `config.ini` is absent but a sibling `config.toml` exists, `toml.Unmarshal` it into a `Config`, then `Save()` it as INI (one-shot migration), then proceed. +- Add fields + accessor: + +```go +// in Config struct: +// CalEpiphany string `toml:"-"` // "fixed"|"sunday" (INI [calendar] epiphany) +// CalAscension string `toml:"-"` +// CalCorpusChristi string `toml:"-"` + +func (c Config) Selection() calendar.Selection { + sel := calendar.DefaultSelection() + sel.Form = orDefault(c.Lectionary, sel.Form) + sel.Epiphany = orDefault(c.CalEpiphany, sel.Epiphany) + sel.Ascension = orDefault(c.CalAscension, sel.Ascension) + sel.CorpusChristi = orDefault(c.CalCorpusChristi, sel.CorpusChristi) + return sel +} + +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} +``` + +Create `internal/config/config.ini` as the INI seed (the INI form of the current +`config.toml` defaults, plus a `[calendar]` section with `epiphany = fixed`, +`ascension = thursday`, `corpus_christi = thursday`). Replace the +`//go:embed config.toml` seed with `//go:embed config.ini`. Retain the go-toml +import **only** inside the migration branch. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/` +Expected: PASS (existing config tests + the two new ones). + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/ +git commit -m "feat(config): INI config + one-shot TOML migration + calendar Selection" +``` + +--- + +## Task 10: Regression oracle (`internal/calendar/oracle_test.go`) + +**Files:** +- Create: `scripts/build-oracle.sh`, `internal/calendar/testdata/oracle-2020-2040.json`, `internal/calendar/oracle_test.go` +- Modify: `internal/caldata/roman-calendar.ini` (iterate until the diff is clean) + +**Interfaces:** +- Consumes: `Compute`, `caldata.Universal()`. +- Produces: a test that computes every day 2020-01-01..2040-12-31 and asserts the observed celebration's season, rank, and colour match the snapshot (identity by date, ignoring localized wording). + +- [ ] **Step 1: Write the snapshot generator (run once, by hand, with network)** + +`scripts/build-oracle.sh` fetches the authoritative calendar for each year from +`https://calapi.inadiutorium.cz/api/v0/en/calendars/general-en/{year}/{month}/{day}` +(calendarium-romanum) and writes `internal/calendar/testdata/oracle-2020-2040.json` +as `{"YYYY-MM-DD": {"season": "...", "rank": "solemnity|feast|memorial|optional|ferial", "colour": "..."}}`. +Map calapi ranks/colours to lectio's vocabulary in the script. This script is +**not** run by `go test`. + +```bash +#!/usr/bin/env bash +set -euo pipefail +out=internal/calendar/testdata/oracle-2020-2040.json +echo '{' > "$out" +# ... loop 2020..2040, day by day, curl calapi, map fields, append "date": {...} ... +echo '}' >> "$out" +``` + +- [ ] **Step 2: Write the oracle test** + +```go +package calendar_test + +import ( + "encoding/json" + "os" + "testing" + "time" + + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/caldata" +) + +type oracleDay struct { + Season, Rank, Colour string +} + +func rankName(r calendar.Rank) string { + switch r { + case calendar.RankSolemnity: + return "solemnity" + case calendar.RankFeast: + return "feast" + case calendar.RankMemorial: + return "memorial" + case calendar.RankOptional: + return "optional" + default: + return "ferial" + } +} + +func TestOracle2020to2040(t *testing.T) { + raw, err := os.ReadFile("testdata/oracle-2020-2040.json") + if err != nil { + t.Skip("oracle snapshot missing; run scripts/build-oracle.sh") + } + var oracle map[string]oracleDay + if err := json.Unmarshal(raw, &oracle); err != nil { + t.Fatal(err) + } + sel := calendar.DefaultSelection() + layers := []calendar.Layer{caldata.Universal()} + var mismatches int + for date, want := range oracle { + day, _ := time.Parse("2006-01-02", date) + got := calendar.Compute(day.UTC(), sel, layers) + if string(got.Season) != want.Season || rankName(got.Observed.Rank) != want.Rank { + mismatches++ + if mismatches <= 25 { + t.Errorf("%s: got %s/%s want %s/%s", date, got.Season, rankName(got.Observed.Rank), want.Season, want.Rank) + } + } + } + if mismatches > 0 { + t.Fatalf("%d structural mismatches vs oracle", mismatches) + } +} +``` + +- [ ] **Step 3: Iterate the engine + data until clean** + +Run the oracle, fix `temporal`/`precedence`/`roman-calendar.ini` until zero +mismatches (documenting any intentional exception — e.g. a genuine +national-vs-universal difference — in a comment). The oracle is the acceptance +gate for Tasks 4, 6, and 8's data. + +- [ ] **Step 4: Run the oracle** + +Run: `go test ./internal/calendar/ -run TestOracle` +Expected: PASS (0 mismatches), or SKIP if the snapshot has not been generated. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/build-oracle.sh internal/calendar/testdata/oracle-2020-2040.json internal/calendar/oracle_test.go internal/caldata/roman-calendar.ini +git commit -m "test(calendar): regression oracle vs calapi 2020-2040 + engine/data fixes" +``` + +--- + +## Task 11: CLI command `lectio calendar [DATE]` (`internal/cli`) + +**Files:** +- Create: `internal/cli/calendar.go` +- Modify: `internal/cli/cli.go` (dispatch the `calendar` subcommand) +- Test: `internal/cli/calendar_test.go` + +**Interfaces:** +- Consumes: `config.Load`, `(Config).Selection`, `caldata.Universal`, `calendar.Compute`. +- Produces: `func RunCalendar(args []string, out io.Writer) error` printing the computed day. + +- [ ] **Step 1: Write the failing test** + +```go +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestRunCalendar(t *testing.T) { + var buf bytes.Buffer + if err := RunCalendar([]string{"2025-08-15"}, &buf); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "2025-08-15") || !strings.Contains(strings.ToLower(out), "solemnity") { + t.Fatalf("output missing date/rank:\n%s", out) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cli/ -run TestRunCalendar` +Expected: FAIL (`undefined: RunCalendar`). + +- [ ] **Step 3: Implement the command** + +```go +package cli + +import ( + "fmt" + "io" + "time" + + "github.com/lukaszkasprzak/lectio/internal/caldata" + "github.com/lukaszkasprzak/lectio/internal/calendar" + "github.com/lukaszkasprzak/lectio/internal/config" +) + +// RunCalendar prints the computed liturgical day for args[0] (YYYY-MM-DD, default today). +func RunCalendar(args []string, out io.Writer) error { + date := time.Now().UTC().Truncate(24 * time.Hour) + if len(args) > 0 && args[0] != "" { + d, err := time.Parse("2006-01-02", args[0]) + if err != nil { + return fmt.Errorf("calendar: bad date %q (want YYYY-MM-DD)", args[0]) + } + date = d.UTC() + } + cfg, err := config.Load() + if err != nil { + return err + } + day := calendar.Compute(date, cfg.Selection(), []calendar.Layer{caldata.Universal()}) + + name := day.Observed.Name["en"] + if pl := day.Observed.Name["pl"]; cfg.UILanguage == "pl" && pl != "" { + name = pl + } + fmt.Fprintf(out, "%s %s\n", date.Format("2006-01-02"), day.Weekday) + fmt.Fprintf(out, "season: %s (week %d, Sunday cycle %s, weekday cycle %s)\n", + day.Season, day.Week, day.SundayCycle, day.WeekdayCycle) + fmt.Fprintf(out, "observed: %s [%s, %s]\n", name, rankLabel(day.Observed.Rank), day.Colour) + for _, o := range day.Others { + fmt.Fprintf(out, " also: %s [%s]\n", o.Name["en"], rankLabel(o.Rank)) + } + for _, m := range day.Observed.Masses { + for _, r := range m.Readings { + label := r.Part + if m.Variant != "" { + label = m.Variant + " " + label + } + fmt.Fprintf(out, " %-14s %s\n", label+":", r.Citation) + } + } + return nil +} + +func rankLabel(r calendar.Rank) string { + switch r { + case calendar.RankSolemnity: + return "solemnity" + case calendar.RankFeast: + return "feast" + case calendar.RankMemorial: + return "memorial" + case calendar.RankOptional: + return "optional memorial" + default: + return "feria" + } +} +``` + +- [ ] **Step 4: Wire the subcommand** + +In `internal/cli/cli.go`, in the top-level argument dispatch, before the default +daily-readings path, add: + +```go +if len(os.Args) > 1 && os.Args[1] == "calendar" { + if err := RunCalendar(os.Args[2:], os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return +} +``` + +(Match the existing dispatch style in `cli.go`; if the CLI uses a flag parser, +register `calendar` as a subcommand consistently with `--calendar`/existing +commands.) + +- [ ] **Step 5: Run tests + manual check** + +Run: `go test ./internal/cli/ -run TestRunCalendar && go run ./cmd/lectio calendar 2025-08-15` +Expected: PASS; the manual run prints the Assumption as a white solemnity. + +- [ ] **Step 6: Commit** + +```bash +git add internal/cli/calendar.go internal/cli/cli.go internal/cli/calendar_test.go +git commit -m "feat(cli): 'lectio calendar [DATE]' computes the liturgical day offline" +``` + +--- + +## Task 12: Version bump + full verification + +**Files:** +- Modify: `internal/config/config.go` (`Version`) + +- [ ] **Step 1: Bump the version** + +In `internal/config/config.go`, change `const Version = "0.25.0"` to `"0.26.0"`. + +- [ ] **Step 2: Full build, vet, test** + +Run: +```bash +go build ./... && go vet ./... && go test ./... +``` +Expected: all packages PASS (oracle SKIPs only if the snapshot is absent — it must be present and passing before merge). + +- [ ] **Step 3: Confirm engine purity** + +Run: +```bash +go list -deps ./internal/calendar | grep -E '^github.com/(lukaszkasprzak/lectio|)' | grep -v 'internal/calendar$' || echo "PURE: calendar has no internal/third-party deps" +``` +Expected: `PURE` (the `calendar` package imports only stdlib). + +- [ ] **Step 4: Commit** + +```bash +git add internal/config/config.go +git commit -m "chore: bump to 0.26.0 (offline OF calendar engine)" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Pure stdlib engine, no network, total `Compute` — Tasks 2–7, enforced by Task 12 Step 3. ✅ +- INI config + INI celebration records + TSV corpora untouched — Tasks 1, 8, 9. ✅ +- Computus, temporal, sanctoral date resolution, precedence (Universal Norms), layer merge — Tasks 2–7. ✅ +- Inline proper readings surfaced — Task 7 (`massFrom`/`buildCelebration`), shown in Task 11 output. ✅ +- Ordered layer-stack signature (`Compute(..., layers []Layer)`) with #1 shipping only the universal layer — Tasks 7, 8. ✅ +- romcal-bootstrap-then-own data + NOTICE — Task 8 Step 5. ✅ +- Regression oracle vs calapi/romcal 2020-2040, offline snapshot — Task 10. ✅ +- TOML→INI migration as an early task — Task 9. ✅ +- `lectio calendar [DATE]` demo surface, scraper untouched — Task 11. ✅ +- Config `[calendar]` selection keys + placement knobs (Epiphany/Ascension/Corpus Christi) — Tasks 9, 4. ✅ +- Deferred (correctly absent): temporal reading cycle (#3), override-file loading + `use =` (#2), API (#4), EF, `bt`, UI migration. ✅ + +**Placeholder scan:** no TBD/TODO. The two "Implementer note" blocks (Tasks 4, 6) give the exact rules + tests + the oracle as the acceptance gate rather than hand-waving; the data-population step (Task 8.5) is a data task specified by source + verification, not code. + +**Type consistency:** `Compute(date time.Time, sel Selection, layers []Layer) LiturgicalDay`, `Layer{ID,Name,Type string; Cels map[string]RawCelebration}`, `RawCelebration{Fields, Variants}`, `Celebration{Slug,Name,Rank,Class,Colour,Date,Masses,Layer}`, `Selection{Form,Epiphany,Ascension,CorpusChristi}`, `candidate{Cel,Temporal,Privileged,Season}` — used identically in Tasks 2, 5, 6, 7, 8, 9, 11. `Easter`, `resolveDate`, `temporal`, `mergeLayers`, `precedence`, `pick`, `buildCelebration` signatures match across producer/consumer tasks. + +**Known iteration points (honest):** the temporal season builders (Task 4) and precedence bands 6/9 + transfer (Task 6) are first cuts validated wholesale by the oracle (Task 10); expect a fix loop there. This is by design — the oracle is the correctness authority. |
