bible270 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +196 -237
  3. data/README.md +159 -79
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +147 -0
  6. data/app/controllers/bible270/application_controller.rb +24 -3
  7. data/app/controllers/bible270/checkoffs_controller.rb +2 -3
  8. data/app/controllers/bible270/comments_controller.rb +6 -2
  9. data/app/controllers/bible270/days_controller.rb +5 -6
  10. data/app/controllers/bible270/profiles_controller.rb +45 -0
  11. data/app/controllers/bible270/readers_controller.rb +5 -4
  12. data/app/controllers/bible270/sessions_controller.rb +89 -26
  13. data/app/helpers/bible270/plan_helper.rb +22 -12
  14. data/app/mailers/bible270/application_mailer.rb +4 -1
  15. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  16. data/app/models/bible270/application_record.rb +1 -0
  17. data/app/models/bible270/checkoff.rb +4 -3
  18. data/app/models/bible270/comment.rb +26 -5
  19. data/app/models/bible270/reader.rb +167 -17
  20. data/app/models/bible270/setting.rb +57 -0
  21. data/app/models/bible270/sign_in_token.rb +5 -2
  22. data/app/views/bible270/admin/comments.html.erb +44 -0
  23. data/app/views/bible270/admin/index.html.erb +48 -0
  24. data/app/views/bible270/admin/show.html.erb +104 -0
  25. data/app/views/bible270/days/_start_date.html.erb +1 -1
  26. data/app/views/bible270/days/index.html.erb +9 -21
  27. data/app/views/bible270/profiles/edit.html.erb +44 -0
  28. data/app/views/bible270/readers/index.html.erb +2 -2
  29. data/app/views/bible270/readers/show.html.erb +5 -5
  30. data/app/views/bible270/sessions/new.html.erb +17 -3
  31. data/app/views/bible270/shared/_day_index.html.erb +12 -0
  32. data/app/views/bible270/shared/_header.html.erb +2 -0
  33. data/app/views/bible270/shared/_styles.html.erb +17 -7
  34. data/app/views/layouts/bible270/application.html.erb +2 -4
  35. data/config/routes.rb +38 -18
  36. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  37. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  38. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  39. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  40. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  41. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  42. data/db/migrate/20260101000007_create_bible270_settings.rb +15 -0
  43. data/lib/bible270/avatars.rb +46 -0
  44. data/lib/bible270/configuration.rb +96 -24
  45. data/lib/bible270/email_sign_in.rb +7 -7
  46. data/lib/bible270/engine.rb +1 -0
  47. data/lib/bible270/names.rb +47 -0
  48. data/lib/bible270/plan.rb +356 -102
  49. data/lib/bible270/versification.rb +84 -67
  50. data/lib/bible270/version.rb +2 -1
  51. data/lib/bible270.rb +8 -5
  52. data/lib/generators/bible270/install/install_generator.rb +521 -38
  53. data/lib/generators/bible270/install/templates/bible270.rb.tt +2 -2
  54. metadata +15 -44
data/lib/bible270/plan.rb CHANGED
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
- require "bible270/versification"
3
+ require 'date'
4
+ require 'yaml'
5
+ require 'bible270/versification'
5
6
 
6
7
  module Bible270
7
8
  # Pure-Ruby, deterministic reading-plan generator. No database required.
@@ -14,45 +15,45 @@ module Bible270
14
15
  # grouped so each day is ~equal in verses (~73/day)
15
16
  # * New Testament – read once, one reading every day; the 10 longest chapters
16
17
  # (Luke 1 and friends) are divided in two so 260 chapters fill 270 days
17
- # * Psalms/Proverbs – Psalms once and Proverbs TWICE, interleaved in one track.
18
+ # * Psalms/Proverbs – Psalms and Proverbs once, interleaved in one track.
18
19
  # Longer psalms are divided so the two fill exactly 270 days;
19
20
  # Psalm 119 is pinned to 11 sections of 16 verses.
20
21
  #
21
- # Genesis→Malachi, Revelation 22, Psalm 150 and the second Proverbs 31 all land
22
+ # Genesis → Malachi, Revelation 22, Psalm 150 and the second Proverbs 31 all land
22
23
  # in the closing days.
23
24
  module Plan
24
25
  DAYS = 270
25
26
 
