Module: Bible270::Plan

Defined in:
lib/bible270/plan.rb

Overview

Pure-Ruby, deterministic reading-plan generator. No database required.

Daily portions are balanced by VERSE COUNT (a canonical, translation-neutral proxy for how much text there is), not by chapter count — so a day landing on Psalm 119 (176 verses) is not treated as equal to a day on Psalm 117 (2 verses).

  • Old Testament – read once (excluding Psalms and Proverbs); WHOLE chapters grouped so each day is ~equal in verses (~73/day)
  • New Testament – read once, one reading every day; the 10 longest chapters (Luke 1 and friends) are divided in two so 260 chapters fill 270 days
  • Psalms/Proverbs – Psalms and Proverbs once, interleaved in one track. Longer psalms are divided so the two fill exactly 270 days; Psalm 119 is pinned to 11 sections of 16 verses.

Genesis → Malachi, Revelation 22, Psalm 150 and the second Proverbs 31 all land in the closing days.

Constant Summary collapse

DAYS =
270
OT =
[
  ['Genesis', 50], ['Exodus', 40], ['Leviticus', 27], ['Numbers', 36],
  ['Deuteronomy', 34], ['Joshua', 24], ['Judges', 21], ['Ruth', 4],
  ['1 Samuel', 31], ['2 Samuel', 24], ['1 Kings', 22], ['2 Kings', 25],
  ['1 Chronicles', 29], ['2 Chronicles', 36], ['Ezra', 10], ['Nehemiah', 13],
  ['Esther', 10], ['Job', 42], ['Ecclesiastes', 12], ['Song of Solomon', 8],
  ['Isaiah', 66], ['Jeremiah', 52], ['Lamentations', 5], ['Ezekiel', 48],
  ['Daniel', 12], ['Hosea', 14], ['Joel', 3], ['Amos', 9], ['Obadiah', 1],
  ['Jonah', 4], ['Micah', 7], ['Nahum', 3], ['Habakkuk', 3], ['Zephaniah', 3],
  ['Haggai', 2], ['Zechariah', 14], ['Malachi', 4]
].freeze
NT =
[
  ['Matthew', 28], ['Mark', 16], ['Luke', 24], ['John', 21], ['Acts', 28],
  ['Romans', 16], ['1 Corinthians', 16], ['2 Corinthians', 13], ['Galatians', 6],
  ['Ephesians', 6], ['Philippians', 4], ['Colossians', 4], ['1 Thessalonians', 5],
  ['2 Thessalonians', 3], ['1 Timothy', 6], ['2 Timothy', 4], ['Titus', 3],
  ['Philemon', 1], ['Hebrews', 13], ['James', 5], ['1 Peter', 5], ['2 Peter', 3],
  ['1 John', 5], ['2 John', 1], ['3 John', 1], ['Jude', 1], ['Revelation', 22]
].freeze
PP =
[['Psalm', 150], ['Proverbs', 31]].freeze
TRACKS =
{
  'ot' => { key: 'ot', label: 'Old Testament',     color: '#2f6a67' },
  'nt' => { key: 'nt', label: 'New Testament',     color: '#8a2f3b' },
  'pp' => { key: 'pp', label: 'Psalms & Proverbs', color: '#a5812c' }
}.freeze
NT_PASSES =

---- New Testament (read once, one reading every day) ------------------

260 chapters over 270 days, so the 10 longest chapters are each divided in two — Luke 1, Matthew 26 and 27, Mark 14, Luke 9, 12 and 22, John 6 and 8, and Acts 7. That gives exactly one reading a day with no days off, and brings the longest reading down from Luke 1's 80 verses to 58.

1
CHAPTER_BREAKS =

---- where divided chapters break -------------------------------------

EDIT THIS to choose your own break points. Keys are [book, chapter]; the value lists the LAST VERSE of every reading except the final one.

['Luke', 1] => [25, 56]   # => Luke 1:1-25, 1:26-56, 1:57-80
['Psalm', 78] => [39]     # => Psalm 78:1-39, 78:40-72

