From 27ceffcb90c0716179342109163c559d492a8abc Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Mon, 3 Aug 2026 23:59:09 +0200 Subject: mobile: add Days, a text-free week projection for the calendar A week through Days benchmarks at ~9-10ms/op, versus ~23ms for seven full days through Day. --- mobile/mobile.go | 72 ++++++++++++++++++++++++++++++++++++++++++++ mobile/mobile_test.go | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/mobile/mobile.go b/mobile/mobile.go index 6312ee0..e79e748 100644 --- a/mobile/mobile.go +++ b/mobile/mobile.go @@ -14,6 +14,7 @@ package mobile import ( "encoding/json" "strings" + "time" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" @@ -112,6 +113,77 @@ func Day(date, form, version, lang string) string { return string(b) } +// summaryPart is one section of a day, citation only -- no rendered text. +type summaryPart struct { + Part string `json:"part"` + Citation string `json:"citation"` +} + +// daySummary is one day's identity and its reading citations. It is Day's +// cheaper projection: the calendar view needs the day's shape, not its text. +type daySummary struct { + Date string `json:"date"` + Name string `json:"name"` + Rank string `json:"rank"` + RankLabel string `json:"rank_label"` + Colour string `json:"colour"` + Parts []summaryPart `json:"parts"` + Error string `json:"error,omitempty"` +} + +// Days returns count consecutive days from start as a JSON array, each with its +// identity and reading citations but no reading text. It is the calendar view's +// source. +// +// start - "YYYY-MM-DD", taken literally: aligning to a week is the caller's job +// count - 1..366; anything else yields "[]" +// form - "of" or "ef" +// lang - interface language for the name, rank word and citation dialect +// +// No version parameter: the corpus decides only scripture text, which this never +// renders, while the citation's sigla dialect follows lang. +// +// A day that fails to compute carries its own "error" and does not abort the +// rest, so one bad date cannot blank a whole week. Malformed input yields "[]" +// rather than an error string, so the caller always parses an array. +func Days(start string, count int, form, lang string) string { + if count < 1 || count > 366 { + return "[]" + } + d, err := time.Parse("2006-01-02", start) + if err != nil { + return "[]" + } + lect := lectByForm(form) + ui := i18n.Get(lang) + cfg := config.Config{UILanguage: lang, Lectionary: lect, All: true} + + 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}) + if err != nil { + row.Error = err.Error() + } else { + row.Name = info.Name + row.Colour = info.Colour + row.Rank = info.Rank + row.RankLabel = ui.Rank[info.Rank] + for _, sec := range secs { + row.Parts = append(row.Parts, summaryPart{Part: sec.PartID, Citation: sec.Citation}) + } + } + out = append(out, row) + } + + b, err := json.Marshal(out) + if err != nil { + return "[]" + } + return string(b) +} + // Ping is a trivial JNI smoke test: it returns "pong" so the app can confirm the // native engine loaded before issuing a real query. func Ping() string { return "pong" } diff --git a/mobile/mobile_test.go b/mobile/mobile_test.go index c7df63c..57e835a 100644 --- a/mobile/mobile_test.go +++ b/mobile/mobile_test.go @@ -2,6 +2,7 @@ package mobile import ( "encoding/json" + "strings" "testing" ) @@ -40,3 +41,85 @@ func TestDayHasColourKeyButNoColourLabel(t *testing.T) { t.Error("colour_label must not be returned: the app draws a swatch") } } + +func decodeDays(t *testing.T, s string) []map[string]any { + t.Helper() + var a []map[string]any + if err := json.Unmarshal([]byte(s), &a); err != nil { + t.Fatalf("Days returned invalid JSON: %v\n%s", err, s) + } + return a +} + +func TestDaysReturnsConsecutiveDates(t *testing.T) { + a := decodeDays(t, Days("2026-07-27", 7, "of", "pl")) + if len(a) != 7 { + t.Fatalf("got %d elements, want 7", len(a)) + } + want := []string{"2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30", + "2026-07-31", "2026-08-01", "2026-08-02"} + for i, w := range want { + if a[i]["date"] != w { + t.Errorf("element %d date = %v, want %v", i, a[i]["date"], w) + } + } +} + +// Days must agree with Day about the same date -- it is a cheaper projection of +// the same computation, not a second implementation. +func TestDaysAgreesWithDay(t *testing.T) { + one := decodeDay(t, Day("2026-08-01", "of", "vul", "pl")) + week := decodeDays(t, Days("2026-08-01", 1, "of", "pl")) + if len(week) != 1 { + t.Fatalf("got %d elements, want 1", len(week)) + } + row := week[0] + for _, k := range []string{"name", "colour", "rank", "rank_label"} { + if row[k] != one[k] { + t.Errorf("%s: Days = %v, Day = %v", k, row[k], one[k]) + } + } + parts := row["parts"].([]any) + readings := one["readings"].([]any) + if len(parts) != len(readings) { + t.Fatalf("parts = %d, readings = %d", len(parts), len(readings)) + } + for i := range parts { + p := parts[i].(map[string]any) + r := readings[i].(map[string]any) + if p["part"] != r["part"] || p["citation"] != r["citation"] { + t.Errorf("element %d: Days %v/%v vs Day %v/%v", + i, p["part"], p["citation"], r["part"], r["citation"]) + } + } +} + +// No reading text may cross the boundary: that is the whole point of Days. +func TestDaysCarriesNoReadingText(t *testing.T) { + s := Days("2026-08-01", 7, "of", "pl") + if strings.Contains(s, `"text"`) { + t.Error("Days must not return reading text") + } + if strings.Contains(s, "colour_label") { + t.Error("Days must not return a colour label") + } +} + +func TestDaysRejectsBadInput(t *testing.T) { + for _, c := range []struct { + start string + count int + }{ + {"not-a-date", 7}, {"2026-08-01", 0}, {"2026-08-01", -1}, {"2026-08-01", 400}, + } { + if got := Days(c.start, c.count, "of", "pl"); got != "[]" { + t.Errorf("Days(%q, %d) = %s, want []", c.start, c.count, got) + } + } +} + +func BenchmarkDaysWeek(b *testing.B) { + for i := 0; i < b.N; i++ { + Days("2026-07-27", 7, "of", "pl") + } +} -- cgit v1.3