26
27
  OT = [
27
- ["Genesis", 50], ["Exodus", 40], ["Leviticus", 27], ["Numbers", 36],
28
- ["Deuteronomy", 34], ["Joshua", 24], ["Judges", 21], ["Ruth", 4],
29
- ["1 Samuel", 31], ["2 Samuel", 24], ["1 Kings", 22], ["2 Kings", 25],
30
- ["1 Chronicles", 29], ["2 Chronicles", 36], ["Ezra", 10], ["Nehemiah", 13],
31
- ["Esther", 10], ["Job", 42], ["Ecclesiastes", 12], ["Song of Solomon", 8],
32
- ["Isaiah", 66], ["Jeremiah", 52], ["Lamentations", 5], ["Ezekiel", 48],
33
- ["Daniel", 12], ["Hosea", 14], ["Joel", 3], ["Amos", 9], ["Obadiah", 1],
34
- ["Jonah", 4], ["Micah", 7], ["Nahum", 3], ["Habakkuk", 3], ["Zephaniah", 3],
35
- ["Haggai", 2], ["Zechariah", 14], ["Malachi", 4]
28
+ ['Genesis', 50], ['Exodus', 40], ['Leviticus', 27], ['Numbers', 36],
29
+ ['Deuteronomy', 34], ['Joshua', 24], ['Judges', 21], ['Ruth', 4],
30
+ ['1 Samuel', 31], ['2 Samuel', 24], ['1 Kings', 22], ['2 Kings', 25],
31
+ ['1 Chronicles', 29], ['2 Chronicles', 36], ['Ezra', 10], ['Nehemiah', 13],
32
+ ['Esther', 10], ['Job', 42], ['Ecclesiastes', 12], ['Song of Solomon', 8],
33
+ ['Isaiah', 66], ['Jeremiah', 52], ['Lamentations', 5], ['Ezekiel', 48],
34
+ ['Daniel', 12], ['Hosea', 14], ['Joel', 3], ['Amos', 9], ['Obadiah', 1],
35
+ ['Jonah', 4], ['Micah', 7], ['Nahum', 3], ['Habakkuk', 3], ['Zephaniah', 3],
36
+ ['Haggai', 2], ['Zechariah', 14], ['Malachi', 4]
36
37
  ].freeze
37
38
 
38
39
  NT = [
39
- ["Matthew", 28], ["Mark", 16], ["Luke", 24], ["John", 21], ["Acts", 28],
40
- ["Romans", 16], ["1 Corinthians", 16], ["2 Corinthians", 13], ["Galatians", 6],
41
- ["Ephesians", 6], ["Philippians", 4], ["Colossians", 4], ["1 Thessalonians", 5],
42
- ["2 Thessalonians", 3], ["1 Timothy", 6], ["2 Timothy", 4], ["Titus", 3],
43
- ["Philemon", 1], ["Hebrews", 13], ["James", 5], ["1 Peter", 5], ["2 Peter", 3],
44
- ["1 John", 5], ["2 John", 1], ["3 John", 1], ["Jude", 1], ["Revelation", 22]
40
+ ['Matthew', 28], ['Mark', 16], ['Luke', 24], ['John', 21], ['Acts', 28],
41
+ ['Romans', 16], ['1 Corinthians', 16], ['2 Corinthians', 13], ['Galatians', 6],
42
+ ['Ephesians', 6], ['Philippians', 4], ['Colossians', 4], ['1 Thessalonians', 5],
43
+ ['2 Thessalonians', 3], ['1 Timothy', 6], ['2 Timothy', 4], ['Titus', 3],
44
+ ['Philemon', 1], ['Hebrews', 13], ['James', 5], ['1 Peter', 5], ['2 Peter', 3],
45
+ ['1 John', 5], ['2 John', 1], ['3 John', 1], ['Jude', 1], ['Revelation', 22]
45
46
  ].freeze
46
47
 
47
- PP = [["Psalm", 150], ["Proverbs", 31]].freeze
48
+ PP = [['Psalm', 150], ['Proverbs', 31]].freeze
48
49
 
49
50
  TRACKS = {
50
- "ot" => { key: "ot", label: "Old Testament", color: "#2f6a67" },
51
- "nt" => { key: "nt", label: "New Testament", color: "#8a2f3b" },
52
- "pp" => { key: "pp", label: "Psalms & Proverbs", color: "#a5812c" }
51
+ 'ot' => { key: 'ot', label: 'Old Testament', color: '#2f6a67' },
52
+ 'nt' => { key: 'nt', label: 'New Testament', color: '#8a2f3b' },
53
+ 'pp' => { key: 'pp', label: 'Psalms & Proverbs', color: '#a5812c' }
53
54
  }.freeze
54
55
 
55
- module_function
56
+ module_function
56
57
 
57
58
  # ---- shared helpers ---------------------------------------------------
58
59
 
@@ -83,7 +84,7 @@ module Bible270
83
84
  parts << (start == last ? "#{book} #{start}" : "#{book} #{start}\u2013#{last}")
84
85
  i = j + 1
85
86
  end
86
- parts.join(", ")
87
+ parts.join(', ')
87
88
  end
