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 once and Proverbs TWICE, 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
PROVERBS_PASSES =

---- Psalms & Proverbs -------------------------------------------------

Psalms once and Proverbs TWICE across the 270 days, in one daily track.

Proverbs supplies 31 x 2 = 62 readings (whole chapters), leaving 208 days for the Psalms. Since there are only 150 psalms, longer ones are divided:

* Psalm 119 is pinned to 11 sections of exactly 16 verses (176 = 11 x 16),
which matches its 22 eight-verse acrostic stanzas, two per section.
* The remaining 48 divisions go to whichever psalm currently carries the
heaviest reading, so the longest are split first and no single reading
runs long.

The 62 Proverbs readings are then spaced evenly through the 270 days, which lands Proverbs 31 on day 135 and again on day 270.

2
PSALM_119_SECTION_SIZE =
16

Class Method Summary collapse

Class Method Details

.after_end?(date, start_date) ⇒ Boolean

Returns:

  • (Boolean)


372
373
374
375
# File 'lib/bible270/plan.rb', line 372

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.



92
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
# File 'lib/bible270/plan.rb', line 92

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)


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

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

.date_for(day, start_date) ⇒ Object

The calendar date a given plan day falls on.



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

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".



358
359
360
361
362
363
364
365
# File 'lib/bible270/plan.rb', line 358

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.



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/bible270/plan.rb', line 131

def divide_to_fill(weights, target, pinned = {})
  parts = Array.new(weights.size, 1)
  pinned.each { |i, n| parts[i] = n }
  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.



176
177
178
179
# File 'lib/bible270/plan.rb', line 176

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

.divided_nt_chaptersObject



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

def divided_nt_chapters = @divided_nt_chapters ||= divided(nt_chapters, nt_parts)

.divided_psalmsObject

Psalms that end up divided, as => number of readings.



311
312
313
# File 'lib/bible270/plan.rb', line 311

def divided_psalms
  @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.



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

def end_date_for(start_date)
  date_for(DAYS, start_date)
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.



145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/bible270/plan.rb', line 145

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

    total = verses_for(book, chapter)
    base = total / n
    extra = total % n
    v = 1
    n.times.map do |k|
      size = base + (k < extra ? 1 : 0)
      reading = [book, chapter, v, v + size - 1]
      v += size
      reading
    end
  end
end

.flatten(track) ⇒ Object

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



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

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"



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

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.



168
169
170
171
172
173
# File 'lib/bible270/plan.rb', line 168

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

.nt_chaptersObject



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

def nt_chapters = @nt_chapters ||= flatten(NT)

.nt_content_daysObject

Every day now carries a New Testament reading.



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

def nt_content_days = @nt_content_days ||= nt_plan.count { |r| !r.nil? }

.nt_partsObject



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

def nt_parts
  @nt_parts ||= divide_to_fill(nt_chapters.map { |b, c| verses_for(b, c) }, DAYS)
end

.nt_planObject



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

def nt_plan
  @nt_plan ||= nt_readings.map { |seg| format_segment(seg) }
end

.nt_readingsObject



214
215
216
# File 'lib/bible270/plan.rb', line 214

def nt_readings
  @nt_readings ||= expand_readings(nt_chapters, nt_parts)
end

.nt_rest_daysObject



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

def nt_rest_days = []

.nt_verse_loadsObject



222
223
224
# File 'lib/bible270/plan.rb', line 222

def nt_verse_loads
  @nt_verse_loads ||= nt_readings.map { |seg| segment_length(seg) }
end

.ot_groupsObject

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



183
184
185
186
187
188
189
# File 'lib/bible270/plan.rb', line 183

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

.ot_planObject



191
192
193
# File 'lib/bible270/plan.rb', line 191

def ot_plan
  @ot_plan ||= ot_groups.map { |g| format_reference(g) }
end

.ot_verse_loadsObject



