aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/readings/offline.go49
-rw-r--r--internal/readings/readings.go16
-rw-r--r--internal/readings/readings_test.go45
-rw-r--r--mobile/mobile.go16
-rw-r--r--mobile/mobile_bench_test.go30
-rw-r--r--mobile/mobile_test.go1
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")
}