88
89
 
89
90
  # Group ordered items (each [book, chapter]) into `bins` contiguous groups so
@@ -131,6 +132,13 @@ module Bible270
131
132
  def divide_to_fill(weights, target, pinned = {})
132
133
  parts = Array.new(weights.size, 1)
133
134
  pinned.each { |i, n| parts[i] = n }
135
+
136
+ if parts.sum > target
137
+ raise ArgumentError,
138
+ "pinned chapter breaks need #{parts.sum} readings but only #{target} are available; " \
139
+ 'remove some CHAPTER_BREAKS entries or lengthen the plan'
140
+ end
141
+
134
142
  candidates = (0...weights.size).reject { |i| pinned.key?(i) }
135
143
 
136
144
  while parts.sum < target && candidates.any?
@@ -147,19 +155,59 @@ module Bible270
147
155
  n = parts[idx]
148
156
  next [[book, chapter]] if n == 1
149
157
 
150
- total = verses_for(book, chapter)
151
- base = total / n
152
- extra = total % n
153
- v = 1
154
- n.times.map do |k|
155
- size = base + (k < extra ? 1 : 0)
156
- reading = [book, chapter, v, v + size - 1]
157
- v += size
158
- reading
159
- end
158
+ boundaries(book, chapter, n).map { |from, to| [book, chapter, from, to] }
159
+ end
160
+ end
161
+
162
+ # [[from, to], ...] for a chapter divided into n readings: the break points
163
+ # from CHAPTER_BREAKS when given, otherwise equal parts.
164
+ def boundaries(book, chapter, parts)
165
+ total = verses_for(book, chapter)
166
+ explicit = chapter_breaks_for(book, chapter)
167
+ last_verses = explicit || even_break_points(total, parts)
168
+
169
+ from = 1
170
+ (last_verses + [total]).map do |to|
171
+ range = [from, to]
172
+ from = to + 1
173
+ range
160
174
  end
161
175
  end
162
176
 
177
+ def even_break_points(total, parts)
178
+ base = total / parts
179
+ extra = total % parts
180
+ v = 0
181
+ (parts - 1).times.map do |k|
182
+ v += base + (k < extra ? 1 : 0)
183
+ v
184
+ end
185
+ end
186
+
187
+ # Validated on the way out so a typo fails loudly instead of silently
188
+ # producing overlapping or out-of-range readings.
189
+ def chapter_breaks_for(book, chapter)
190
+ points = configured_breaks[[book, chapter]]
191
+ return nil if points.nil?
192
+
193
+ total = verses_for(book, chapter)
194
+ unless points == points.sort.uniq && points.all? { |v| v.is_a?(Integer) && v.between?(1, total - 1) }
195
+ raise ArgumentError,
196
+ "CHAPTER_BREAKS[#{[book, chapter].inspect}] must be ascending, distinct " \
197
+ "verse numbers between 1 and #{total - 1}, got #{points.inspect}"
198
+ end
199
+
200
+ points
201
+ end
202
+
203
+ # {index => reading count} for chapters whose breaks are set explicitly.
204
+ def pinned_from_breaks(chapters)
205
+ chapters.each_with_index.filter_map do |(book, chapter), idx|
206
+ points = configured_breaks[[book, chapter]]
207
+ [idx, points.size + 1] if points
208
+ end.to_h
209
+ end
210
+
163
211
  def segment_length(segment)
164
212
  segment.size == 2 ? verses_for(segment[0], segment[1]) : (segment[3] - segment[2] + 1)
165
213
  end
@@ -175,13 +223,101 @@ module Bible270
175
223
  # Chapters that ended up divided, as {[book, chapter] => reading count}.
176
224
  def divided(chapters, parts)
177
225
  chapters.each_with_index.select { |_, i| parts[i] > 1 }