The number of readings a chapter becomes is then fixed by the number of breaks you give it, and the rest of the plan re-divides around it to still fill exactly DAYS days. Anything not listed here is split into equal parts.

{
  ['Psalm', 9] => [], ['Psalm', 10] => [], # Stay whole
  ['Psalm', 18] => [24], # => Psalm 18:1–24, 18:25–50
  ['Psalm', 22] => [21], # => Psalm 22:1–21, 22:22–31
  ['Psalm', 31] => [], ['Psalm', 33] => [], ['Psalm', 34] => [], ['Psalm', 38] => [], # Stay whole
  ['Psalm', 44] => [], ['Psalm', 49] => [], ['Psalm', 50] => [], ['Psalm', 51] => [], # Stay whole
  ['Psalm', 55] => [], ['Psalm', 59] => [], ['Psalm', 65] => [], ['Psalm', 66] => [], # Stay whole
  ['Psalm', 68] => [18], # => Psalm 68:1–18, 68:19–35
  ['Psalm', 69] => [18], # => Psalm 69:1–18, 69:19–36
  ['Psalm', 78] => [20, 39, 55], # => 78:1–20, 21–39, 40–55, 56–72
  ['Psalm', 37] => [20], # => Psalm 37:1–20, 37:21–40
  ['Matthew', 26] => [35], # => Matthew 26:1–35, 26:36–75
  ['Matthew', 27] => [31], # => Matthew 27:1–31
  ['Mark', 14] => [31],    # => Mark 14:1–31, 14:32–72
  ['Luke', 1] => [25, 56], # => Luke 1:1–25, 1:26–56, 1:57–80
  ['Luke', 9] => [36], # => Luke 9:1–36, 9:37–62
  ['Luke', 11] => [26], # => Luke 11:1–26, 11:27–54
  ['Luke', 22] => [38], # => Luke 22:1–38, 22:39–71
  ['John', 6] => [40], # => John 6:1–40, 6:41–71
  ['Acts', 7] => [36], # => Acts 7:1–36, 7:37–60

  # -- Psalms over 25 verses, divided at their turns of thought ----------
  ['Psalm', 35] => [],             # kept whole
  ['Psalm', 73] => [14],           # the envious lament | the sanctuary
  ['Psalm', 89] => [18, 37],       # hymn | covenant oracle | lament
  ['Psalm', 102] => [17],          # affliction | Zion restored
  ['Psalm', 104] => [18],          # creation ordered | creation sustained
  ['Psalm', 105] => [22],          # covenant and Joseph | exodus
  ['Psalm', 106] => [23],          # praise and wilderness | Canaan and mercy
  ['Psalm', 107] => [16, 32],      # wanderers and prisoners | sick and storm-tossed | coda
  ['Psalm', 109] => [20],          # the imprecation | the plea
  ['Psalm', 118] => [18],          # thanksgiving | the gate of the LORD
  ['Psalm', 136] => [],            # the refrain psalm, kept whole

  # -- Proverbs: the discourses of 1-9 and the appendices of 30-31 have
  #    real structure, so they break at it. Chapters 10-29 are collections
  #    of independent sayings with no narrative flow, so they are divided
  #    evenly; add entries here if you prefer particular groupings.
  ['Proverbs', 1] => [7, 19],      # prologue | enticement of sinners | Wisdom calls
  ['Proverbs', 2] => [9],          # the search for wisdom | its protection
  ['Proverbs', 3] => [12, 26],     # trust and discipline | wisdom's worth | neighbourly justice
  ['Proverbs', 4] => [9, 19],      # a father's teaching | the two paths | guard the heart
  ['Proverbs', 5] => [14],         # the adulteress | your own cistern
  ['Proverbs', 6] => [19],         # surety, sluggard, seven abominations | adultery
  ['Proverbs', 7] => [5, 23],      # keep my commands | the seduction | her house
  ['Proverbs', 8] => [11, 21],     # Wisdom calls | her worth | Wisdom at creation
  ['Proverbs', 9] => [6, 12],      # Wisdom's feast | scoffer and wise | Folly's feast
  ['Proverbs', 30] => [9, 17],     # Agur's confession | four sayings | the numbered proverbs
  ['Proverbs', 22] => [16],        # sayings | words of the wise
  ['Proverbs', 23] => [8, 21, 28],
  ['Proverbs', 24] => [12, 22],
  ['Proverbs', 26] => [12, 19],    # the fool | the sluggard | the quarreller
  ['Proverbs', 27] => [10, 17],
  ['Proverbs', 28] => [12, 18],
  ['Proverbs', 31] => [9]          # King Lemuel | the woman of valour
}.freeze
PSALM_WHOLE_MAX_VERSES =

