summaryrefslogtreecommitdiff
path: root/docs/superpowers
diff options
context:
space:
mode:
authorŁukasz <lukasz@arcofasiagroup.com>2026-07-23 12:03:33 +0200
committerŁukasz <lukasz@arcofasiagroup.com>2026-07-23 12:03:33 +0200
commitfd5f75e4080ee1c6c17a70414349eea8ea1903d2 (patch)
treea395f126abe2017f098a9925a95100f0fedfed29 /docs/superpowers
parenteece293ab9849eb8367c791cf6b5d29697c6eaed (diff)
downloadlectio-fd5f75e4080ee1c6c17a70414349eea8ea1903d2.tar.gz
lectio-fd5f75e4080ee1c6c17a70414349eea8ea1903d2.zip
Implementation plan: 14 TDD tasks for the lectio Go rewrite
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md1378
1 files changed, 1378 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md b/docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md
new file mode 100644
index 0000000..84e5c32
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-23-lectio-go-rewrite.md
@@ -0,0 +1,1378 @@
+# lectio Go Rewrite 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:** Reimplement the Python `daily-reading` tool as a self-contained Go project with two binaries — `lectio` (subcommand CLI) and `lectio-ui` (colored Bubble Tea TUI) — that fetches the daily Catholic readings and shows them across five versions with correct psalm versification, works offline from a harvested sigla file, and needs no external tools.
+
+**Architecture:** Thin front-ends (`cmd/lectio`, `cmd/lectio-ui`) over shared `internal/` packages: `bible` (embedded corpora + lookup + citation conversion), `psalter` (versification), `liturgy` (fetch/parse/cache/harvest/offline), `config`, `render` (text output), `cli`, `tui`. Modelled on `~/git/projects/bread-calc`.
+
+**Tech Stack:** Go 1.24, `github.com/charmbracelet/bubbletea` + `lipgloss` (TUI), `github.com/pelletier/go-toml/v2` (config). Stdlib `flag`, `net/http`, `embed`, `encoding/json` elsewhere.
+
+**Spec:** `docs/superpowers/specs/2026-07-23-lectio-go-rewrite-design.md` (read it before starting).
+
+## Global Constraints
+
+- Module path: `github.com/lukaszkasprzak/lectio`. Go directive: `go 1.24.0`.
+- No external runtime tools (no `vul`/`grb`/`wuj`/`drb`); all four corpora are embedded.
+- No CLI framework dependency (cobra etc.) — dispatch hand-rolled with stdlib `flag`.
+- Five version codes: `pl, wuj, vul, grb, drb`. Labels: `pl="Polski (niedziela.pl)"`, `wuj="Wujek (pol.)"`, `vul="Wulgata (lac.)"`, `grb="Grecki"`, `drb="Douay-Rheims (ang.)"`.
+- Psalm systems: `vul/grb/wuj` → `"vulgate"`, `drb` → `"drb"`. `pl` is not a bible-lookup version.
+- Cache dir: `${XDG_CACHE_HOME:-~/.cache}/lectio/`. Data dir (sigla): `${XDG_DATA_HOME:-~/.local/share}/lectio/sigla.tsv`. Config: `${XDG_CONFIG_HOME:-~/.config}/lectio/config.toml`.
+- Source URL: `https://niezbednik.niedziela.pl/liturgia/{date}/Ewangelia`. Dates are `YYYY-MM-DD`.
+- User-Agent: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36`.
+- Offline: `pl` is dropped and `wuj` takes the Polish role.
+- Exit codes: 0 ok, 1 runtime error, 2 usage error.
+- `gofmt`-formatted; `go vet ./...` clean. Commit after every task.
+
+---
+
+## File Structure
+
+```
+go.mod go.sum Makefile .gitignore README.md LICENSE
+cmd/lectio/main.go -> cli.Run(os.Args[1:], stdin, stdout, stderr)
+cmd/lectio-ui/main.go -> load config, tui.New(...), tea program
+internal/psalter/psalter.go DRB_TITLE_FOLD, DrbVerse, HebrewToVulgateChapter
+internal/bible/corpora/*.tsv embedded wuj.tsv vul.tsv grb.tsv drb.tsv
+internal/bible/bible.go Corpus, Load, Lookup, Verse
+internal/bible/books.go canonical books, alias table, ResolveBook
+internal/bible/ref.go ParseRef, SplitRef (reference grammar)
+internal/bible/convert.go ToEnglishRef, psalmRef (Polish citation -> English)
+internal/liturgy/section.go Section type, version constants/labels
+internal/liturgy/parse.go Parse(html) []Section
+internal/liturgy/fetch.go Fetch, cache (HTML+JSON), Load
+internal/liturgy/store.go Harvest (update), sigla TSV, offline Load
+internal/liturgy/testdata/*.html fixtures
+internal/config/config.go Config, Load, seed
+internal/render/render.go GatherVersion, Compare, RenderSection, dedup
+internal/cli/cli.go Run + subcommand dispatch
+internal/tui/styles.go color role styles
+internal/tui/tui.go Model, Init, Update, View
+```
+
+---
+
+## Task 1: Project scaffold + vendored corpora
+
+**Files:**
+- Create: `go.mod`, `Makefile`, `.gitignore`, `LICENSE`, `cmd/lectio/main.go`, `cmd/lectio-ui/main.go`
+- Create: `internal/bible/corpora/wuj.tsv`, `vul.tsv`, `grb.tsv`, `drb.tsv`
+
+**Interfaces:**
+- Produces: buildable module; `internal/cli.Run` and `internal/tui` referenced by mains (stubbed this task).
+
+- [ ] **Step 1: Init module and vendor corpora**
+
+```bash
+cd ~/git/projects/lectio
+cat > go.mod <<'EOF'
+module github.com/lukaszkasprzak/lectio
+
+go 1.24.0
+
+require (
+ github.com/charmbracelet/bubbletea v1.3.10
+ github.com/charmbracelet/lipgloss v1.1.0
+ github.com/pelletier/go-toml/v2 v2.4.2
+)
+EOF
+mkdir -p internal/bible/corpora cmd/lectio cmd/lectio-ui
+cp ~/git/projects/offline_readings/corpus/wuj.tsv internal/bible/corpora/wuj.tsv
+cp ~/git/projects/offline_readings/drb/drb.tsv internal/bible/corpora/drb.tsv
+sed '1,/^#EOF$/d' "$(command -v vul)" | tar xzf - -O vul.tsv > internal/bible/corpora/vul.tsv
+sed '1,/^#EOF$/d' "$(command -v grb)" | tar xzf - -O grb.tsv > internal/bible/corpora/grb.tsv
+# grb.tsv has a UTF-8 BOM on line 1; strip it
+sed -i '1s/^\xEF\xBB\xBF//' internal/bible/corpora/grb.tsv
+wc -l internal/bible/corpora/*.tsv
+```
+
+Expected: four files, ~31k–35k lines each.
+
+- [ ] **Step 2: Stub the two mains so the tree builds**
+
+`cmd/lectio/main.go`:
+```go
+package main
+
+import (
+ "os"
+
+ "github.com/lukaszkasprzak/lectio/internal/cli"
+)
+
+func main() {
+ os.Exit(cli.Run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
+}
+```
+
+`internal/cli/cli.go` (temporary stub, replaced in Task 12):
+```go
+package cli
+
+import (
+ "fmt"
+ "io"
+)
+
+// Run is the CLI entry point; returns a process exit code.
+func Run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ fmt.Fprintln(stdout, "lectio: not yet implemented")
+ return 0
+}
+```
+
+`cmd/lectio-ui/main.go` (temporary stub, replaced in Task 15):
+```go
+package main
+
+import "fmt"
+
+func main() { fmt.Println("lectio-ui: not yet implemented") }
+```
+
+- [ ] **Step 3: Makefile, .gitignore, LICENSE**
+
+`Makefile` (adapt bread-calc's):
+```make
+LECTIO := lectio
+LECTIO_UI := lectio-ui
+PREFIX ?= $(HOME)/.local
+BINDIR := $(PREFIX)/bin
+
+.PHONY: help build install uninstall test vet fmt clean cross
+help: ## show this help
+ @grep -hE '^[a-z-]+:.*##' $(MAKEFILE_LIST) | sed -E 's/:.*## /\t/' | sort
+build: ## build ./lectio and ./lectio-ui
+ go build -o $(LECTIO) ./cmd/lectio
+ go build -o $(LECTIO_UI) ./cmd/lectio-ui
+install: ## build and install both to $(BINDIR)
+ @mkdir -p $(BINDIR)
+ go build -o $(BINDIR)/$(LECTIO) ./cmd/lectio
+ go build -o $(BINDIR)/$(LECTIO_UI) ./cmd/lectio-ui
+ @echo "installed $(BINDIR)/$(LECTIO) and $(BINDIR)/$(LECTIO_UI)"
+uninstall: ## remove installed binaries
+ rm -f $(BINDIR)/$(LECTIO) $(BINDIR)/$(LECTIO_UI)
+test: ## run tests
+ go test ./...
+vet: ## go vet
+ go vet ./...
+fmt: ## gofmt the tree
+ gofmt -w .
+clean: ## remove build artifacts
+ rm -f $(LECTIO) $(LECTIO_UI); rm -rf dist
+cross: ## cross-compile into dist/
+ @mkdir -p dist
+ @for t in linux/amd64 linux/arm64 darwin/arm64 darwin/amd64 windows/amd64; do \
+ os=$${t%/*}; arch=$${t#*/}; ext=; [ $$os = windows ] && ext=.exe; \
+ echo " $$os/$$arch"; \
+ GOOS=$$os GOARCH=$$arch go build -o dist/$(LECTIO)-$$os-$$arch$$ext ./cmd/lectio; \
+ GOOS=$$os GOARCH=$$arch go build -o dist/$(LECTIO_UI)-$$os-$$arch$$ext ./cmd/lectio-ui; \
+ done
+```
+
+`.gitignore`:
+```
+/lectio
+/lectio-ui
+dist/
+*.test
+*.out
+coverage.*
+```
+
+`LICENSE`: copy `~/git/projects/bread-calc/LICENSE` verbatim.
+
+- [ ] **Step 4: Fetch deps and build**
+
+Run:
+```bash
+go mod tidy
+go build ./cmd/lectio ./cmd/lectio-ui && ./lectio
+```
+Expected: `go.sum` written; build succeeds; prints `lectio: not yet implemented`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "scaffold: module, mains, Makefile, embedded corpora"
+```
+
+---
+
+## Task 2: internal/psalter (versification port)
+
+**Files:**
+- Create: `internal/psalter/psalter.go`, `internal/psalter/psalter_test.go`
+- Source: port `~/git/projects/daily-reading/psalm_versify.py` verbatim.
+
+**Interfaces:**
+- Produces: `psalter.DrbVerse(hebrewPsalm, lectionaryVerse int) int`; `psalter.HebrewToVulgateChapter(h int) int`; `psalter.DRBTitleFold map[int]int`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/psalter/psalter_test.go`:
+```go
+package psalter
+
+import "testing"
+
+func TestDrbVerse(t *testing.T) {
+ cases := []struct{ psalm, v, want int }{
+ {34, 2, 1}, // Ps 34: k=1, title folded
+ {34, 3, 2},
+ {51, 3, 1}, // Ps 51: k=2 (two-line title)
+ {63, 2, 1}, // Ps 63: k=1
+ {1, 5, 5}, // untitled: identity
+ {34, 1, 1}, // clamp: never below 1
+ }
+ for _, c := range cases {
+ if got := DrbVerse(c.psalm, c.v); got != c.want {
+ t.Errorf("DrbVerse(%d,%d)=%d want %d", c.psalm, c.v, got, c.want)
+ }
+ }
+}
+
+func TestHebrewToVulgateChapter(t *testing.T) {
+ cases := []struct{ h, want int }{{8, 8}, {34, 33}, {9, 9}, {10, 9}, {116, 114}, {147, 146}, {150, 150}}
+ for _, c := range cases {
+ if got := HebrewToVulgateChapter(c.h); got != c.want {
+ t.Errorf("HebrewToVulgateChapter(%d)=%d want %d", c.h, got, c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/psalter/` — Expected: FAIL (undefined: DrbVerse).
+
+- [ ] **Step 3: Implement psalter.go**
+
+Port the data and functions from `psalm_versify.py`. `DRBTitleFold` is the exact `DRB_TITLE_FOLD` dict (63 entries) rendered as a Go map literal; `DrbVerse` and `HebrewToVulgateChapter` mirror the Python functions.
+
+`internal/psalter/psalter.go`:
+```go
+// Package psalter bridges psalm versification between the Vulgate-family
+// versions and the Douay-Rheims source. See the Go port of psalm_versify.py.
+package psalter
+
+// DRBTitleFold maps a Hebrew psalm number to the number of title verses the DRB
+// source folds into verse 1 (1, or 2 for a historical superscription). Untitled
+// psalms are absent (k = 0). Copy every entry from psalm_versify.py DRB_TITLE_FOLD.
+var DRBTitleFold = map[int]int{
+ 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, 8: 1, 11: 1, 12: 1, 14: 1, 18: 1, 19: 1,
+ 20: 1, 21: 1, 22: 1, 30: 1, 31: 1, 34: 1, 36: 1, 38: 1, 39: 1, 40: 1,
+ 41: 1, 42: 1, 44: 1, 45: 1, 46: 1, 47: 1, 48: 1, 49: 1, 51: 2, 52: 2,
+ 53: 1, 54: 2, 55: 1, 56: 1, 57: 1, 58: 1, 59: 1, 60: 2, 61: 1, 62: 1,
+ 63: 1, 64: 1, 65: 1, 67: 1, 68: 1, 69: 1, 70: 1, 75: 1, 76: 1, 77: 1,
+ 80: 1, 81: 1, 83: 1, 84: 1, 85: 1, 88: 1, 89: 1, 92: 1, 102: 1, 108: 1,
+ 140: 1, 142: 1,
+}
+
+// DrbVerse returns the DRB-source verse number for a lectionary (BT) psalm verse.
+func DrbVerse(hebrewPsalm, lectionaryVerse int) int {
+ v := lectionaryVerse - DRBTitleFold[hebrewPsalm]
+ if v < 1 {
+ return 1
+ }
+ return v
+}
+
+// HebrewToVulgateChapter maps a Masoretic psalm number to its Vulgate chapter.
+func HebrewToVulgateChapter(h int) int {
+ switch {
+ case h <= 8 || h >= 148:
+ return h
+ case h >= 9 && h <= 10:
+ return 9
+ case h >= 11 && h <= 113:
+ return h - 1
+ case h >= 114 && h <= 115:
+ return 113
+ case h == 116:
+ return 114
+ case h >= 117 && h <= 146:
+ return h - 1
+ case h == 147:
+ return 146
+ }
+ return h
+}
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/psalter/` — Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/psalter && git commit -m "psalter: port psalm versification"
+```
+
+---
+
+## Task 3: internal/bible — canonical books + alias resolution
+
+**Files:**
+- Create: `internal/bible/books.go`, `internal/bible/books_test.go`
+- Source: port book names/abbrevs from the four corpora and the alias table from `~/git/projects/offline_readings/corpus/book_aliases.py` + `~/git/projects/daily-reading/ewangelia.py` `POLISH_TO_EN`.
+
+**Interfaces:**
+- Produces: `bible.ResolveBook(query string) (canonical string, ok bool)` — resolves an English name, English prefix, or Polish alias to a canonical English book name (as used in the corpora, e.g. `"John"`, `"1 Corinthians"`, `"The Acts"`, `"Song of Solomon"`, `"Wisdom"`).
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/bible/books_test.go`:
+```go
+package bible
+
+import "testing"
+
+func TestResolveBook(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"John", "John"},
+ {"Joh", "John"}, // English prefix
+ {"J", "John"}, // Polish abbrev — NOT Joshua
+ {"Łk", "Luke"},
+ {"1 Kor", "1 Corinthians"},
+ {"Jana", "John"},
+ {"Rodzaju", "Genesis"},
+ {"Mdr", "Wisdom"},
+ {"Pnp", "Song of Solomon"},
+ {"Dz", "The Acts"},
+ }
+ for _, c := range cases {
+ if got, ok := ResolveBook(c.in); !ok || got != c.want {
+ t.Errorf("ResolveBook(%q)=%q,%v want %q", c.in, got, ok, c.want)
+ }
+ }
+ if _, ok := ResolveBook("Nonsense"); ok {
+ t.Error("ResolveBook(Nonsense) should fail")
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/bible/ -run TestResolveBook` — Expected: FAIL (undefined: ResolveBook).
+
+- [ ] **Step 3: Implement books.go**
+
+Build an alias→canonical map. Resolution order (deterministic, fixes `J`→John): (1) exact alias match (longest alias wins for multi-word), (2) exact canonical name, (3) canonical-name prefix (len ≥ 2). Port the alias sets verbatim from `book_aliases.py` `BOOKS` (73 books incl. deuterocanonicals; each entry's aliases plus the canonical name). Generate case variants (original, lower, upper) at init as `book_aliases.py` does.
+
+`internal/bible/books.go` (structure — fill `aliasSeed` from `book_aliases.py`):
+```go
+package bible
+
+import "strings"
+
+// canonical English book names, in corpus form.
+// aliasSeed maps a canonical name to its accepted aliases (Polish abbrev, Polish
+// full name, English abbrev). COPY every entry from offline_readings book_aliases.py.
+var aliasSeed = map[string][]string{
+ "Genesis": {"Rdz", "Rodzaju", "Gen"},
+ "Exodus": {"Wj", "Wyjścia", "Ex", "Exod"},
+ // ... all 73 books, verbatim from book_aliases.py ...
+ "John": {"J", "Jan", "Jana"},
+ "1 Corinthians": {"1 Kor", "1 Koryntian"},
+ "Song of Solomon": {"Pnp", "Pieśń nad Pieśniami"},
+ "The Acts": {"Dz", "Dzieje", "Dzieje Apostolskie", "Acts"},
+ "Wisdom": {"Mdr", "Mądrości", "Księga Mądrości"},
+ "Revelation": {"Ap", "Apokalipsa", "Objawienie"},
+}
+
+var aliasMap map[string]string // lowercased alias -> canonical
+var canonical []string // canonical names for prefix matching
+
+func init() {
+ aliasMap = map[string]string{}
+ seen := map[string]bool{}
+ for name, aliases := range aliasSeed {
+ canonical = append(canonical, name)
+ for _, a := range append([]string{name}, aliases...) {
+ k := strings.ToLower(a)
+ if !seen[k] {
+ seen[k] = true
+ aliasMap[k] = name
+ }
+ }
+ }
+}
+
+// ResolveBook maps an English name/prefix or a Polish alias to a canonical book.
+func ResolveBook(query string) (string, bool) {
+ q := strings.ToLower(strings.TrimSpace(query))
+ if c, ok := aliasMap[q]; ok { // exact alias/name
+ return c, true
+ }
+ for _, name := range canonical { // canonical prefix, len>=2
+ if len(q) >= 2 && strings.HasPrefix(strings.ToLower(name), q) {
+ return name, true
+ }
+ }
+ return "", false
+}
+```
+
+Note: the alias map's exact match runs before prefix, so `"j"` resolves via alias to `John` and never prefix-matches `Joshua`.
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/bible/ -run TestResolveBook` — Expected: PASS. If `J`→Joshua, the alias for John is missing; verify `aliasSeed["John"]` contains `"J"`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/bible/books.go internal/bible/books_test.go
+git commit -m "bible: canonical books + alias resolution"
+```
+
+---
+
+## Task 4: internal/bible — embedded corpora + Lookup
+
+**Files:**
+- Create: `internal/bible/bible.go`, `internal/bible/bible_test.go`
+
+**Interfaces:**
+- Consumes: `ResolveBook` (Task 3).
+- Produces: `bible.Verse{Chapter, Verse int; Text string}`; `bible.Lookup(version, book string, groups []VerseGroup) ([]Verse, []string)` where `VerseGroup` is defined in Task 5. **This task provides the corpus store + a simpler `lookupBook(version, book string) (map[[2]int]string, []int, bool)` helper**; the group-aware `Lookup` is completed in Task 5. Split accordingly: here, deliver corpus loading + a `Verses(version, book, chap int) []Verse` accessor.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/bible/bible_test.go`:
+```go
+package bible
+
+import "testing"
+
+func TestVerses(t *testing.T) {
+ cases := []struct {
+ version, book string
+ chap, verse int
+ wantPrefix string
+ }{
+ {"wuj", "Genesis", 1, 1, "Na początku stworzył Bóg"},
+ {"vul", "Genesis", 1, 1, "In principio creavit Deus"},
+ {"drb", "John", 20, 1, "AND on the first day of the week"},
+ {"wuj", "Wisdom", 3, 1, "A dusze sprawiedliwych"}, // deuterocanonical
+ }
+ for _, c := range cases {
+ vs := Verses(c.version, c.book, c.chap)
+ var got string
+ for _, v := range vs {
+ if v.Verse == c.verse {
+ got = v.Text
+ }
+ }
+ if !hasPrefix(got, c.wantPrefix) {
+ t.Errorf("%s %s %d:%d = %q want prefix %q", c.version, c.book, c.chap, c.verse, got, c.wantPrefix)
+ }
+ }
+}
+
+func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/bible/ -run TestVerses` — Expected: FAIL (undefined: Verses).
+
+- [ ] **Step 3: Implement bible.go**
+
+Embed the four TSVs; parse each once (lazy, `sync.Once`) into `map[book]map[chap][]Verse`.
+
+```go
+package bible
+
+import (
+ "embed"
+ "strconv"
+ "strings"
+ "sync"
+)
+
+//go:embed corpora/wuj.tsv corpora/vul.tsv corpora/grb.tsv corpora/drb.tsv
+var corporaFS embed.FS
+
+// Verse is a single verse.
+type Verse struct {
+ Chapter, Verse int
+ Text string
+}
+
+type corpus struct {
+ books map[string]map[int][]Verse // book -> chapter -> verses (verse-ordered)
+}
+
+var (
+ corpora = map[string]*corpus{}
+ corporaMu sync.Mutex
+)
+
+func load(version string) *corpus {
+ corporaMu.Lock()
+ defer corporaMu.Unlock()
+ if c, ok := corpora[version]; ok {
+ return c
+ }
+ data, err := corporaFS.ReadFile("corpora/" + version + ".tsv")
+ if err != nil {
+ corpora[version] = &corpus{books: map[string]map[int][]Verse{}}
+ return corpora[version]
+ }
+ c := &corpus{books: map[string]map[int][]Verse{}}
+ for _, line := range strings.Split(string(data), "\n") {
+ f := strings.Split(line, "\t")
+ if len(f) != 6 {
+ continue
+ }
+ chap, _ := strconv.Atoi(f[3])
+ vn, _ := strconv.Atoi(f[4])
+ book := f[0]
+ if c.books[book] == nil {
+ c.books[book] = map[int][]Verse{}
+ }
+ c.books[book][chap] = append(c.books[book][chap], Verse{chap, vn, f[5]})
+ }
+ corpora[version] = c
+ return c
+}
+
+// Verses returns all verses of one chapter of a book in a version (may be empty).
+func Verses(version, book string, chap int) []Verse {
+ return load(version).books[book][chap]
+}
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/bible/ -run TestVerses` — Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/bible/bible.go internal/bible/bible_test.go
+git commit -m "bible: embed corpora + chapter lookup"
+```
+
+---
+
+## Task 5: internal/bible — reference grammar (ParseRef, SplitRef, Lookup)
+
+**Files:**
+- Create: `internal/bible/ref.go`, `internal/bible/ref_test.go`
+
+**Interfaces:**
+- Consumes: `ResolveBook`, `Verses`.
+- Produces:
+ - `type VerseGroup struct{ Chapter, From, To int }` (To==From for a single verse; a whole-verse-list "20:1,2,3" is multiple groups).
+ - `SplitRef(ref string) []string` — split a kjv-style ref with a mixed comma/range list into single-group refs (port of Python `split_ref`).
+ - `Lookup(version, ref string) (verses []Verse, missing []string)` — resolve a full English-style ref (`"John 20:1,11-18"`), returning matched verses in order and the sub-refs the corpus lacked.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/bible/ref_test.go`:
+```go
+package bible
+
+import "testing"
+
+func TestSplitRef(t *testing.T) {
+ got := SplitRef("John 20:1,11-18")
+ want := []string{"John 20:1", "John 20:11-18"}
+ if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
+ t.Fatalf("SplitRef mixed = %v want %v", got, want)
+ }
+ if g := SplitRef("Mat 7:1-5"); len(g) != 1 || g[0] != "Mat 7:1-5" {
+ t.Errorf("SplitRef contiguous = %v", g)
+ }
+}
+
+func TestLookup(t *testing.T) {
+ vs, missing := Lookup("wuj", "John 20:1,11-18")
+ if len(missing) != 0 {
+ t.Fatalf("missing = %v", missing)
+ }
+ if len(vs) == 0 || vs[0].Verse != 1 {
+ t.Fatalf("first verse = %+v", vs)
+ }
+ last := vs[len(vs)-1]
+ if last.Verse != 18 {
+ t.Errorf("last verse = %d want 18", last.Verse)
+ }
+ if _, m := Lookup("vul", "Wisdom 3:1"); len(m) == 0 {
+ t.Error("vul lacks Wisdom; expected a missing entry")
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/bible/ -run 'TestSplitRef|TestLookup'` — Expected: FAIL.
+
+- [ ] **Step 3: Implement ref.go**
+
+Port `split_ref` and the reference-parsing logic. A ref is `<book> <chap>:<verses>` where `<verses>` is a comma list of items, each a single verse or a `from-to` range. `SplitRef` groups a mixed list (single verses + ranges) into separate refs, mirroring Python. `Lookup` resolves the book, then for each `SplitRef` part parses the chapter/range, pulls from `Verses`, and records misses.
+
+```go
+package bible
+
+import (
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+var refRe = regexp.MustCompile(`^(.*?)\s+(\d+):(.+)$`)
+
+// SplitRef splits a ref whose verse list mixes single verses and ranges into
+// one ref per group (the kjv tools reject a mixed list in a single query).
+func SplitRef(ref string) []string {
+ m := refRe.FindStringSubmatch(ref)
+ if m == nil {
+ return []string{ref}
+ }
+ book, chap, verses := m[1], m[2], m[3]
+ if !strings.Contains(verses, ",") {
+ return []string{ref}
+ }
+ var out []string
+ for _, g := range strings.Split(verses, ",") {
+ g = strings.TrimSpace(g)
+ if g == "" {
+ continue
+ }
+ if strings.Contains(g, ":") {
+ out = append(out, book+" "+g)
+ } else {
+ out = append(out, book+" "+chap+":"+g)
+ }
+ }
+ return out
+}
+
+// Lookup resolves an English-style reference against a version, returning the
+// matched verses (in order) and the sub-refs the corpus had no entry for.
+func Lookup(version, ref string) ([]Verse, []string) {
+ var verses []Verse
+ var missing []string
+ for _, part := range SplitRef(ref) {
+ m := refRe.FindStringSubmatch(part)
+ if m == nil {
+ missing = append(missing, part)
+ continue
+ }
+ book, ok := ResolveBook(m[1])
+ if !ok {
+ missing = append(missing, part)
+ continue
+ }
+ chap, _ := strconv.Atoi(m[2])
+ from, to := verseRange(m[3])
+ found := false
+ for _, v := range Verses(version, book, chap) {
+ if v.Verse >= from && v.Verse <= to {
+ verses = append(verses, v)
+ found = true
+ }
+ }
+ if !found {
+ missing = append(missing, part)
+ }
+ }
+ return verses, missing
+}
+
+func verseRange(s string) (int, int) {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexAny(s, "-–—"); i >= 0 {
+ from, _ := strconv.Atoi(strings.TrimSpace(s[:i]))
+ to, _ := strconv.Atoi(strings.TrimSpace(s[i+1:]))
+ return from, to
+ }
+ n, _ := strconv.Atoi(s)
+ return n, n
+}
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/bible/ -run 'TestSplitRef|TestLookup'` — Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/bible/ref.go internal/bible/ref_test.go
+git commit -m "bible: reference grammar + Lookup"
+```
+
+---
+
+## Task 6: internal/bible — Polish citation → English ref conversion
+
+**Files:**
+- Create: `internal/bible/convert.go`, `internal/bible/convert_test.go`
+- Source: port `to_english_ref` / `_psalm_ref` and `POLISH_TO_EN` from `~/git/projects/daily-reading/ewangelia.py`.
+
+**Interfaces:**
+- Consumes: `psalter.DrbVerse`.
+- Produces: `bible.ToEnglishRef(plCitation, system string) (string, error)` where `system` ∈ `"vulgate"`, `"drb"`. Converts a Polish citation (`"Mt 7, 1-5"`, `"por. J 20, 11"`, `"Ps 63 (62), 2. 3-4 (R.: por. 2ab)"`) to a kjv-style ref.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/bible/convert_test.go`:
+```go
+package bible
+
+import "testing"
+
+func TestToEnglishRef(t *testing.T) {
+ cases := []struct{ in, system, want string }{
+ {"Mt 7, 1-5", "vulgate", "Mat 7:1-5"},
+ {"J 20, 1. 11-18", "vulgate", "John 20:1,11-18"},
+ {"por. J 20, 11", "vulgate", "John 20:11"},
+ {"Ps 63 (62), 2. 3-4. 5-6. 8-9 (R.: por. 2ab)", "vulgate", "Psalms 62:2,3-4,5-6,8-9"},
+ {"Ps 63 (62), 2. 3-4. 5-6. 8-9 (R.: por. 2ab)", "drb", "Psalms 63:1,2-3,4-5,7-8"},
+ }
+ for _, c := range cases {
+ got, err := ToEnglishRef(c.in, c.system)
+ if err != nil || got != c.want {
+ t.Errorf("ToEnglishRef(%q,%q)=%q,%v want %q", c.in, c.system, got, err, c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/bible/ -run TestToEnglishRef` — Expected: FAIL.
+
+- [ ] **Step 3: Implement convert.go**
+
+Port `POLISH_TO_EN` (the full dict from `ewangelia.py`, ~66 entries — note it maps to short gospel forms `Mt→Mat` etc.), `to_english_ref`, and `_psalm_ref`. Steps mirror the Python exactly: strip leading `por.`, strip trailing `(R.: ...)`, collapse whitespace, match the longest Polish book key, for Psalms route through `psalmRef` (dual-numbering pick + drb verse fold via `psalter.DrbVerse`), then the punctuation transforms (`, `→`:`, `. `→`,`, ` i `→`,`, ranges, drop verse-part letters, remove spaces).
+
+Provide the full function bodies (translated line-for-line from the Python). Key psalm helper:
+```go
+// psalmRef maps the psalm citation body ("63 (62), 2. 3-4. ...") to the target Psalter.
+func psalmRef(rest, system string) string {
+ m := regexp.MustCompile(`(\d+)(?:\s*\((\d+)\))?(.*)$`).FindStringSubmatch(rest)
+ if m == nil {
+ return rest
+ }
+ heb, _ := strconv.Atoi(m[1])
+ tail := m[3]
+ if system == "vulgate" {
+ ch := m[1]
+ if m[2] != "" {
+ ch = m[2]
+ }
+ return ch + tail
+ }
+ if system == "drb" {
+ tail = regexp.MustCompile(`\d+`).ReplaceAllStringFunc(tail, func(s string) string {
+ n, _ := strconv.Atoi(s)
+ return strconv.Itoa(psalter.DrbVerse(heb, n))
+ })
+ }
+ return strconv.Itoa(heb) + tail
+}
+```
+`ToEnglishRef` returns an `error` when no Polish book matches (mirrors the Python `ValueError`).
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/bible/` — Expected: PASS (all bible tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/bible/convert.go internal/bible/convert_test.go
+git commit -m "bible: Polish citation -> English ref (with psalm versification)"
+```
+
+---
+
+## Task 7: internal/liturgy — Section type + Parse
+
+**Files:**
+- Create: `internal/liturgy/section.go`, `internal/liturgy/parse.go`, `internal/liturgy/parse_test.go`
+- Create fixtures: `internal/liturgy/testdata/2026-07-22.html` (split feast), `2026-06-22.html` (normal day). Copy from `~/.cache/daily-reading/`.
+
+**Interfaces:**
+- Produces: `liturgy.Section{Heading, Subtitle, Citation string; Paragraphs [][]string}`; `liturgy.Parse(html string) ([]Section, error)`; `liturgy.ExtractCitation(heading string) (string, error)`.
+
+- [ ] **Step 1: Copy fixtures + write the failing test**
+
+```bash
+mkdir -p internal/liturgy/testdata
+cp ~/.cache/daily-reading/2026-07-22.html internal/liturgy/testdata/
+cp ~/.cache/daily-reading/2026-06-22.html internal/liturgy/testdata/
+```
+
+`internal/liturgy/parse_test.go`:
+```go
+package liturgy
+
+import (
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestParse(t *testing.T) {
+ html, _ := os.ReadFile("testdata/2026-07-22.html")
+ secs, err := Parse(string(html))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var gospel *Section
+ for i := range secs {
+ if strings.HasPrefix(secs[i].Heading, "Ewangelia") {
+ gospel = &secs[i]
+ }
+ }
+ if gospel == nil {
+ t.Fatal("no gospel section")
+ }
+ if gospel.Citation != "J 20, 1. 11-18" {
+ t.Errorf("gospel citation = %q", gospel.Citation)
+ }
+ if len(gospel.Paragraphs) == 0 {
+ t.Error("gospel has no paragraphs")
+ }
+}
+
+func TestParseLayoutChange(t *testing.T) {
+ if _, err := Parse("<html><body>redesigned</body></html>"); err == nil {
+ t.Error("expected error on missing reading tab")
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/liturgy/ -run TestParse` — Expected: FAIL (undefined: Parse).
+
+- [ ] **Step 3: Implement section.go + parse.go**
+
+Port `parse_sections`, `html_to_lines`, `extract_reference` from `ewangelia.py`. Prefer the `tabnowy0all` tab; fall back to `tabstary0all`; error if neither, or if found-but-empty. Use `regexp` and `html.UnescapeString`. `ExtractCitation` pulls the parenthetical from a heading.
+
+Provide full Go translations. Key regexes: pane `<div class="tab-pane[^"]*"\s+id="%s">`, headings `<h2>(.*?)</h2>` (with `(?s)`), subtitle `<h4>(.*?)</h4>`, paragraphs `<p>(.*?)</p>`, `<br\s*/?>` → newline, strip `<[^>]+>`.
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/liturgy/ -run TestParse` — Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/liturgy/section.go internal/liturgy/parse.go internal/liturgy/parse_test.go internal/liturgy/testdata
+git commit -m "liturgy: Section type + HTML parse (fixtures)"
+```
+
+---
+
+## Task 8: internal/liturgy — Fetch + cache + Load
+
+**Files:**
+- Create: `internal/liturgy/fetch.go`, `internal/liturgy/fetch_test.go`
+
+**Interfaces:**
+- Consumes: `Parse`.
+- Produces:
+ - `type Options struct{ Date string; Refresh, Offline bool }`
+ - `liturgy.Load(opts Options) ([]Section, error)` — JSON cache → HTML cache(+parse, write JSON) → fetch(+cache both). (Offline path added in Task 9.)
+ - `liturgy.cacheDir() string`, using `XDG_CACHE_HOME`.
+
+- [ ] **Step 1: Write the failing test (cache round-trip via a local server)**
+
+`internal/liturgy/fetch_test.go`:
+```go
+package liturgy
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+)
+
+func TestLoadCaches(t *testing.T) {
+ html, _ := os.ReadFile("testdata/2026-06-22.html")
+ hits := 0
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ w.Write(html)
+ }))
+ defer srv.Close()
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ baseURL = srv.URL + "/liturgia/%s/Ewangelia" // test hook
+
+ secs1, err := Load(Options{Date: "2026-06-22"})
+ if err != nil || len(secs1) == 0 {
+ t.Fatalf("load1: %v", err)
+ }
+ secs2, _ := Load(Options{Date: "2026-06-22"}) // should hit JSON cache
+ if hits != 1 {
+ t.Errorf("server hit %d times, want 1 (cache miss on repeat)", hits)
+ }
+ if len(secs2) != len(secs1) {
+ t.Error("cache returned different section count")
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/liturgy/ -run TestLoadCaches` — Expected: FAIL.
+
+- [ ] **Step 3: Implement fetch.go**
+
+`baseURL` is a package var (default the real URL, overridable in tests). `Load`: compute cache paths; if not `Refresh`, try `{date}.json` (unmarshal `[]Section`); else try `{date}.html` (parse, write JSON); else GET `baseURL` with the User-Agent, and — only if the page is fully published (`id="\w*0all"` present) — write `{date}.html` and `{date}.json`. Publish detection and the "Przykro nam"/unpublished handling mirror `ewangelia.py`.
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/liturgy/ -run TestLoadCaches` — Expected: PASS (`hits == 1`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/liturgy/fetch.go internal/liturgy/fetch_test.go
+git commit -m "liturgy: fetch + HTML/JSON cache"
+```
+
+---
+
+## Task 9: internal/liturgy — sigla harvest + offline Load
+
+**Files:**
+- Create: `internal/liturgy/store.go`, `internal/liturgy/store_test.go`
+- Modify: `internal/liturgy/fetch.go` (Load consults offline path when `opts.Offline`)
+
+**Interfaces:**
+- Produces:
+ - `liturgy.Harvest(fromDate string, maxDays int) (added int, furthest string, err error)` — walks dates, extracts citations, writes/merges the sigla TSV. Stops at the unpublished horizon.
+ - `liturgy.LoadOffline(date string) ([]Section, error)` — build sections from the sigla TSV (Citation set, Paragraphs empty).
+ - `liturgy.siglaPath() string` (`XDG_DATA_HOME`).
+- Modify `Load`: when `opts.Offline`, return `LoadOffline(opts.Date)`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/liturgy/store_test.go`:
+```go
+package liturgy
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+)
+
+func TestHarvestAndOffline(t *testing.T) {
+ html, _ := os.ReadFile("testdata/2026-07-22.html")
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.Contains(r.URL.Path, "2026-07-22") {
+ w.Write(html)
+ } else {
+ w.Write([]byte("<html>Przykro nam</html>")) // horizon
+ }
+ }))
+ defer srv.Close()
+ t.Setenv("XDG_CACHE_HOME", t.TempDir())
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ baseURL = srv.URL + "/liturgia/%s/Ewangelia"
+
+ added, _, err := Harvest("2026-07-22", 3)
+ if err != nil || added < 1 {
+ t.Fatalf("harvest: added=%d err=%v", added, err)
+ }
+ secs, err := LoadOffline("2026-07-22")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var haveGospel bool
+ for _, s := range secs {
+ if s.Citation == "J 20, 1. 11-18" {
+ haveGospel = true
+ }
+ }
+ if !haveGospel {
+ t.Error("offline gospel citation missing")
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/liturgy/ -run TestHarvestAndOffline` — Expected: FAIL.
+
+- [ ] **Step 3: Implement store.go + modify Load**
+
+`Harvest`: from `fromDate`, for up to `maxDays` (0 = until horizon), fetch+parse each date; on "no reading published"/parse error, stop (horizon). For each section, `ExtractCitation` → append rows `date\tlabel\tcitation`. Merge with existing TSV (replace a date's rows on re-harvest). `LoadOffline`: read the TSV, gather rows for the date, build `[]Section{Heading:label, Citation:citation}`. Date iteration uses `time.Parse("2006-01-02", ...)` + `AddDate(0,0,1)`. In `Load`: add `if opts.Offline { return LoadOffline(opts.Date) }` at the top; and wrap the network branch so that on a fetch error it falls back to `LoadOffline(opts.Date)` when the date is harvested, returning the original fetch error only if the offline load also fails (auto-fallback per the spec).
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/liturgy/` — Expected: PASS (all liturgy tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/liturgy/store.go internal/liturgy/store_test.go internal/liturgy/fetch.go
+git commit -m "liturgy: sigla harvest + offline load"
+```
+
+---
+
+## Task 10: internal/config
+
+**Files:**
+- Create: `internal/config/config.go`, `internal/config/config.toml` (embedded seed), `internal/config/config_test.go`
+
+**Interfaces:**
+- Produces: `config.Config{Versions []string; DefaultVersion string; Width int; All, Offline bool}`; `config.Load() (Config, error)` (env → `~/.config/lectio/config.toml` seed → defaults); `config.Default() Config`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/config/config_test.go`:
+```go
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadSeeds(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.DefaultVersion != "pl" || cfg.Offline {
+ t.Errorf("defaults wrong: %+v", cfg)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "lectio", "config.toml")); err != nil {
+ t.Error("config not seeded")
+ }
+}
+
+func TestLoadOverride(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ os.MkdirAll(filepath.Join(dir, "lectio"), 0o755)
+ os.WriteFile(filepath.Join(dir, "lectio", "config.toml"),
+ []byte("offline = true\ndefault_version = \"wuj\"\n"), 0o644)
+ cfg, _ := Load()
+ if !cfg.Offline || cfg.DefaultVersion != "wuj" {
+ t.Errorf("override not applied: %+v", cfg)
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/config/` — Expected: FAIL.
+
+- [ ] **Step 3: Implement config.go + seed**
+
+`internal/config/config.toml` = the seed from the spec (with `schema_version`, `versions`, `default_version`, `width`, `all`, `offline = false`), `//go:embed`ed. `Default()` returns the same values. `Load`: `LECTIO_CONFIG` env → `os.UserConfigDir()/lectio/config.toml` (seed the embedded default if absent) → parse with `toml.Unmarshal`. Validate each version in `Versions`/`DefaultVersion` against the five codes; error on unknown. Struct tags match the TOML keys.
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/config/` — Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/config && git commit -m "config: TOML load + seed"
+```
+
+---
+
+## Task 11: internal/render
+
+**Files:**
+- Create: `internal/render/render.go`, `internal/render/render_test.go`
+
+**Interfaces:**
+- Consumes: `liturgy.Section`, `bible.ToEnglishRef`, `bible.Lookup`, version labels/systems.
+- Produces:
+ - `render.GatherVersion(version string, sec liturgy.Section) (label string, blocks []string)` — `pl` returns deduped paragraphs (drop "Słowa Ewangelii" incipit + repeated refrains); a bible version returns verse lines `"C:V text"` plus a "brak" note for missing groups.
+ - `render.Compare(secs []liturgy.Section, versions []string, width int) string` — the side-by-side column layout (port `render_compare`).
+ - `render.OfflineVersions(versions []string) []string` — drop `pl`, ensure `wuj`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/render/render_test.go`:
+```go
+package render
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/liturgy"
+)
+
+func TestGatherPLDedup(t *testing.T) {
+ sec := liturgy.Section{
+ Heading: "Psalm (Ps 1)",
+ Paragraphs: [][]string{{"stanza one"}, {"refrain"}, {"stanza two"}, {"refrain"}},
+ }
+ _, blocks := GatherVersion("pl", sec)
+ n := 0
+ for _, b := range blocks {
+ if b == "refrain" {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Errorf("refrain appears %d times, want 1 (deduped)", n)
+ }
+}
+
+func TestGatherBible(t *testing.T) {
+ sec := liturgy.Section{Heading: "Ewangelia (J 20, 1. 11-18)"}
+ label, blocks := GatherVersion("wuj", sec)
+ if !strings.Contains(label, "Wujek") {
+ t.Errorf("label = %q", label)
+ }
+ if len(blocks) == 0 || !strings.HasPrefix(blocks[0], "20:1") {
+ t.Errorf("first block = %q", blocks)
+ }
+}
+
+func TestOfflineVersions(t *testing.T) {
+ got := OfflineVersions([]string{"pl", "wuj", "vul"})
+ for _, v := range got {
+ if v == "pl" {
+ t.Error("pl not dropped offline")
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/render/` — Expected: FAIL.
+
+- [ ] **Step 3: Implement render.go**
+
+Port `gather_version` (pl branch with incipit-drop + dedup; bible branch via `bible.ToEnglishRef(citation, system(version))` then `bible.Lookup`), the version labels/systems maps, `render_compare` (column widths, gap, label+dash header, row assembly), and `OfflineVersions`. `GatherVersion` derives the citation from `sec.Citation` (fallback `ExtractCitation(sec.Heading)`).
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/render/` — Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/render && git commit -m "render: gather versions + compare + dedup"
+```
+
+---
+
+## Task 12: internal/cli — subcommand dispatch
+
+**Files:**
+- Replace: `internal/cli/cli.go` (the Task 1 stub); Create: `internal/cli/cli_test.go`
+
+**Interfaces:**
+- Consumes: `config.Load`, `liturgy.Load/Harvest`, `render.*`.
+- Produces: full `Run(args, stdin, stdout, stderr) int` handling `today`, `date D`, `compare LIST`, `show VERSION`, `update`, `--version`, `help`; global `--offline`; per-command `--all --raw --width --refresh --date`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/cli/cli_test.go`:
+```go
+package cli
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestHelp(t *testing.T) {
+ var out, errb bytes.Buffer
+ code := Run([]string{"help"}, nil, &out, &errb)
+ if code != 0 || !strings.Contains(out.String(), "lectio") {
+ t.Errorf("help code=%d out=%q", code, out.String())
+ }
+}
+
+func TestUnknownCommand(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"bogus"}, nil, &out, &errb); code != 2 {
+ t.Errorf("unknown cmd code=%d want 2", code)
+ }
+}
+
+func TestVersion(t *testing.T) {
+ var out, errb bytes.Buffer
+ if code := Run([]string{"--version"}, nil, &out, &errb); code != 0 {
+ t.Errorf("version code=%d", code)
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/cli/` — Expected: FAIL.
+
+- [ ] **Step 3: Implement cli.go**
+
+Hand-rolled dispatch (like bread-calc): first non-flag arg is the subcommand (default `today`). Each subcommand builds a `flag.FlagSet`, resolves `config.Load()` (flags override), calls `liturgy.Load`/`Harvest`, renders via `render`, writes to `stdout`. `--offline`/config sets `Options.Offline` and swaps versions via `render.OfflineVersions`. `help`/`-h` prints usage; unknown command → usage error (2); runtime errors → 1. Include a `helpText` const listing every subcommand (mirror the spec's CLI section).
+
+- [ ] **Step 4: Run tests, verify pass**
+
+Run: `go test ./internal/cli/` — Expected: PASS.
+
+- [ ] **Step 5: Build + manual smoke + commit**
+
+```bash
+go build ./cmd/lectio && ./lectio today && ./lectio compare pl,wuj,vul,drb
+git add internal/cli && git commit -m "cli: subcommand dispatch"
+```
+Expected: today's gospel prints; compare shows four columns (network required).
+
+---
+
+## Task 13: internal/tui — styles + model
+
+**Files:**
+- Create: `internal/tui/styles.go`, `internal/tui/tui.go`, `internal/tui/tui_test.go`
+- Replace: `cmd/lectio-ui/main.go`
+
+**Interfaces:**
+- Consumes: `config.Config`, `liturgy.Load`, `render.GatherVersion`, `render.OfflineVersions`.
+- Produces: `tui.New(cfg config.Config) tui.Model` implementing `tea.Model`.
+
+- [ ] **Step 1: Write the failing test (pure model logic)**
+
+`internal/tui/tui_test.go`:
+```go
+package tui
+
+import (
+ "testing"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+)
+
+func TestVersionCycle(t *testing.T) {
+ m := New(config.Config{Versions: []string{"pl", "wuj", "vul"}, DefaultVersion: "pl"})
+ if m.version() != "pl" {
+ t.Fatalf("start = %q", m.version())
+ }
+ m = m.cycleVersion(+1)
+ if m.version() != "wuj" {
+ t.Errorf("after tab = %q", m.version())
+ }
+ m = m.cycleVersion(-1)
+ if m.version() != "pl" {
+ t.Errorf("after shift-tab = %q", m.version())
+ }
+}
+
+func TestOfflineDropsPL(t *testing.T) {
+ m := New(config.Config{Versions: []string{"pl", "wuj", "vul"}, DefaultVersion: "pl", Offline: true})
+ for _, v := range m.versions {
+ if v == "pl" {
+ t.Error("offline model kept pl")
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/tui/` — Expected: FAIL.
+
+- [ ] **Step 3: Implement styles.go + tui.go**
+
+`styles.go`: named `lipgloss.Style` per color role (heading bold accent, citation dim, verse number muted, verse text default, refrain italic, header, footer) using `lipgloss.AdaptiveColor`; honor `NO_COLOR`. `tui.go`: `Model{cfg, versions, verIdx, date, sections, scroll, width, loading, err}`; `New` applies `OfflineVersions` when `cfg.Offline` and sets `verIdx` to `default_version`; `version()`, `cycleVersion(d int)`, helpers are pure and unit-tested. `Init` issues the first load `tea.Cmd`; `Update` handles `tea.KeyMsg` (tab/arrows/j/k/g/G/r/q), `readingsMsg`, `errMsg`, `tea.WindowSizeMsg`; `View` renders header + styled reading (via `render.GatherVersion` for the active version, styled per role) + footer keybar. Fetching runs in a `tea.Cmd` returning `readingsMsg`/`errMsg`.
+
+- [ ] **Step 4: Replace cmd/lectio-ui/main.go**
+
+```go
+package main
+
+import (
+ "fmt"
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+
+ "github.com/lukaszkasprzak/lectio/internal/config"
+ "github.com/lukaszkasprzak/lectio/internal/tui"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if _, err := tea.NewProgram(tui.New(cfg), tea.WithAltScreen()).Run(); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+```
+
+- [ ] **Step 5: Run tests + build + commit**
+
+```bash
+go test ./internal/tui/ && go build ./cmd/lectio-ui
+git add internal/tui cmd/lectio-ui && git commit -m "tui: colored reader + version switch"
+```
+Expected: tests pass; builds. (Interactive run verified manually: `./lectio-ui`.)
+
+---
+
+## Task 14: README, vet, cross-build, finalize
+
+**Files:**
+- Create: `README.md`
+
+**Interfaces:** none.
+
+- [ ] **Step 1: Write README**
+
+Cover: what it is, install (`make install`), the subcommands, config file + keys, the TUI keys and colors, the `update`/offline workflow, and that it's self-contained (no external tools). Note the Python `daily-reading` is the predecessor.
+
+- [ ] **Step 2: vet + fmt + full test**
+
+Run: `gofmt -w . && go vet ./... && go test ./...`
+Expected: no vet diagnostics; all tests pass.
+
+- [ ] **Step 3: Cross-build sanity**
+
+Run: `make cross && ls dist/`
+Expected: ten binaries.
+
+- [ ] **Step 4: Install + smoke both binaries**
+
+Run: `make install && lectio today && echo '---' && lectio update --days 5`
+Expected: gospel prints; update reports harvested days. (`lectio-ui` verified interactively.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "docs: README; finalize build"
+```
+
+---
+
+## Self-Review Notes
+
+- Spec coverage: two binaries (T1,12,13,15-via-14), embedded corpora (T1,4), lookup (T4,5), aliases incl. `J`→John (T3), citation conversion + psalm systems (T2,6), fetch/parse (T7,8), cache HTML+JSON (T8), sigla harvest + offline (T9), config incl. `offline` (T10), render + dedup + pl→wuj (T11), subcommands (T12), colored reader TUI (T13), Makefile/cross/README (T1,14). All spec sections map to a task.
+- Bulk data (73-book aliases, `POLISH_TO_EN`, `DRB_TITLE_FOLD`) is ported verbatim from named source files — concrete, not placeholder.
+- Type consistency: `Verse`, `Section`, `Options`, `Config`, `GatherVersion`, `Lookup`, `ToEnglishRef`, `DrbVerse` names are used identically across tasks.