178
- .to_h { |ch, i| [ch, parts[i]] }
226
+ .to_h { |ch, i| [ch, parts[i]] }
227
+ end
228
+
229
+ # ---- memoisation ------------------------------------------------------
230
+ #
231
+ # Everything derived from the plan is cached together so that reset! (used by
232
+ # the tests, and by anything that changes CHAPTER_BREAKS at runtime) drops it
233
+ # all at once rather than leaving a half-rebuilt plan.
234
+
235
+ def memo(key)
236
+ fingerprint = configured_breaks
237
+ if @cache_fingerprint != fingerprint
238
+ @cache = {}
239
+ @cache_fingerprint = fingerprint
240
+ end
241
+ @cache ||= {}
242
+ return @cache[key] if @cache.key?(key)
243
+
244
+ @cache[key] = yield
245
+ end
246
+
247
+ def reset!
248
+ @cache = {}
249
+ @cache_fingerprint = nil
250
+ true
251
+ end
252
+
253
+ # ---- where the break points come from ---------------------------------
254
+ #
255
+ # Three sources, later winning: the CHAPTER_BREAKS constant, then
256
+ # Bible270.config.chapter_breaks, then a YAML file at
257
+ # Bible270.config.chapter_breaks_path. The file is re-read whenever it
258
+ # changes, so break points can be tuned without restarting the server.
259
+
260
+ def configured_breaks
261
+ base = CHAPTER_BREAKS.merge(config_breaks)
262
+ path = breaks_file_path
263
+ path ? base.merge(file_breaks(path)) : base
264
+ end
265
+
266
+ def config_breaks
267
+ return {} unless bible270_config.respond_to?(:chapter_breaks)
268
+
269
+ normalize_breaks(bible270_config.chapter_breaks || {})
270
+ end
271
+
272
+ def breaks_file_path
273
+ return nil unless bible270_config.respond_to?(:chapter_breaks_path)
274
+
275
+ path = bible270_config.chapter_breaks_path
276
+ path && File.exist?(path.to_s) ? path.to_s : nil
277
+ end
278
+
279
+ def bible270_config
280
+ Bible270.respond_to?(:config) ? Bible270.config : nil
281
+ end
282
+
283
+ # Cached against mtime and size: an edit is picked up, an unchanged file is
284
+ # not re-parsed on every request.
285
+ def file_breaks(path)
286
+ stat = File.stat(path)
287
+ stamp = [path, stat.mtime.to_f, stat.size]
288
+ return @file_breaks if @file_breaks_stamp == stamp
289
+
290
+ @file_breaks_stamp = stamp
291
+ @file_breaks = parse_breaks_file(path)
292
+ end
293
+
294
+ def parse_breaks_file(path)
295
+ raw = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
296
+ raise ArgumentError, "#{path} must map \"Book Chapter\" to a list of verses" unless raw.is_a?(Hash)
297
+
298
+ normalize_breaks(raw)
299
+ rescue Psych::SyntaxError => e
300
+ raise ArgumentError, "#{path} is not valid YAML: #{e.message}"
301
+ end
302
+
303
+ # Keys may be ['Psalm', 18] or 'Psalm 18'; a nil value means "keep whole".
304
+ def normalize_breaks(hash)
305
+ hash.to_h { |key, value| [break_key(key), Array(value).map(&:to_i)] }
306
+ end
307
+
308
+ def break_key(key)
309
+ return [key[0].to_s, key[1].to_i] if key.is_a?(Array)
310
+
311
+ match = key.to_s.strip.match(%r{\A(.+?)\s+(\d+)\z})
312
+ raise ArgumentError, "chapter break key #{key.inspect} must look like 'Psalm 18'" if match.nil?
313
+
314
+ [match[1], match[2].to_i]
179
315
  end
180
316
 
181
317
  # ---- Old Testament ----------------------------------------------------
182
318
 
183
319
  def ot_groups
184
- @ot_groups ||= begin
320
+ memo(:ot_groups) do
185
321
  chapters = flatten(OT)
186
322
  weights = chapters.map { |b, c| verses_for(b, c) }
187
323
  balance_by_weight(chapters, weights, DAYS)
@@ -189,11 +325,11 @@ module Bible270
189
325
  end
190
326
 
191
327
  def ot_plan
192
- @ot_plan ||= ot_groups.map { |g| format_reference(g) }
328
+ memo(:ot_plan) { ot_groups.map { |g| format_reference(g) } }
193
329
  end
194
330
 
195
331
  def ot_verse_loads
196
- @ot_verse_loads ||= ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
332
+ memo(:ot_verse_loads) { ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } } }
197
333
  end
198
334
 
199
335
  # ---- New Testament (read once, one reading every day) ------------------
@@ -205,117 +341,215 @@ module Bible270
205
341
 
206
342
  NT_PASSES = 1
207
343
 
208
- def nt_chapters = @nt_chapters ||= flatten(NT)
344
+ def nt_chapters = memo(:nt_chapters) { flatten(NT) }
209
345
 
210
346
  def nt_parts
211
- @nt_parts ||= divide_to_fill(nt_chapters.map { |b, c| verses_for(b, c) }, DAYS)
347
+ memo(:nt_parts) { divide_to_fill(nt_chapters.map { |b, c| verses_for(b, c) }, DAYS, pinned_from_breaks(nt_chapters)) }
212
348
  end
213
349
 
214
350
  def nt_readings
215
- @nt_readings ||= expand_readings(nt_chapters, nt_parts)
351
+ memo(:nt_readings) { expand_readings(nt_chapters, nt_parts) }
216
352
  end