A psalm this short or shorter is never divided.

25
PSALM_119_SECTION_SIZE =
16

Class Method Summary collapse

Class Method Details

.after_end?(date, start_date) ⇒ Boolean

Returns:

  • (Boolean)


625
626
627
628
# File 'lib/bible270/plan.rb', line 625

def after_end?(date, start_date)
  raw = day_for(date, start_date, clamp: false)
  raw ? raw > DAYS : false
end

.balance_by_weight(items, weights, bins) ⇒ Object

Group ordered items (each [book, chapter]) into bins contiguous groups so that the total weight per group is as even as possible, while guaranteeing every group gets at least one item.



93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/bible270/plan.rb', line 93

def balance_by_weight(items, weights, bins)
  n = items.size
  total = weights.sum.to_f
  groups = Array.new(bins) { [] }
  idx = 0
  running = 0.0
  (0...bins).each do |d|
    target = (d + 1) * total / bins
    # every bin takes at least one item
    groups[d] << items[idx]
    running += weights[idx]
    idx += 1
    # then add more only while it brings this bin's cumulative closer to the
    # target boundary (round-to-nearest), keeping one item in reserve per
    # remaining bin
    while idx < n && (n - idx) > (bins - d - 1)
      w = weights[idx]
      break if running >= target
      # stop if overshooting the target would be worse than stopping short
      break if (running + w - target) > (target - running)

      groups[d] << items[idx]
      running += w
      idx += 1
    end
  end
  groups[bins - 1].concat(items[idx...n]) if idx < n
  groups
end

.before_start?(date, start_date) ⇒ Boolean

Returns:

  • (Boolean)


620
621
622
623
# File 'lib/bible270/plan.rb', line 620

def before_start?(date, start_date)
  raw = day_for(date, start_date, clamp: false)
  raw ? raw < 1 : false
end

.bible270_configObject



279
280
281
# File 'lib/bible270/plan.rb', line 279

def bible270_config
  Bible270.respond_to?(:config) ? Bible270.config : nil
end

.boundaries(book, chapter, parts) ⇒ Object

[[from, to], ...] for a chapter divided into n readings: the break points from CHAPTER_BREAKS when given, otherwise equal parts.



164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/bible270/plan.rb', line 164

def boundaries(book, chapter, parts)
  total = verses_for(book, chapter)
  explicit = chapter_breaks_for(book, chapter)
  last_verses = explicit || even_break_points(total, parts)

  from = 1
  (last_verses + [total]).map do |to|
    range = [from, to]
    from = to + 1
    range
  end
end

.break_key(key) ⇒ Object

Raises:

  • (ArgumentError)


308
309
310
311
312
313
314
315
# File 'lib/bible270/plan.rb', line 308

def break_key(key)
  return [key[0].to_s, key[1].to_i] if key.is_a?(Array)

  match = key.to_s.strip.match(%r{\A(.+?)\s+(\d+)\z})
  raise ArgumentError, "chapter break key #{key.inspect} must look like 'Psalm 18'" if match.nil?

  [match[1], match[2].to_i]
end

.breaks_file_pathObject



272
273
274
275
276
277
# File 'lib/bible270/plan.rb', line 272

