# 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 `"--"` / `"-sunday-"`, > `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.]` 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.