217
353
 
218
354
  def nt_plan
219
- @nt_plan ||= nt_readings.map { |seg| format_segment(seg) }
355
+ memo(:nt_plan) { nt_readings.map { |seg| format_segment(seg) } }
220
356
  end
221
357
 
222
358
  def nt_verse_loads
223
- @nt_verse_loads ||= nt_readings.map { |seg| segment_length(seg) }
359
+ memo(:nt_verse_loads) { nt_readings.map { |seg| segment_length(seg) } }
224
360
  end
225
361
 
226
362
  # Every day now carries a New Testament reading.
227
- def nt_content_days = @nt_content_days ||= nt_plan.count { |r| !r.nil? }
363
+ def nt_content_days = memo(:nt_content_days) { nt_plan.count { |r| !r.nil? } }
228
364
 
229
365
  def nt_rest_days = []
230
366
 
231
- def divided_nt_chapters = @divided_nt_chapters ||= divided(nt_chapters, nt_parts)
367
+ def divided_nt_chapters = memo(:divided_nt_chapters) { divided(nt_chapters, nt_parts) }
232
368
 
233
369
  # ---- Psalms & Proverbs -------------------------------------------------
234
370
  #
235
- # Psalms once and Proverbs TWICE across the 270 days, in one daily track.
371
+ # Psalms once and Proverbs once, interleaved in one daily track.
372
+ #
373
+ # 150 + 31 = 181 chapters have to cover 270 days, so 89 of them are divided.
374
+ # Two rules shape which:
236
375
  #
237
- # Proverbs supplies 31 x 2 = 62 readings (whole chapters), leaving 208 days
238
- # for the Psalms. Since there are only 150 psalms, longer ones are divided:
376
+ # * A psalm of PSALM_WHOLE_MAX_VERSES verses or fewer is never divided.
377
+ # That locks 131 of the 150 psalms whole.
378
+ # * Psalm 119 is pinned to sections of PSALM_119_SECTION_SIZE verses.
239
379
  #
240
- # * Psalm 119 is pinned to 11 sections of exactly 16 verses (176 = 11 x 16),
241
- # which matches its 22 eight-verse acrostic stanzas, two per section.
242
- # * The remaining 48 divisions go to whichever psalm currently carries the
243
- # heaviest reading, so the longest are split first and no single reading
244
- # runs long.
380
+ # The remaining divisions are shared between the eligible psalms and Proverbs
381
+ # by verse load — each one goes to whichever chapter currently carries the
382
+ # heaviest reading — so both books end up with readings of about the same
383
+ # length and Proverbs lands on roughly its proportional share of the days.
384
+ # CHAPTER_BREAKS overrides any of it.
245
385
  #
246
- # The 62 Proverbs readings are then spaced evenly through the 270 days, which
247
- # lands Proverbs 31 on day 135 and again on day 270.
386
+ # A divided chapter is always read on consecutive days: readings are grouped
387
+ # by chapter and the two books interleaved a whole chapter at a time, so
388
+ # nothing is ever inserted between the parts of a split psalm.
248
389
 