def breaks_file_path
  return nil unless bible270_config.respond_to?(:chapter_breaks_path)

  path = bible270_config.chapter_breaks_path
  path && File.exist?(path.to_s) ? path.to_s : nil
end

.chapter_breaks_for(book, chapter) ⇒ Object

Validated on the way out so a typo fails loudly instead of silently producing overlapping or out-of-range readings.



189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/bible270/plan.rb', line 189

def chapter_breaks_for(book, chapter)
  points = configured_breaks[[book, chapter]]
  return nil if points.nil?

  total = verses_for(book, chapter)
  unless points == points.sort.uniq && points.all? { |v| v.is_a?(Integer) && v.between?(1, total - 1) }
    raise ArgumentError,
          "CHAPTER_BREAKS[#{[book, chapter].inspect}] must be ascending, distinct " \
          "verse numbers between 1 and #{total - 1}, got #{points.inspect}"
  end

  points
end

.config_breaksObject



266
267
268
269
270
# File 'lib/bible270/plan.rb', line 266

def config_breaks
  return {} unless bible270_config.respond_to?(:chapter_breaks)

  normalize_breaks(bible270_config.chapter_breaks || {})
end

.configured_breaksObject

---- where the break points come from ---------------------------------

Three sources, later winning: the CHAPTER_BREAKS constant, then Bible270.config.chapter_breaks, then a YAML file at Bible270.config.chapter_breaks_path. The file is re-read whenever it changes, so break points can be tuned without restarting the server.



260
261
262
263
264
# File 'lib/bible270/plan.rb', line 260

def configured_breaks
  base = CHAPTER_BREAKS.merge(config_breaks)
  path = breaks_file_path
  path ? base.merge(file_breaks(path)) : base
end

.date_for(day, start_date) ⇒ Object

The calendar date a given plan day falls on.



596
597
598
599
600
601
# File 'lib/bible270/plan.rb', line 596

def date_for(day, start_date)
  start = to_date(start_date)
  return nil unless start && valid_day?(day)

  start + (day - 1)
end

.day_for(date, start_date, clamp: true) ⇒ Object

Which plan day a calendar date corresponds to. By default the result is clamped into 1..DAYS; pass clamp: false to get the raw (possibly out of range) offset, which is how you tell "hasn't started yet" from "day 1".



611
612
613
614
615
616
617
618
# File 'lib/bible270/plan.rb', line 611

def day_for(date, start_date, clamp: true)
  start = to_date(start_date)
  on    = to_date(date)
  return nil unless start && on

  offset = (on - start).to_i + 1
  clamp ? offset.clamp(1, DAYS) : offset
end

.divide_to_fill(weights, target, pinned = {}) ⇒ Object

weights: verses per chapter, in order. pinned: => fixed part count. Returns the number of readings each chapter becomes.



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/bible270/plan.rb', line 132

def divide_to_fill(weights, target, pinned = {})
  parts = Array.new(weights.size, 1)
  pinned.each { |i, n| parts[i] = n }

  if parts.sum > target
    raise ArgumentError,
          "pinned chapter breaks need #{parts.sum} readings but only #{target} are available; " \
          'remove some CHAPTER_BREAKS entries or lengthen the plan'
  end

  candidates = (0...weights.size).reject { |i| pinned.key?(i) }

  while parts.sum < target && candidates.any?
    heaviest = candidates.max_by { |i| weights[i].fdiv(parts[i]) }
    parts[heaviest] += 1
  end
  parts
end

.divided(chapters, parts) ⇒ Object

Chapters that ended up divided, as chapter] => reading count.



224
225
226
227
# File 'lib/bible270/plan.rb', line 224

def divided(chapters, parts)
  chapters.each_with_index.select { |_, i| parts[i] > 1 }
    .to_h { |ch, i| [ch, parts[i]] }
end

.divided_nt_chaptersObject



367
# File 'lib/bible270/plan.rb', line 367

def divided_nt_chapters = memo(:divided_nt_chapters) { divided(nt_chapters, nt_parts) }

.divided_proverbsObject



545
546
547
# File 'lib/bible270/plan.rb', line 545

