diff options
| -rw-r--r-- | internal/readings/offline.go | 49 | ||||
| -rw-r--r-- | internal/readings/readings.go | 16 | ||||
| -rw-r--r-- | internal/readings/readings_test.go | 45 | ||||
| -rw-r--r-- | mobile/mobile.go | 16 | ||||
| -rw-r--r-- | mobile/mobile_bench_test.go | 30 | ||||
| -rw-r--r-- | mobile/mobile_test.go | 1 |
6 files changed, 147 insertions, 10 deletions
diff --git a/internal/readings/offline.go b/internal/readings/offline.go index b2c16df..546d3b0 100644 --- a/internal/readings/offline.go +++ b/internal/readings/offline.go @@ -13,6 +13,33 @@ import ( "github.com/lukaszkasprzak/lectio/internal/naming" ) +// Prepared holds the date-independent setup offlineLoad otherwise redoes on +// every call: the stacked calendar layers (caldata.Stack) and the book table +// (bible.LoadBookTable). Both depend only on cfg -- never on the date -- so a +// caller resolving many dates against the same cfg (mobile.Days's 7-day loop, +// an eventual month view) should build one Prepared with Prepare and reuse it +// via LoadWith for every date, instead of paying Stack's INI parsing and +// LoadBookTable's file read + parse once per date. See Prepare and LoadWith. +type Prepared struct { + layers []calendar.Layer + tbl *bible.BookTable +} + +// Prepare builds a Prepared for cfg: the layer stack for cfg.Selection().Form +// stacked with cfg.Use (caldata.Stack), and the book table for the user's +// books.ini override, if any (bible.LoadBookTable). Both calls already +// tolerate their own failure (Stack falls back to the embedded calendar on a +// bad user layer; LoadBookTable falls back to the embedded book table on a +// bad user override) exactly as offlineLoad always has -- Prepare changes +// only when this work happens, never what it computes or how it degrades. +func Prepare(cfg config.Config) Prepared { + sel := cfg.Selection() + dir, _ := config.CalendarsDir() + layers, _ := caldata.Stack(sel.Form, dir, cfg.Use) // Stack falls back to embedded data on error + tbl, _ := bible.LoadBookTable(config.UserBooksINI()) // nil on error -> citations shown as authored + return Prepared{layers: layers, tbl: tbl} +} + // offlineLoad resolves a day's readings entirely from the embedded calendar // engine and lectionary data -- no network. It returns the same source-agnostic // liturgy.Section / liturgy.DayInfo the CLI/TUI/web already render, so the daily @@ -20,18 +47,28 @@ import ( // lectio's English-canonical authored form; the render localises each one to // the chosen corpus's Psalter and the user's sigla dialect (see // render.GatherVersion, bible.OFRef). +// +// offlineLoad is Prepare(cfg) followed by offlineLoadWith -- a single call's +// worth of convenience for Load, which has no date to amortize Prepare's cost +// over. A caller with several dates should call Prepare once and use +// offlineLoadWith/LoadWith directly instead (see Prepared's doc comment). func offlineLoad(cfg config.Config, date string) ([]liturgy.Section, liturgy.DayInfo, error) { + return offlineLoadWith(Prepare(cfg), cfg, date) +} + +// offlineLoadWith is offlineLoad, given an already-built Prepared instead of +// building its own. Computing the day itself (calendar.Compute, the readings +// it resolves) still happens once per call, exactly as before -- only the +// layer stack and book table are reused. +func offlineLoadWith(p Prepared, cfg config.Config, date string) ([]liturgy.Section, liturgy.DayInfo, error) { d, err := time.Parse("2006-01-02", date) if err != nil { return nil, liturgy.DayInfo{}, fmt.Errorf("bad date %q (want YYYY-MM-DD)", date) } sel := cfg.Selection() - dir, _ := config.CalendarsDir() - layers, _ := caldata.Stack(sel.Form, dir, cfg.Use) // Stack falls back to embedded data on error - day := calendar.Compute(d.UTC(), sel, layers) - rs := caldata.Readings(sel, layers, d.UTC(), day) - tbl, _ := bible.LoadBookTable(config.UserBooksINI()) // nil on error -> citations shown as authored - return sectionsFor(rs, sel.Form, cfg.UILanguage, cfg.SiglaLang(), tbl), dayInfo(cfg, day), nil + day := calendar.Compute(d.UTC(), sel, p.layers) + rs := caldata.Readings(sel, p.layers, d.UTC(), day) + return sectionsFor(rs, sel.Form, cfg.UILanguage, cfg.SiglaLang(), p.tbl), dayInfo(cfg, day), nil } // citationForms renders a reading's authored (English) citation into its diff --git a/internal/readings/readings.go b/internal/readings/readings.go index d0d7bf9..29eff5a 100644 --- a/internal/readings/readings.go +++ b/internal/readings/readings.go @@ -25,8 +25,22 @@ type Options struct { // liturgical colour -- see liturgy.DayInfo) for the configured form // (cfg.Lectionary: "traditional" or "new") and applies part filtering. Every // reading is resolved offline from the embedded calendar and lectionary data. +// +// Load is LoadWith(Prepare(cfg), cfg, opts) -- a single call's worth of +// convenience. A caller resolving several dates against the same cfg (a +// week/month view) should call Prepare once and use LoadWith directly instead +// of paying Prepare's cost on every date; see Prepared's doc comment +// (internal/readings/offline.go). func Load(cfg config.Config, opts Options) ([]liturgy.Section, liturgy.DayInfo, error) { - secs, info, err := offlineLoad(cfg, opts.Date) + return LoadWith(Prepare(cfg), cfg, opts) +} + +// LoadWith is Load, given an already-built Prepared (see Prepare) instead of +// building its own. Reuse one Prepared across every date resolved against the +// same cfg to skip re-stacking the calendar layers and re-parsing the book +// table per date -- the fast path mobile.Days's multi-day loop uses. +func LoadWith(p Prepared, cfg config.Config, opts Options) ([]liturgy.Section, liturgy.DayInfo, error) { + secs, info, err := offlineLoadWith(p, cfg, opts.Date) if err != nil { return nil, liturgy.DayInfo{}, err } diff --git a/internal/readings/readings_test.go b/internal/readings/readings_test.go index 1674d85..c81e324 100644 --- a/internal/readings/readings_test.go +++ b/internal/readings/readings_test.go @@ -1,6 +1,7 @@ package readings import ( + "reflect" "strings" "testing" @@ -136,6 +137,50 @@ func TestSundayRankIsDisplayOnly(t *testing.T) { } } +// TestLoadWithAgreesWithLoad guards the fast path multi-date callers (e.g. +// mobile.Days) use to avoid re-stacking the calendar layers and re-parsing +// the book table once per date: LoadWith, given a cfg's own Prepared value, +// must return exactly what Load(cfg, opts) returns, for every date and both +// forms. This is the correctness backstop for the perf fix -- Prepare only +// hoists WHEN the date-independent setup happens, never WHAT it computes. +func TestLoadWithAgreesWithLoad(t *testing.T) { + dates := []string{ + "2026-07-22", // ordinary weekday + "2028-02-29", // leap day + "2026-04-09", // Holy Thursday 2026 + "2026-04-10", // Good Friday 2026 + "2026-04-11", // Holy Saturday 2026 + "2026-11-02", // All Souls (a Requiem day) + "2026-12-25", // Christmas + } + for _, lect := range []string{"new", "traditional"} { + cfg := config.Config{Lectionary: lect} + p := Prepare(cfg) + for _, date := range dates { + opts := Options{Date: date, All: true} + wantSecs, wantInfo, wantErr := Load(cfg, opts) + gotSecs, gotInfo, gotErr := LoadWith(p, cfg, opts) + if (wantErr == nil) != (gotErr == nil) { + t.Fatalf("%s %s: Load err=%v, LoadWith err=%v", lect, date, wantErr, gotErr) + } + if wantErr != nil { + continue + } + if gotInfo != wantInfo { + t.Errorf("%s %s: LoadWith info = %+v, want %+v", lect, date, gotInfo, wantInfo) + } + if len(gotSecs) != len(wantSecs) { + t.Fatalf("%s %s: LoadWith %d sections, want %d", lect, date, len(gotSecs), len(wantSecs)) + } + for i := range wantSecs { + if !reflect.DeepEqual(gotSecs[i], wantSecs[i]) { + t.Errorf("%s %s: section %d = %+v, want %+v", lect, date, i, gotSecs[i], wantSecs[i]) + } + } + } + } +} + // TestLoadTraditional computes the Extraordinary Form day offline: it never // needs the network, and yields the EF epistle+gospel with a header name. func TestLoadTraditional(t *testing.T) { diff --git a/mobile/mobile.go b/mobile/mobile.go index 7e4c109..bc380d2 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -78,7 +78,12 @@ func Day(date, form, version, lang string) string { // Day identity (name/season/colour) in the interface language. cfgID := config.Config{UILanguage: lang, Lectionary: lect, All: true} - if _, info, err := readings.Load(cfgID, readings.Options{Date: date, All: true}); err == nil { + // cfgID and cfgR below differ only in UILanguage/ReadingVersion, never in + // Lectionary or Use -- the two inputs Prepare's cost depends on -- so one + // Prepare (keyed off cfgID; either config would do) serves both Load + // calls. See readings.Prepared's doc comment. + p := readings.Prepare(cfgID) + if _, info, err := readings.LoadWith(p, cfgID, readings.Options{Date: date, All: true}); err == nil { out.Name = info.Name out.Season = info.Season out.Colour = info.Colour @@ -92,7 +97,7 @@ func Day(date, form, version, lang string) string { // language localizes the structural labels (Heading) and citation dialect; // the corpus (version) alone decides the scripture text. cfgR := config.Config{UILanguage: lang, Lectionary: lect, ReadingVersion: version, All: true} - secs, _, err := readings.Load(cfgR, readings.Options{Date: date, All: true}) + secs, _, err := readings.LoadWith(p, cfgR, readings.Options{Date: date, All: true}) if err != nil { if out.Error == "" { out.Error = err.Error() @@ -166,12 +171,17 @@ func Days(start string, count int, form, lang string) string { lect := lectByForm(form) ui := i18n.Get(lang) cfg := config.Config{UILanguage: lang, Lectionary: lect, All: true} + // cfg is identical for every date in the loop below, so the layer stack + // and book table it implies are too -- Prepare once here rather than + // letting each readings.Load call re-stack/re-parse them. See + // readings.Prepared's doc comment. + p := readings.Prepare(cfg) out := make([]daySummary, 0, count) for i := 0; i < count; i++ { date := d.AddDate(0, 0, i).Format("2006-01-02") row := daySummary{Date: date, Parts: []summaryPart{}} - secs, info, err := readings.Load(cfg, readings.Options{Date: date, All: true}) + secs, info, err := readings.LoadWith(p, cfg, readings.Options{Date: date, All: true}) if err != nil { row.Error = err.Error() } else { diff --git a/mobile/mobile_bench_test.go b/mobile/mobile_bench_test.go new file mode 100644 index 0000000..7e95744 --- /dev/null +++ b/mobile/mobile_bench_test.go @@ -0,0 +1,30 @@ +package mobile + +import "testing" + +// BenchmarkDaysWeek (mobile_test.go) already covers the OF 7-day view this +// perf fix targets -- see internal/readings/offline.go's offlineLoad, which +// currently re-stacks the calendar layers and re-parses the book table once +// per day inside the loop Days drives. The benchmarks below add the two +// comparisons that let that fix's win be measured: the same view in the +// traditional form (a different, larger embedded calendar layer), and a +// single Day call, the per-day unit of work Days repeats. + +// BenchmarkDays7EF is the 7-day view in the traditional (1962) form, whose +// calendar layer is a different embedded file (Tridentine vs Universal). +func BenchmarkDays7EF(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + Days("2026-07-27", 7, "ef", "pl") + } +} + +// BenchmarkDay1 measures a single Day call, the unit of work Days repeats. +// Comparing this to BenchmarkDaysWeek/7 shows how much of each day's cost is +// the date-independent setup this fix hoists out. +func BenchmarkDay1(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + Day("2026-07-27", "of", "vul", "pl") + } +} diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index 85de500..f497c56 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -130,6 +130,7 @@ func TestDaysAcceptsUpperBoundCount(t *testing.T) { } func BenchmarkDaysWeek(b *testing.B) { + b.ReportAllocs() for i := 0; i < b.N; i++ { Days("2026-07-27", 7, "of", "pl") } |