249
- PROVERBS_PASSES = 2
250
- PSALM_119_SECTION_SIZE = 16
390
+ # ---- where divided chapters break -------------------------------------
391
+ #
392
+ # EDIT THIS to choose your own break points. Keys are [book, chapter];
393
+ # the value lists the LAST VERSE of every reading except the final one.
394
+ #
395
+ # ['Luke', 1] => [25, 56] # => Luke 1:1-25, 1:26-56, 1:57-80
396
+ # ['Psalm', 78] => [39] # => Psalm 78:1-39, 78:40-72
397
+ #
398
+ # The number of readings a chapter becomes is then fixed by the number of
399
+ # breaks you give it, and the rest of the plan re-divides around it to still
400
+ # fill exactly DAYS days. Anything not listed here is split into equal parts.
401
+ CHAPTER_BREAKS = {
402
+ ['Psalm', 9] => [], ['Psalm', 10] => [], # Stay whole
403
+ ['Psalm', 18] => [24], # => Psalm 18:1–24, 18:25–50
404
+ ['Psalm', 22] => [21], # => Psalm 22:1–21, 22:22–31
405
+ ['Psalm', 31] => [], ['Psalm', 33] => [], ['Psalm', 34] => [], ['Psalm', 38] => [], # Stay whole
406
+ ['Psalm', 44] => [], ['Psalm', 49] => [], ['Psalm', 50] => [], ['Psalm', 51] => [], # Stay whole
407
+ ['Psalm', 55] => [], ['Psalm', 59] => [], ['Psalm', 65] => [], ['Psalm', 66] => [], # Stay whole
408
+ ['Psalm', 68] => [18], # => Psalm 68:1–18, 68:19–35
409
+ ['Psalm', 69] => [18], # => Psalm 69:1–18, 69:19–36
410
+ ['Psalm', 78] => [20, 39, 55], # => 78:1–20, 21–39, 40–55, 56–72
411
+ ['Psalm', 37] => [20], # => Psalm 37:1–20, 37:21–40
412
+ ['Matthew', 26] => [35], # => Matthew 26:1–35, 26:36–75
413
+ ['Matthew', 27] => [31], # => Matthew 27:1–31
414
+ ['Mark', 14] => [31], # => Mark 14:1–31, 14:32–72
415
+ ['Luke', 1] => [25, 56], # => Luke 1:1–25, 1:26–56, 1:57–80
416
+ ['Luke', 9] => [36], # => Luke 9:1–36, 9:37–62
417
+ ['Luke', 11] => [26], # => Luke 11:1–26, 11:27–54
418
+ ['Luke', 22] => [38], # => Luke 22:1–38, 22:39–71
419
+ ['John', 6] => [40], # => John 6:1–40, 6:41–71
420
+ ['Acts', 7] => [36], # => Acts 7:1–36, 7:37–60
421
+
422
+ # -- Psalms over 25 verses, divided at their turns of thought ----------
423
+ ['Psalm', 35] => [], # kept whole
424
+ ['Psalm', 73] => [14], # the envious lament | the sanctuary
425
+ ['Psalm', 89] => [18, 37], # hymn | covenant oracle | lament
426
+ ['Psalm', 102] => [17], # affliction | Zion restored
427
+ ['Psalm', 104] => [18], # creation ordered | creation sustained
428
+ ['Psalm', 105] => [22], # covenant and Joseph | exodus
429
+ ['Psalm', 106] => [23], # praise and wilderness | Canaan and mercy
430
+ ['Psalm', 107] => [16, 32], # wanderers and prisoners | sick and storm-tossed | coda
431
+ ['Psalm', 109] => [20], # the imprecation | the plea
432
+ ['Psalm', 118] => [18], # thanksgiving | the gate of the LORD
433
+ ['Psalm', 136] => [], # the refrain psalm, kept whole
434
+
435
+ # -- Proverbs: the discourses of 1-9 and the appendices of 30-31 have
436
+ # real structure, so they break at it. Chapters 10-29 are collections
437
+ # of independent sayings with no narrative flow, so they are divided
438
+ # evenly; add entries here if you prefer particular groupings.
439
+ ['Proverbs', 1] => [7, 19], # prologue | enticement of sinners | Wisdom calls
440
+ ['Proverbs', 2] => [9], # the search for wisdom | its protection
441
+ ['Proverbs', 3] => [12, 26], # trust and discipline | wisdom's worth | neighbourly justice
442
+ ['Proverbs', 4] => [9, 19], # a father's teaching | the two paths | guard the heart
443
+ ['Proverbs', 5] => [14], # the adulteress | your own cistern
444
+ ['Proverbs', 6] => [19], # surety, sluggard, seven abominations | adultery
445
+ ['Proverbs', 7] => [5, 23], # keep my commands | the seduction | her house
446
+ ['Proverbs', 8] => [11, 21], # Wisdom calls | her worth | Wisdom at creation
447
+ ['Proverbs', 9] => [6, 12], # Wisdom's feast | scoffer and wise | Folly's feast
448
+ ['Proverbs', 30] => [9, 17], # Agur's confession | four sayings | the numbered proverbs
449
+ ['Proverbs', 22] => [16], # sayings | words of the wise
450
+ ['Proverbs', 23] => [8, 21, 28],
451
+ ['Proverbs', 24] => [12, 22],
452
+ ['Proverbs', 26] => [12, 19], # the fool | the sluggard | the quarreller
453
+ ['Proverbs', 27] => [10, 17],
454
+ ['Proverbs', 28] => [12, 18],
455
+ ['Proverbs', 31] => [9] # King Lemuel | the woman of valour
456
+ }.freeze
251
457
 
252
- def proverbs_reading_count = flatten([["Proverbs", 31]]).size * PROVERBS_PASSES
458
+ # A psalm this short or shorter is never divided.
459
+ PSALM_WHOLE_MAX_VERSES = 25
253
460
 
254
- def psalm_reading_count = DAYS - proverbs_reading_count
461
+ PSALM_119_SECTION_SIZE = 16
255
462
 
256
463
  def psalm_chapters
257
- @psalm_chapters ||= (1..Versification.chapter_count("Psalm")).map { |c| ["Psalm", c] }
464
+ memo(:psalm_chapters) { (1..Versification.chapter_count('Psalm')).map { |c| ['Psalm', c] } }
258
465
  end