def divided_proverbs
  memo(:divided_proverbs) { divided(proverbs_chapters, proverbs_parts).to_h { |ch, n| [ch[1], n] } }
end

.divided_psalmsObject



541
542
543
# File 'lib/bible270/plan.rb', line 541

def divided_psalms
  memo(:divided_psalms) { divided(psalm_chapters, psalm_parts).to_h { |ch, n| [ch[1], n] } }
end

.end_date_for(start_date) ⇒ Object

The last day of the plan.



604
605
606
# File 'lib/bible270/plan.rb', line 604

def end_date_for(start_date)
  date_for(DAYS, start_date)
end

.even_break_points(total, parts) ⇒ Object



177
178
179
180
181
182
183
184
185
# File 'lib/bible270/plan.rb', line 177

def even_break_points(total, parts)
  base = total / parts
  extra = total % parts
  v = 0
  (parts - 1).times.map do |k|
    v += base + (k < extra ? 1 : 0)
    v
  end
end

.expand_readings(chapters, parts) ⇒ Object

Turn [book, chapter] pairs plus a part count into readings. A reading is [book, chapter] when whole, or [book, chapter, from, to] when it's a slice.



153
154
155
156
157
158
159
160
# File 'lib/bible270/plan.rb', line 153

def expand_readings(chapters, parts)
  chapters.each_with_index.flat_map do |(book, chapter), idx|
    n = parts[idx]
    next [[book, chapter]] if n == 1

    boundaries(book, chapter, n).map { |from, to| [book, chapter, from, to] }
  end
end

.file_breaks(path) ⇒ Object

Cached against mtime and size: an edit is picked up, an unchanged file is not re-parsed on every request.



285
286
287
288
289
290
291
292
# File 'lib/bible270/plan.rb', line 285

def file_breaks(path)
  stat = File.stat(path)
  stamp = [path, stat.mtime.to_f, stat.size]
  return @file_breaks if @file_breaks_stamp == stamp

  @file_breaks_stamp = stamp
  @file_breaks = parse_breaks_file(path)
end

.flatten(track) ⇒ Object

---- shared helpers ---------------------------------------------------



60
61
62
# File 'lib/bible270/plan.rb', line 60

def flatten(track)
  track.flat_map { |book, chapters| (1..chapters).map { |c| [book, c] } }
end

.format_reference(chapters) ⇒ Object

Collapse consecutive whole chapters in the same book: "Genesis 1–3, Exodus 1"



69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/bible270/plan.rb', line 69

def format_reference(chapters)
  return nil if chapters.empty?

  parts = []
  i = 0
  while i < chapters.size
    book = chapters[i][0]
    start = chapters[i][1]
    j = i
    while j + 1 < chapters.size &&
          chapters[j + 1][0] == book &&
          chapters[j + 1][1] == chapters[j][1] + 1
      j += 1
    end
    last = chapters[j][1]
    parts << (start == last ? "#{book} #{start}" : "#{book} #{start}\u2013#{last}")
    i = j + 1
  end
  parts.join(', ')
end

.format_segment(segment) ⇒ Object

"Luke 2" for a whole chapter, "Luke 1:1\u201340" for part of one.



216
217
218
219
220
221
# File 'lib/bible270/plan.rb', line 216

def format_segment(segment)
  book, chapter, from, to = segment
  return "#{book} #{chapter}" if from.nil?

  from == to ? "#{book} #{chapter}:#{from}" : "#{book} #{chapter}:#{from}\u2013#{to}"
end

.group_by_chapter(readings) ⇒ Object



506
507
508
# File 'lib/bible270/plan.rb', line 506

def group_by_chapter(readings)
  readings.chunk_while { |a, b| a[0] == b[0] && a[1] == b[1] }.to_a
end

.memo(key) ⇒ Object

---- memoisation ------------------------------------------------------

Everything derived from the plan is cached together so that reset! (used by the tests, and by anything that changes CHAPTER_BREAKS at runtime) drops it all at once rather than leaving a half-rebuilt plan.