195
196
197
# File 'lib/bible270/plan.rb', line 195

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

.pp_planObject



297
298
299
# File 'lib/bible270/plan.rb', line 297

def pp_plan
  @pp_plan ||= pp_readings.map { |seg| format_segment(seg) }
end

.pp_readingsObject

One reading per day, Psalms and Proverbs interleaved.



289
290
291
292
293
294
295
# File 'lib/bible270/plan.rb', line 289

def pp_readings
  @pp_readings ||= begin
    psalms = psalm_readings.dup
    proverbs = proverbs_readings.dup
    (1..DAYS).map { |day| proverbs_day?(day) ? proverbs.shift : psalms.shift }
  end
end

.pp_verse_loadsObject



301
302
303
# File 'lib/bible270/plan.rb', line 301

def pp_verse_loads
  @pp_verse_loads ||= pp_readings.map { |seg| segment_length(seg) }
end

.present_tracks(day) ⇒ Object



321
322
323
# File 'lib/bible270/plan.rb', line 321

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

.proverbs_day?(day) ⇒ Boolean

True when the given 1-indexed day is one of the evenly spaced Proverbs days.

Returns:

  • (Boolean)


283
284
285
286
# File 'lib/bible270/plan.rb', line 283

def proverbs_day?(day)
  n = proverbs_reading_count
  (day * n / DAYS) > ((day - 1) * n / DAYS)
end

.proverbs_daysObject

1-indexed days carrying a Proverbs reading.



306
307
308
# File 'lib/bible270/plan.rb', line 306

def proverbs_days
  @proverbs_days ||= (1..DAYS).select { |d| proverbs_day?(d) }
end

.proverbs_reading_countObject



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

def proverbs_reading_count = flatten([["Proverbs", 31]]).size * PROVERBS_PASSES

.proverbs_readingsObject

Proverbs 1..31, repeated PROVERBS_PASSES times, always whole chapters.



275
276
277
278
279
280
# File 'lib/bible270/plan.rb', line 275

def proverbs_readings
  @proverbs_readings ||= begin
    chapters = (1..Versification.chapter_count("Proverbs")).map { |c| ["Proverbs", c] }
    chapters * PROVERBS_PASSES
  end
end

.psalm_chaptersObject



256
257
258
# File 'lib/bible270/plan.rb', line 256

def psalm_chapters
  @psalm_chapters ||= (1..Versification.chapter_count("Psalm")).map { |c| ["Psalm", c] }
end

.psalm_partsObject

How many readings each psalm is divided into (index 0 == Psalm 1). Psalm 119 is pinned to 11 sections of PSALM_119_SECTION_SIZE verses.



262
263
264
265
266
267
268
# File 'lib/bible270/plan.rb', line 262

def psalm_parts
  @psalm_parts ||= begin
    weights = psalm_chapters.map { |b, c| verses_for(b, c) }
    i119 = 118
    divide_to_fill(weights, psalm_reading_count, i119 => weights[i119] / PSALM_119_SECTION_SIZE)
  end
end

.psalm_reading_countObject



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

def psalm_reading_count = DAYS - proverbs_reading_count

.psalm_readingsObject



270
271
272
# File 'lib/bible270/plan.rb', line 270

def psalm_readings
  @psalm_readings ||= expand_readings(psalm_chapters, psalm_parts)
end

.readings_for(day) ⇒ Object

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



317
318
319
# File 'lib/bible270/plan.rb', line 317

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



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

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

.segment_length(segment) ⇒ Object



163
164
165
# File 'lib/bible270/plan.rb', line 163

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).



332
333
334
335
336
337
338
339
340
# File 'lib/bible270/plan.rb', line 332

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

.totalsObject



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'lib/bible270/plan.rb', line 377

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_passes: PROVERBS_PASSES,
    proverbs_readings: proverbs_reading_count,           # 62
    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)


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

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

.verses_for(book, chapter) ⇒ Object



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

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