259
466
 
260
- # How many readings each psalm is divided into (index 0 == Psalm 1).
261
- # Psalm 119 is pinned to 11 sections of PSALM_119_SECTION_SIZE verses.
262
- def psalm_parts
263
- @psalm_parts ||= begin
264
- weights = psalm_chapters.map { |b, c| verses_for(b, c) }
265
- i119 = 118
266
- divide_to_fill(weights, psalm_reading_count, i119 => weights[i119] / PSALM_119_SECTION_SIZE)
267
- end
467
+ def proverbs_chapters
468
+ memo(:proverbs_chapters) { (1..Versification.chapter_count('Proverbs')).map { |c| ['Proverbs', c] } }
268
469
  end
269
470
 
270
- def psalm_readings
271
- @psalm_readings ||= expand_readings(psalm_chapters, psalm_parts)
272
- end
471
+ def pp_chapters = memo(:pp_chapters) { psalm_chapters + proverbs_chapters }
273
472
 
274
- # Proverbs 1..31, repeated PROVERBS_PASSES times, always whole chapters.
275
- def proverbs_readings
276
- @proverbs_readings ||= begin
277
- chapters = (1..Versification.chapter_count("Proverbs")).map { |c| ["Proverbs", c] }
278
- chapters * PROVERBS_PASSES
473
+ # Psalms and Proverbs are divided together against a single budget of DAYS
474
+ # readings, so the two books balance against each other by verse load rather
475
+ # than each being squeezed into a fixed number of days.
476
+ def pp_parts
477
+ memo(:pp_parts) do
478
+ chapters = pp_chapters
479
+ weights = chapters.map { |b, c| verses_for(b, c) }
480
+ divide_to_fill(weights, DAYS, pp_pinned_parts(chapters, weights))
279
481
  end
280
482
  end
281
483
 
282
- # True when the given 1-indexed day is one of the evenly spaced Proverbs days.
283
- def proverbs_day?(day)
284
- n = proverbs_reading_count
285
- (day * n / DAYS) > ((day - 1) * n / DAYS)
286
- end
484
+ def pp_pinned_parts(chapters, weights)
485
+ pinned = {}
486
+ chapters.each_with_index do |(book, chapter), idx|
487
+ next unless book == 'Psalm'
287
488
 
288
- # One reading per day, Psalms and Proverbs interleaved.
289
- def pp_readings
290
- @pp_readings ||= begin
291
- psalms = psalm_readings.dup
292
- proverbs = proverbs_readings.dup
293
- (1..DAYS).map { |day| proverbs_day?(day) ? proverbs.shift : psalms.shift }
489
+ pinned[idx] = 1 if weights[idx] <= PSALM_WHOLE_MAX_VERSES
490
+ pinned[idx] = weights[idx] / PSALM_119_SECTION_SIZE if chapter == 119
294
491
  end
492
+ # An explicit entry in CHAPTER_BREAKS always wins.
493
+ pinned.merge(pinned_from_breaks(chapters))
295
494
  end
296
495
 
297
- def pp_plan
298
- @pp_plan ||= pp_readings.map { |seg| format_segment(seg) }
496
+ def psalm_parts = memo(:psalm_parts) { pp_parts.first(psalm_chapters.size) }
497
+ def proverbs_parts = memo(:proverbs_parts) { pp_parts.last(proverbs_chapters.size) }
498
+
499
+ def psalm_readings = memo(:psalm_readings) { expand_readings(psalm_chapters, psalm_parts) }
500
+ def proverbs_readings = memo(:proverbs_readings) { expand_readings(proverbs_chapters, proverbs_parts) }
501
+
502
+ # Readings grouped by chapter, so a divided chapter stays together.
503
+ def psalm_groups = memo(:psalm_groups) { group_by_chapter(psalm_readings) }
504
+ def proverbs_groups = memo(:proverbs_groups) { group_by_chapter(proverbs_readings) }
505
+
506
+ def group_by_chapter(readings)
507
+ readings.chunk_while { |a, b| a[0] == b[0] && a[1] == b[1] }.to_a
299
508
  end
300
509
 
301
- def pp_verse_loads
302
- @pp_verse_loads ||= pp_readings.map { |seg| segment_length(seg) }
510
+ # Interleave the two books a whole chapter at a time, spacing the Proverbs
511
+ # chapters evenly through the Psalms. Because a group is emitted in one go,
512
+ # nothing can land between the parts of a divided chapter.
513
+ def pp_readings
514
+ memo(:pp_readings) do
515
+ psalms = psalm_groups
516
+ proverbs = proverbs_groups
517
+ placed = 0
518
+ days = []
519
+
520
+ psalms.each_with_index do |group, i|
521
+ days.concat(group)
522
+ due = ((i + 1) * proverbs.size) / psalms.size
523
+ while placed < due
524
+ days.concat(proverbs[placed])
525
+ placed += 1
526
+ end
527
+ end
528
+ days.concat(proverbs[placed..].to_a.flatten(1)) if placed < proverbs.size
529
+ days
530
+ end
303
531
  end