235
236
237
238
239
240
241
242
243
244
245
# File 'lib/bible270/plan.rb', line 235

def memo(key)
  fingerprint = configured_breaks
  if @cache_fingerprint != fingerprint
    @cache = {}
    @cache_fingerprint = fingerprint
  end
  @cache ||= {}
  return @cache[key] if @cache.key?(key)

  @cache[key] = yield
end

.normalize_breaks(hash) ⇒ Object

Keys may be ['Psalm', 18] or 'Psalm 18'; a nil value means "keep whole".



304
305
306
# File 'lib/bible270/plan.rb', line 304

def normalize_breaks(hash)
  hash.to_h { |key, value| [break_key(key), Array(value).map(&:to_i)] }
end

.normalize_track(value) ⇒ Object

Blank means "the whole day", which is stored as NULL. Select fields submit an empty string rather than nil, so this has to be normalised before validation or the inclusion check rejects it.



566
567
568
569
570
571
# File 'lib/bible270/plan.rb', line 566

def normalize_track(value)
  track = value.to_s.strip
  return nil if track.empty?

  track
end

.nt_chaptersObject



344
# File 'lib/bible270/plan.rb', line 344

def nt_chapters = memo(:nt_chapters) { flatten(NT) }

.nt_content_daysObject

Every day now carries a New Testament reading.



363
# File 'lib/bible270/plan.rb', line 363

def nt_content_days = memo(:nt_content_days) { nt_plan.count { |r| !r.nil? } }

.nt_partsObject



346
347
348
# File 'lib/bible270/plan.rb', line 346

def nt_parts
  memo(:nt_parts) { divide_to_fill(nt_chapters.map { |b, c| verses_for(b, c) }, DAYS, pinned_from_breaks(nt_chapters)) }
end

.nt_planObject



354
355
356
# File 'lib/bible270/plan.rb', line 354

def nt_plan
  memo(:nt_plan) { nt_readings.map { |seg| format_segment(seg) } }
end

.nt_readingsObject



350
351
352
# File 'lib/bible270/plan.rb', line 350

def nt_readings
  memo(:nt_readings) { expand_readings(nt_chapters, nt_parts) }
end

.nt_rest_daysObject



365
# File 'lib/bible270/plan.rb', line 365

def nt_rest_days = []

.nt_verse_loadsObject



358
359
360
# File 'lib/bible270/plan.rb', line 358

def nt_verse_loads
  memo(:nt_verse_loads) { nt_readings.map { |seg| segment_length(seg) } }
end

.ot_groupsObject

---- Old Testament ----------------------------------------------------



319
320
321
322
323
324
325
# File 'lib/bible270/plan.rb', line 319

def ot_groups
  memo(:ot_groups) do
    chapters = flatten(OT)
    weights  = chapters.map { |b, c| verses_for(b, c) }
    balance_by_weight(chapters, weights, DAYS)
  end
end

.ot_planObject



327
328
329
# File 'lib/bible270/plan.rb', line 327

def ot_plan
  memo(:ot_plan) { ot_groups.map { |g| format_reference(g) } }
end

.ot_verse_loadsObject



331
332
333
# File 'lib/bible270/plan.rb', line 331

def ot_verse_loads
  memo(:ot_verse_loads) { ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } } }
end

.parse_breaks_file(path) ⇒ Object



294
295
296
297
298
299
300
301
# File 'lib/bible270/plan.rb', line 294

def parse_breaks_file(path)
  raw = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
  raise ArgumentError, "#{path} must map \"Book Chapter\" to a list of verses" unless raw.is_a?(Hash)

  normalize_breaks(raw)
rescue Psych::SyntaxError => e
  raise ArgumentError, "#{path} is not valid YAML: #{e.message}"
end

.pinned_from_breaks(chapters) ⇒ Object

=> reading count for chapters whose breaks are set explicitly.



204
205
206
207
208
209
# File 'lib/bible270/plan.rb', line 204

