internal/dateprogression.go (view raw)
1package bible
2
3import (
4 "fmt"
5 "time"
6)
7
8// DateProgression handles calculating Bible position based on date
9type DateProgression struct {
10 bible *Bible
11 versesPerDay int
12 readingMode ReadingMode
13 // Pre-computed cumulative offset at the START of each day of year (1-indexed)
14 dailyOffsets []int
15}
16
17// NewDateProgression creates a new date progression calculator
18func NewDateProgression(bible *Bible) *DateProgression {
19 dp := &DateProgression{bible: bible, versesPerDay: 12, readingMode: ReadingModeEvangelion}
20 dp.precomputeDailyOffsets()
21 return dp
22}
23
24// NewDateProgressionWithConfig creates a new date progression calculator with config
25func NewDateProgressionWithConfig(bible *Bible, versesPerDay int) *DateProgression {
26 if versesPerDay <= 0 {
27 versesPerDay = 12
28 }
29 dp := &DateProgression{bible: bible, versesPerDay: versesPerDay, readingMode: ReadingModeEvangelion}
30 dp.precomputeDailyOffsets()
31 return dp
32}
33
34// NewDateProgressionWithReadingMode creates a new date progression calculator with reading mode
35func NewDateProgressionWithReadingMode(bible *Bible, versesPerDay int, readingMode ReadingMode) *DateProgression {
36 if versesPerDay <= 0 {
37 versesPerDay = 12
38 }
39 dp := &DateProgression{bible: bible, versesPerDay: versesPerDay, readingMode: readingMode}
40 dp.precomputeDailyOffsets()
41 return dp
42}
43
44// precomputeDailyOffsets computes the cumulative verse offset for each day of the year
45// using lookahead logic: when fewer than versesPerDay remain in a chapter, extend
46// to read all remaining verses in that chapter. This eliminates short reading days.
47func (dp *DateProgression) precomputeDailyOffsets() {
48 totalVerses := dp.GetTotalVersesForMode()
49 if totalVerses == 0 {
50 dp.dailyOffsets = make([]int, 367)
51 return
52 }
53
54 dp.dailyOffsets = make([]int, 367) // 1-indexed by day of year
55 cumOffset := 0
56
57 for day := 1; day <= 366; day++ {
58 dp.dailyOffsets[day] = cumOffset % totalVerses
59 cumOffset += dp.computeConsumed(dp.dailyOffsets[day])
60 }
61}
62
63// computeConsumed calculates the number of verses actually read starting from a given
64// cumulative offset. Uses lookahead: if fewer than versesPerDay remain in the chapter,
65// extends to read all remaining verses in that chapter.
66func (dp *DateProgression) computeConsumed(offset int) int {
67 startBook, endBook := dp.getBookRange()
68
69 for book := startBook; book <= endBook; book++ {
70 maxChapter := dp.findMaxChapter(book)
71 if maxChapter == 0 {
72 continue
73 }
74
75 for chapter := 1; chapter <= maxChapter; chapter++ {
76 versesInChapter := dp.bible.CountVersesInChapter(book, chapter)
77
78 if offset < versesInChapter {
79 firstVerse := offset + 1
80 lastVerse := firstVerse + dp.versesPerDay - 1
81 if lastVerse > versesInChapter {
82 lastVerse = versesInChapter
83 }
84 // Lookahead: if fewer than versesPerDay remain, read to chapter end
85 remaining := versesInChapter - lastVerse
86 if remaining > 0 && remaining < dp.versesPerDay {
87 lastVerse = versesInChapter
88 }
89 return lastVerse - firstVerse + 1
90 }
91
92 offset -= versesInChapter
93 }
94 }
95
96 // Wrap around
97 totalVerses := dp.GetTotalVersesForMode()
98 if totalVerses > 0 {
99 return dp.computeConsumed(offset % totalVerses)
100 }
101 return 0
102}
103
104// findMaxChapter finds the last chapter number in a book
105func (dp *DateProgression) findMaxChapter(book int) int {
106 for chapter := 1; ; chapter++ {
107 if dp.bible.CountVersesInChapter(book, chapter) == 0 {
108 return chapter - 1
109 }
110 }
111}
112
113// GetPositionForDate calculates the verse range for a given date
114func (dp *DateProgression) GetPositionForDate(date time.Time) (*Bookmark, error) {
115 // Calculate day of year (1-366)
116 dayOfYear := date.YearDay()
117
118 if dayOfYear < 1 || dayOfYear >= len(dp.dailyOffsets) {
119 dayOfYear = 1
120 }
121
122 // Look up pre-computed cumulative offset for this day
123 targetVerseOffset := dp.dailyOffsets[dayOfYear]
124
125 return dp.findPositionForOffset(targetVerseOffset)
126}
127
128// findPositionForOffset finds the bookmark for a given cumulative verse offset.
129// Applies lookahead: if fewer than versesPerDay remain in the chapter, extends
130// to read to the end of the chapter.
131func (dp *DateProgression) findPositionForOffset(offset int) (*Bookmark, error) {
132 startBook, endBook := dp.getBookRange()
133
134 for book := startBook; book <= endBook; book++ {
135 maxChapter := dp.findMaxChapter(book)
136 if maxChapter == 0 {
137 continue
138 }
139
140 for chapter := 1; chapter <= maxChapter; chapter++ {
141 versesInChapter := dp.bible.CountVersesInChapter(book, chapter)
142
143 if offset < versesInChapter {
144 firstVerse := offset + 1
145
146 // Default: read versesPerDay verses, clamped to chapter end
147 lastVerse := firstVerse + dp.versesPerDay - 1
148 if lastVerse > versesInChapter {
149 lastVerse = versesInChapter
150 }
151
152 // Lookahead: if fewer than versesPerDay remain, read to chapter end
153 remaining := versesInChapter - lastVerse
154 if remaining > 0 && remaining < dp.versesPerDay {
155 lastVerse = versesInChapter
156 }
157
158 return &Bookmark{
159 Book: BookIndex(book),
160 Chapter: chapter,
161 FirstVerse: firstVerse,
162 SecondVerse: lastVerse,
163 }, nil
164 }
165
166 offset -= versesInChapter
167 }
168 }
169
170 // Wrap around to beginning if offset exceeds total verses
171 totalVerses := dp.GetTotalVersesForMode()
172 if totalVerses == 0 {
173 return nil, fmt.Errorf("no verses found for reading mode %s. The Bible data file may not contain books for this reading mode", dp.readingMode)
174 }
175 if offset >= 0 {
176 adjustedOffset := offset % totalVerses
177 return dp.findPositionForOffset(adjustedOffset)
178 }
179
180 return nil, fmt.Errorf("could not find position for offset %d", offset)
181}
182
183// getBookRange returns the start and end book numbers for the current reading mode
184func (dp *DateProgression) getBookRange() (startBook, endBook int) {
185 switch dp.readingMode {
186 case ReadingModeEvangelion:
187 return 40, 43 // Matthew, Mark, Luke, John
188 case ReadingModeNewTestament:
189 return 40, 66 // Matthew through Revelation
190 case ReadingModeOldTestament:
191 return 1, 39 // Genesis through Malachi
192 case ReadingModeBible:
193 return 1, 66 // Entire Bible
194 default:
195 return 40, 43 // Default to Evangelion
196 }
197}
198
199// GetTotalVersesForMode returns the total number of verses for the current reading mode
200func (dp *DateProgression) GetTotalVersesForMode() int {
201 total := 0
202 startBook, endBook := dp.getBookRange()
203
204 for book := startBook; book <= endBook; book++ {
205 for chapter := 1; ; chapter++ {
206 verses := dp.bible.CountVersesInChapter(book, chapter)
207 if verses == 0 {
208 break
209 }
210 total += verses
211 }
212 }
213 return total
214}
215
216// GetTotalGospelVerses returns the total number of verses in all four Gospels.
217//
218// Deprecated: Use GetTotalVersesForMode instead.
219func (dp *DateProgression) GetTotalGospelVerses() int {
220 total := 0
221 for book := 40; book <= 43; book++ {
222 for chapter := 1; ; chapter++ {
223 verses := dp.bible.CountVersesInChapter(book, chapter)
224 if verses == 0 {
225 break
226 }
227 total += verses
228 }
229 }
230 return total
231}