304
532
 
533
+ def pp_plan = memo(:pp_plan) { pp_readings.map { |seg| format_segment(seg) } }
534
+ def pp_verse_loads = memo(:pp_verse_loads) { pp_readings.map { |seg| segment_length(seg) } }
535
+
305
536
  # 1-indexed days carrying a Proverbs reading.
306
537
  def proverbs_days
307
- @proverbs_days ||= (1..DAYS).select { |d| proverbs_day?(d) }
538
+ memo(:proverbs_days) { (1..DAYS).select { |d| pp_readings[d - 1][0] == 'Proverbs' } }
308
539
  end
309
540
 
310
- # Psalms that end up divided, as {chapter => number of readings}.
311
541
  def divided_psalms
312
- @divided_psalms ||= divided(psalm_chapters, psalm_parts).to_h { |ch, n| [ch[1], n] }
542
+ memo(:divided_psalms) { divided(psalm_chapters, psalm_parts).to_h { |ch, n| [ch[1], n] } }
543
+ end
544
+
545
+ def divided_proverbs
546
+ memo(:divided_proverbs) { divided(proverbs_chapters, proverbs_parts).to_h { |ch, n| [ch[1], n] } }
313
547
  end
314
548
 
315
549
  # ---- public API -------------------------------------------------------
316
550
 
317
551
  def readings_for(day)
318
- { "ot" => ot_plan[day - 1], "nt" => nt_plan[day - 1], "pp" => pp_plan[day - 1] }
552
+ { 'ot' => ot_plan[day - 1], 'nt' => nt_plan[day - 1], 'pp' => pp_plan[day - 1] }
319
553
  end
320
554
 
321
555
  def present_tracks(day)
@@ -326,6 +560,21 @@ module Bible270
326
560
 
327
561
  def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
328
562
 
563
+ # Blank means "the whole day", which is stored as NULL. Select fields submit
564
+ # an empty string rather than nil, so this has to be normalised before
565
+ # validation or the inclusion check rejects it.
566
+ def normalize_track(value)
567
+ track = value.to_s.strip
568
+ return nil if track.empty?
569
+
570
+ track
571
+ end
572
+
573
+ def valid_track?(value)
574
+ track = normalize_track(value)
575
+ track.nil? || TRACKS.key?(track)
576
+ end
577
+
329
578
  # ---- calendar mapping (all pure functions; nil start_date = undated) ----
330
579
 
331
580
  # Coerce whatever we were handed into a Date (or nil).
@@ -334,7 +583,11 @@ module Bible270
334
583
  when nil then nil
335
584
  when ::Date then value
336
585
  when ::Time then value.to_date
337
- when ::String then (::Date.parse(value) rescue nil)
586
+ when ::String then begin
587
+ ::Date.parse(value)
588
+ rescue StandardError
589
+ nil
590
+ end
338
591
  else value.respond_to?(:to_date) ? value.to_date : nil
339
592
  end
340
593
  end
@@ -381,12 +634,13 @@ module Bible270
381
634
  nt_passes: NT_PASSES,
382
635
  nt_readings: nt_readings.size, # 270 - one every day
383
636
  nt_divided: divided_nt_chapters.size, # 10 long chapters halved
384
- psalms: Versification.chapter_count("Psalm"), # 150
637
+ psalms: Versification.chapter_count('Psalm'), # 150
385
638
  psalm_readings: psalm_readings.size, # 208
386
- proverbs: Versification.chapter_count("Proverbs"), # 31
387
- proverbs_passes: PROVERBS_PASSES,
388
- proverbs_readings: proverbs_reading_count, # 62
389
- pp: flatten(PP).size, # 181 chapters
639
+ proverbs: Versification.chapter_count('Proverbs'), # 31
640
+ proverbs_readings: proverbs_readings.size,
641
+ proverbs_divided: divided_proverbs.size,
642
+ psalms_whole_max_verses: PSALM_WHOLE_MAX_VERSES,
643
+ pp: flatten(PP).size, # 181 chapters
390
644
  pp_verses: flatten(PP).sum { |b, c| verses_for(b, c) },
391
645
  ot_verses: flatten(OT).sum { |b, c| verses_for(b, c) },
392
646
  nt_verses: flatten(NT).sum { |b, c| verses_for(b, c) },