def pinned_from_breaks(chapters)
  chapters.each_with_index.filter_map do |(book, chapter), idx|
    points = configured_breaks[[book, chapter]]
    [idx, points.size + 1] if points
  end.to_h
end

.pp_chaptersObject



471
# File 'lib/bible270/plan.rb', line 471

def pp_chapters = memo(:pp_chapters) { psalm_chapters + proverbs_chapters }

.pp_partsObject

Psalms and Proverbs are divided together against a single budget of DAYS readings, so the two books balance against each other by verse load rather than each being squeezed into a fixed number of days.



476
477
478
479
480
481
482
# File 'lib/bible270/plan.rb', line 476

def pp_parts
  memo(:pp_parts) do
    chapters = pp_chapters
    weights  = chapters.map { |b, c| verses_for(b, c) }
    divide_to_fill(weights, DAYS, pp_pinned_parts(chapters, weights))
  end
end

.pp_pinned_parts(chapters, weights) ⇒ Object



484
485
486
487
488
489
490
491
492
493
494
# File 'lib/bible270/plan.rb', line 484

def pp_pinned_parts(chapters, weights)
  pinned = {}
  chapters.each_with_index do |(book, chapter), idx|
    next unless book == 'Psalm'

    pinned[idx] = 1 if weights[idx] <= PSALM_WHOLE_MAX_VERSES
    pinned[idx] = weights[idx] / PSALM_119_SECTION_SIZE if chapter == 119
  end
  # An explicit entry in CHAPTER_BREAKS always wins.
  pinned.merge(pinned_from_breaks(chapters))
end

.pp_planObject



533
# File 'lib/bible270/plan.rb', line 533

def pp_plan        = memo(:pp_plan)        { pp_readings.map { |seg| format_segment(seg) } }

.pp_readingsObject

Interleave the two books a whole chapter at a time, spacing the Proverbs chapters evenly through the Psalms. Because a group is emitted in one go, nothing can land between the parts of a divided chapter.



513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
# File 'lib/bible270/plan.rb', line 513

def pp_readings
  memo(:pp_readings) do
    psalms   = psalm_groups
    proverbs = proverbs_groups
    placed   = 0
    days     = []

    psalms.each_with_index do |group, i|
      days.concat(group)
      due = ((i + 1) * proverbs.size) / psalms.size
      while placed < due
        days.concat(proverbs[placed])
        placed += 1
      end
    end
    days.concat(proverbs[placed..].to_a.flatten(1)) if placed < proverbs.size
    days
  end
end

.pp_verse_loadsObject



534
# File 'lib/bible270/plan.rb', line 534

def pp_verse_loads = memo(:pp_verse_loads) { pp_readings.map { |seg| segment_length(seg) } }

.present_tracks(day) ⇒ Object



555
556
557
# File 'lib/bible270/plan.rb', line 555

def present_tracks(day)
  readings_for(day).select { |_, ref| ref }.keys
end

.proverbs_chaptersObject



467
468
469
# File 'lib/bible270/plan.rb', line 467

def proverbs_chapters
  memo(:proverbs_chapters) { (1..Versification.chapter_count('Proverbs')).map { |c| ['Proverbs', c] } }
end

.proverbs_daysObject

1-indexed days carrying a Proverbs reading.



537
538
539
# File 'lib/bible270/plan.rb', line 537

def proverbs_days
  memo(:proverbs_days) { (1..DAYS).select { |d| pp_readings[d - 1][0] == 'Proverbs' } }
end

.proverbs_groupsObject



504
# File 'lib/bible270/plan.rb', line 504

def proverbs_groups = memo(:proverbs_groups) { group_by_chapter(proverbs_readings) }

.proverbs_partsObject



497
# File 'lib/bible270/plan.rb', line 497

def proverbs_parts = memo(:proverbs_parts) { pp_parts.last(proverbs_chapters.size) }

.proverbs_readingsObject



500
# File 'lib/bible270/plan.rb', line 500

def proverbs_readings = memo(:proverbs_readings) { expand_readings(proverbs_chapters, proverbs_parts) }

