summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--mobile/mobile.go72
-rw-r--r--mobile/mobile_test.go83
2 files changed, 155 insertions, 0 deletions
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")
+ }
+}