.psalm_chaptersObject



463
464
465
# File 'lib/bible270/plan.rb', line 463

def psalm_chapters
  memo(:psalm_chapters) { (1..Versification.chapter_count('Psalm')).map { |c| ['Psalm', c] } }
end

.psalm_groupsObject

Readings grouped by chapter, so a divided chapter stays together.



503
# File 'lib/bible270/plan.rb', line 503

def psalm_groups    = memo(:psalm_groups)    { group_by_chapter(psalm_readings) }

.psalm_partsObject



496
# File 'lib/bible270/plan.rb', line 496

def psalm_parts    = memo(:psalm_parts)    { pp_parts.first(psalm_chapters.size) }

.psalm_readingsObject



499
# File 'lib/bible270/plan.rb', line 499

def psalm_readings    = memo(:psalm_readings)    { expand_readings(psalm_chapters, psalm_parts) }

.readings_for(day) ⇒ Object

---- public API -------------------------------------------------------



551
552
553
# File 'lib/bible270/plan.rb', line 551

def readings_for(day)
  { 'ot' => ot_plan[day - 1], 'nt' => nt_plan[day - 1], 'pp' => pp_plan[day - 1] }
end

.required_track_count(day) ⇒ Object



559
# File 'lib/bible270/plan.rb', line 559

def required_track_count(day) = present_tracks(day).size

.reset!Object



247
248
249
250
251
# File 'lib/bible270/plan.rb', line 247

def reset!
  @cache = {}
  @cache_fingerprint = nil
  true
end

.segment_length(segment) ⇒ Object



211
212
213
# File 'lib/bible270/plan.rb', line 211

def segment_length(segment)
  segment.size == 2 ? verses_for(segment[0], segment[1]) : (segment[3] - segment[2] + 1)
end

.to_date(value) ⇒ Object

Coerce whatever we were handed into a Date (or nil).



581
582
583
584
585
586
587
588
589
590
591
592
593
# File 'lib/bible270/plan.rb', line 581

def to_date(value)
  case value
  when nil then nil
  when ::Date then value
  when ::Time then value.to_date
  when ::String then begin
    ::Date.parse(value)
  rescue StandardError
    nil
  end
  else value.respond_to?(:to_date) ? value.to_date : nil
  end
end

.totalsObject



630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
# File 'lib/bible270/plan.rb', line 630

def totals
  {
    ot: flatten(OT).size,                  # 748 chapters
    nt: flatten(NT).size,                  # 260 chapters, read once
    nt_passes: NT_PASSES,
    nt_readings: nt_readings.size,          # 270 - one every day
    nt_divided: divided_nt_chapters.size,   # 10 long chapters halved
    psalms: Versification.chapter_count('Psalm'),        # 150
    psalm_readings: psalm_readings.size,                 # 208
    proverbs: Versification.chapter_count('Proverbs'),   # 31
    proverbs_readings: proverbs_readings.size,
    proverbs_divided: divided_proverbs.size,
    psalms_whole_max_verses: PSALM_WHOLE_MAX_VERSES,
    pp: flatten(PP).size, # 181 chapters
    pp_verses: flatten(PP).sum { |b, c| verses_for(b, c) },
    ot_verses: flatten(OT).sum { |b, c| verses_for(b, c) },
    nt_verses: flatten(NT).sum { |b, c| verses_for(b, c) },
    days: DAYS
  }
end

.valid_day?(day) ⇒ Boolean

Returns:

  • (Boolean)


561
# File 'lib/bible270/plan.rb', line 561

def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS

.valid_track?(value) ⇒ Boolean

Returns:

  • (Boolean)


573
574
575
576
# File 'lib/bible270/plan.rb', line 573

def valid_track?(value)
  track = normalize_track(value)
  track.nil? || TRACKS.key?(track)
end

.verses_for(book, chapter) ⇒ Object



64
65
66
# File 'lib/bible270/plan.rb', line 64

def verses_for(book, chapter)
  Versification.verses(book, chapter)
end