bible270 0.6.2

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 (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +181 -0
  3. data/MIT-LICENSE +18 -0
  4. data/README.md +373 -0
  5. data/Rakefile +11 -0
  6. data/app/controllers/bible270/application_controller.rb +48 -0
  7. data/app/controllers/bible270/checkoffs_controller.rb +32 -0
  8. data/app/controllers/bible270/comments_controller.rb +46 -0
  9. data/app/controllers/bible270/days_controller.rb +42 -0
  10. data/app/controllers/bible270/readers_controller.rb +46 -0
  11. data/app/controllers/bible270/sessions_controller.rb +126 -0
  12. data/app/helpers/bible270/plan_helper.rb +45 -0
  13. data/app/mailers/bible270/application_mailer.rb +7 -0
  14. data/app/mailers/bible270/sign_in_mailer.rb +14 -0
  15. data/app/models/bible270/application_record.rb +6 -0
  16. data/app/models/bible270/checkoff.rb +23 -0
  17. data/app/models/bible270/comment.rb +16 -0
  18. data/app/models/bible270/reader.rb +211 -0
  19. data/app/models/bible270/sign_in_token.rb +74 -0
  20. data/app/views/bible270/checkoffs/toggle.turbo_stream.erb +4 -0
  21. data/app/views/bible270/comments/_comment.html.erb +15 -0
  22. data/app/views/bible270/comments/_form.html.erb +13 -0
  23. data/app/views/bible270/comments/create.turbo_stream.erb +7 -0
  24. data/app/views/bible270/comments/destroy.turbo_stream.erb +1 -0
  25. data/app/views/bible270/days/_reading.html.erb +25 -0
  26. data/app/views/bible270/days/_start_date.html.erb +43 -0
  27. data/app/views/bible270/days/index.html.erb +72 -0
  28. data/app/views/bible270/days/show.html.erb +50 -0
  29. data/app/views/bible270/readers/index.html.erb +24 -0
  30. data/app/views/bible270/readers/show.html.erb +41 -0
  31. data/app/views/bible270/sessions/new.html.erb +52 -0
  32. data/app/views/bible270/shared/_header.html.erb +20 -0
  33. data/app/views/bible270/shared/_styles.html.erb +147 -0
  34. data/app/views/bible270/sign_in_mailer/magic_link.html.erb +20 -0
  35. data/app/views/bible270/sign_in_mailer/magic_link.text.erb +7 -0
  36. data/app/views/layouts/bible270/application.html.erb +28 -0
  37. data/config/routes.rb +30 -0
  38. data/db/migrate/20260101000001_create_bible270_readers.rb +17 -0
  39. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +15 -0
  40. data/db/migrate/20260101000003_create_bible270_comments.rb +14 -0
  41. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +18 -0
  42. data/lib/bible270/configuration.rb +177 -0
  43. data/lib/bible270/email_sign_in.rb +68 -0
  44. data/lib/bible270/engine.rb +21 -0
  45. data/lib/bible270/plan.rb +352 -0
  46. data/lib/bible270/versification.rb +83 -0
  47. data/lib/bible270/version.rb +4 -0
  48. data/lib/bible270.rb +9 -0
  49. data/lib/generators/bible270/install/install_generator.rb +66 -0
  50. data/lib/generators/bible270/install/templates/bible270.rb.tt +41 -0
  51. data/lib/generators/bible270/install/templates/omniauth.rb.tt +25 -0
  52. metadata +158 -0
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ class CreateBible270Comments < ActiveRecord::Migration[7.0]
3
+ def change
4
+ create_table :bible270_comments do |t|
5
+ t.references :reader, null: false, index: true,
6
+ foreign_key: { to_table: :bible270_readers }
7
+ t.integer :day, null: false
8
+ t.string :track
9
+ t.text :body, null: false
10
+ t.timestamps
11
+ end
12
+ add_index :bible270_comments, %i[day created_at]
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ class CreateBible270SignInTokens < ActiveRecord::Migration[7.0]
3
+ def change
4
+ create_table :bible270_sign_in_tokens do |t|
5
+ t.string :email, null: false
6
+ t.string :token_digest, null: false
7
+ t.string :display_name
8
+ t.datetime :expires_at, null: false
9
+ t.datetime :consumed_at
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :bible270_sign_in_tokens, :token_digest, unique: true,
14
+ name: "idx_b270_signin_tokens_digest"
15
+ add_index :bible270_sign_in_tokens, %i[email created_at],
16
+ name: "idx_b270_signin_tokens_email"
17
+ end
18
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ # `uri` is a default gem on every supported Ruby. We deliberately avoid `cgi`
4
+ # here: the CGI library was removed from Ruby's default gems in Ruby 4.0 (only
5
+ # cgi/escape remains), so requiring it emits a deprecation warning there.
6
+ # URI.encode_www_form_component produces byte-identical output to CGI.escape.
7
+ require "uri"
8
+ require "bible270/plan"
9
+
10
+ module Bible270
11
+ class Configuration
12
+ # Controller the engine's controllers inherit from. Set this to your host's
13
+ # "::ApplicationController" if you want to share layout, auth and helpers.
14
+ attr_accessor :parent_controller
15
+
16
+ # Layout used to render engine pages.
17
+ attr_accessor :layout
18
+
19
+ # A callable (lambda) that receives the current controller and returns a
20
+ # Bible270::Reader (or nil). Use this to bridge your host's users:
21
+ #
22
+ # config.current_reader_resolver = ->(c) {
23
+ # u = c.send(:current_user)
24
+ # u && Bible270::Reader.for_owner(u, display_name: u.name, email: u.email)
25
+ # }
26
+ #
27
+ # Leave nil to use the built-in session-based sign-in (OmniAuth).
28
+ attr_accessor :current_reader_resolver
29
+
30
+ # Where to send the reader after a successful sign-in / sign-out.
31
+ attr_accessor :after_sign_in_path
32
+ attr_accessor :after_sign_out_path
33
+
34
+ # ---- OmniAuth (built-in sign-in) --------------------------------------
35
+
36
+ # Providers to offer on the sign-in screen. Accepts symbols, or pairs of
37
+ # [provider, label] when you want to override the displayed name:
38
+ #
39
+ # config.omniauth_providers = [:github]
40
+ # config.omniauth_providers = [:github, [:google_oauth2, "Google"]]
41
+ #
42
+ # The host application supplies the matching strategy gems and registers
43
+ # them in the OmniAuth builder (see the README, or run the install generator).
44
+ attr_reader :omniauth_providers
45
+
46
+ # OmniAuth's path_prefix, if you need to override it. Normally leave this
47
+ # nil: the engine derives "<mount point>/auth" from the request, which
48
+ # matches a builder configured with path_prefix: "<mount point>/auth".
49
+ attr_accessor :omniauth_path_prefix
50
+
51
+ # Human labels for providers we know; anything else is titleised.
52
+ PROVIDER_LABELS = {
53
+ github: "GitHub", gitlab: "GitLab", google_oauth2: "Google", google: "Google",
54
+ facebook: "Facebook", twitter: "Twitter", apple: "Apple", discord: "Discord",
55
+ microsoft_graph: "Microsoft", azure_activedirectory_v2: "Microsoft",
56
+ openid_connect: "OpenID Connect", saml: "SSO"
57
+ }.freeze
58
+
59
+ def omniauth_providers=(list)
60
+ @omniauth_providers = Array(list).map do |entry|
61
+ key, label = entry.is_a?(Array) ? entry : [entry, nil]
62
+ key = key.to_sym
63
+ [key, (label || PROVIDER_LABELS[key] || key.to_s.tr("_", " ").split.map(&:capitalize).join(" "))]
64
+ end
65
+ end
66
+
67
+ def omniauth_provider_keys = omniauth_providers.map(&:first)
68
+
69
+ # ---- Email sign-in (passwordless magic link) --------------------------
70
+
71
+ # Offer sign-in by email link, so readers who don't have (or don't want to
72
+ # use) a GitHub/Google/etc. account can still take part. Requires the host
73
+ # app to have Action Mailer delivery configured.
74
+ attr_accessor :email_sign_in
75
+
76
+ # From: address for the sign-in email.
77
+ attr_accessor :mailer_from
78
+
79
+ # How long a magic link stays valid (seconds).
80
+ attr_accessor :email_sign_in_ttl
81
+
82
+ # Simple abuse guard: at most N links per address per window (seconds).
83
+ attr_accessor :email_sign_in_window
84
+ attr_accessor :email_sign_in_max_per_window
85
+
86
+ # Whether a reader may type the display name shown beside their reflections
87
+ # when signing in by email (otherwise it's derived from the address).
88
+ attr_accessor :email_sign_in_ask_name
89
+
90
+ # Send the sign-in email via Active Job (deliver_later) instead of inline.
91
+ # Leave false unless you have a queue backend configured.
92
+ attr_accessor :email_sign_in_deliver_later
93
+
94
+ def email_sign_in? = !!@email_sign_in
95
+
96
+ # Any way at all for a new person to sign in?
97
+ def any_sign_in_method?
98
+ email_sign_in? || omniauth_providers.any?
99
+ end
100
+
101
+ def single_provider? = omniauth_providers.size == 1
102
+
103
+ def label_for_provider(key)
104
+ found = omniauth_providers.find { |k, _| k.to_s == key.to_s }
105
+ return found.last if found
106
+
107
+ PROVIDER_LABELS[key.to_sym] || key.to_s.tr("_", " ").split.map(&:capitalize).join(" ")
108
+ end
109
+
110
+ # Default Bible translation and a builder for external passage links.
111
+ attr_accessor :bible_version
112
+ attr_accessor :passage_url_builder
113
+
114
+ # Public labels.
115
+ attr_accessor :app_name
116
+ attr_accessor :tagline
117
+
118
+ # Optional community-wide start date for the plan. Set this when everyone
119
+ # reads together as a cohort (e.g. a church starting on a set Sunday):
120
+ #
121
+ # config.start_date = Date.new(2026, 9, 6) # or the string "2026-09-06"
122
+ #
123
+ # Leave nil for an undated plan, where each reader's own start date applies
124
+ # (defaulting to the day they first check something off).
125
+ attr_reader :start_date
126
+
127
+ # Whether an individual reader may set or change their own start date. When
128
+ # false, everyone is pinned to config.start_date.
129
+ attr_accessor :allow_reader_start_date
130
+
131
+ # Only signed-in readers may check off and comment (viewing is always open).
132
+ attr_accessor :require_sign_in_to_participate
133
+
134
+ def initialize
135
+ @parent_controller = "ActionController::Base"
136
+ @layout = "bible270/application"
137
+ @current_reader_resolver = nil
138
+ @after_sign_in_path = nil
139
+ @after_sign_out_path = nil
140
+ @bible_version = "ESV"
141
+ @passage_url_builder = lambda do |reference, version|
142
+ search = URI.encode_www_form_component(reference)
143
+ version = URI.encode_www_form_component(version)
144
+ "https://www.biblegateway.com/passage/?search=#{search}&version=#{version}"
145
+ end
146
+ @app_name = "Daily Bread"
147
+ @tagline = "A 270-day journey through Scripture"
148
+ @require_sign_in_to_participate = true
149
+ @start_date = nil
150
+ @allow_reader_start_date = true
151
+ self.omniauth_providers = [:github]
152
+ @omniauth_path_prefix = nil
153
+ @email_sign_in = true
154
+ @mailer_from = "no-reply@example.com"
155
+ @email_sign_in_ttl = 20 * 60 # 20 minutes
156
+ @email_sign_in_window = 15 * 60 # 15 minutes
157
+ @email_sign_in_max_per_window = 5
158
+ @email_sign_in_ask_name = true
159
+ @email_sign_in_deliver_later = false
160
+ end
161
+
162
+ # Accepts a Date, Time, or "YYYY-MM-DD" string.
163
+ def start_date=(value)
164
+ @start_date = Plan.to_date(value)
165
+ end
166
+ end
167
+
168
+ class << self
169
+ def config
170
+ @config ||= Configuration.new
171
+ end
172
+
173
+ def configure
174
+ yield config
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "digest"
5
+
6
+ module Bible270
7
+ # Pure helpers for passwordless ("magic link") email sign-in. Kept free of
8
+ # Rails so the security-relevant bits can be unit tested on their own.
9
+ #
10
+ # We never store a password and never store the raw token — only a SHA-256
11
+ # digest of it, so a leaked database can't be used to sign in.
12
+ module EmailSignIn
13
+ # Deliberately permissive: real validation is "did the link get clicked".
14
+ EMAIL_RE = /\A[^@\s,;:<>"]+@[^@\s,;:<>"]+\.[A-Za-z]{2,}\z/
15
+
16
+ module_function
17
+
18
+ # Trim, strip a mailto:, and downcase. Returns nil for anything unusable.
19
+ def normalize_email(value)
20
+ email = value.to_s.strip
21
+ email = email[7..] if email.downcase.start_with?("mailto:")
22
+ email = email.strip.downcase
23
+ return nil if email.empty?
24
+ return nil unless email.match?(EMAIL_RE)
25
+ return nil if email.length > 254 # RFC 5321 practical maximum
26
+
27
+ email
28
+ end
29
+
30
+ def valid_email?(value)
31
+ !normalize_email(value).nil?
32
+ end
33
+
34
+ # A URL-safe, high-entropy single-use token (256 bits).
35
+ def generate_token
36
+ SecureRandom.urlsafe_base64(32)
37
+ end
38
+
39
+ # What we actually persist. Hex digest keeps it index-friendly.
40
+ def digest_token(token)
41
+ return nil if token.to_s.empty?
42
+
43
+ Digest::SHA256.hexdigest(token.to_s)
44
+ end
45
+
46
+ # Constant-time comparison, for callers that need to compare digests
47
+ # directly rather than looking them up.
48
+ def secure_compare(a, b)
49
+ a = a.to_s
50
+ b = b.to_s
51
+ return false unless a.bytesize == b.bytesize
52
+
53
+ res = 0
54
+ a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
55
+ res.zero?
56
+ end
57
+
58
+ # The part of an address we can use as a default display name:
59
+ # "mary.anne.smith@example.com" => "Mary Anne Smith"
60
+ def display_name_from(email)
61
+ local = normalize_email(email)&.split("@")&.first
62
+ return nil if local.nil? || local.empty?
63
+
64
+ # NB: the hyphen must come last or tr reads "_-+" as a range.
65
+ local.tr("._+-", " ").split.map { |w| w[0].upcase + (w[1..] || "") }.join(" ")
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace Bible270
5
+
6
+ config.generators do |g|
7
+ g.test_framework :minitest
8
+ g.orm :active_record
9
+ end
10
+
11
+ # Make the engine's migrations available to the host via
12
+ # bin/rails bible270:install:migrations
13
+ initializer "bible270.append_migrations" do |app|
14
+ unless app.root.to_s.match?(root.to_s)
15
+ config.paths["db/migrate"].expanded.each do |path|
16
+ app.config.paths["db/migrate"] << path
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "bible270/versification"
5
+
6
+ module Bible270
7
+ # Pure-Ruby, deterministic reading-plan generator. No database required.
8
+ #
9
+ # Daily portions are balanced by VERSE COUNT (a canonical, translation-neutral
10
+ # proxy for how much text there is), not by chapter count — so a day landing on
11
+ # Psalm 119 (176 verses) is not treated as equal to a day on Psalm 117 (2 verses).
12
+ #
13
+ # * Old Testament – read once, cover to cover; WHOLE chapters grouped so each
14
+ # day is ~equal in verses (~73/day)
15
+ # * New Testament – read through TWICE (~2 chapters/day, every day)
16
+ # * Psalms/Proverbs – a WHOLE-CHAPTER daily companion. With only 181 chapters
17
+ # over 270 days it cycles ~1.9x. Chapters stay intact; very
18
+ # short ones merge with a neighbour, and the one excessively
19
+ # long chapter (Psalm 119) is divided into two readings.
20
+ #
21
+ # Genesis→Malachi finishes on day 270, as does the second pass through the NT.
22
+ module Plan
23
+ DAYS = 270
24
+
25
+ OT = [
26
+ ["Genesis", 50], ["Exodus", 40], ["Leviticus", 27], ["Numbers", 36],
27
+ ["Deuteronomy", 34], ["Joshua", 24], ["Judges", 21], ["Ruth", 4],
28
+ ["1 Samuel", 31], ["2 Samuel", 24], ["1 Kings", 22], ["2 Kings", 25],
29
+ ["1 Chronicles", 29], ["2 Chronicles", 36], ["Ezra", 10], ["Nehemiah", 13],
30
+ ["Esther", 10], ["Job", 42], ["Ecclesiastes", 12], ["Song of Solomon", 8],
31
+ ["Isaiah", 66], ["Jeremiah", 52], ["Lamentations", 5], ["Ezekiel", 48],
32
+ ["Daniel", 12], ["Hosea", 14], ["Joel", 3], ["Amos", 9], ["Obadiah", 1],
33
+ ["Jonah", 4], ["Micah", 7], ["Nahum", 3], ["Habakkuk", 3], ["Zephaniah", 3],
34
+ ["Haggai", 2], ["Zechariah", 14], ["Malachi", 4]
35
+ ].freeze
36
+
37
+ NT = [
38
+ ["Matthew", 28], ["Mark", 16], ["Luke", 24], ["John", 21], ["Acts", 28],
39
+ ["Romans", 16], ["1 Corinthians", 16], ["2 Corinthians", 13], ["Galatians", 6],
40
+ ["Ephesians", 6], ["Philippians", 4], ["Colossians", 4], ["1 Thessalonians", 5],
41
+ ["2 Thessalonians", 3], ["1 Timothy", 6], ["2 Timothy", 4], ["Titus", 3],
42
+ ["Philemon", 1], ["Hebrews", 13], ["James", 5], ["1 Peter", 5], ["2 Peter", 3],
43
+ ["1 John", 5], ["2 John", 1], ["3 John", 1], ["Jude", 1], ["Revelation", 22]
44
+ ].freeze
45
+
46
+ PP = [["Psalm", 150], ["Proverbs", 31]].freeze
47
+
48
+ TRACKS = {
49
+ "ot" => { key: "ot", label: "Old Testament", color: "#2f6a67" },
50
+ "nt" => { key: "nt", label: "New Testament", color: "#8a2f3b" },
51
+ "pp" => { key: "pp", label: "Psalms & Proverbs", color: "#a5812c" }
52
+ }.freeze
53
+
54
+ module_function
55
+
56
+ # ---- shared helpers ---------------------------------------------------
57
+
58
+ def flatten(track)
59
+ track.flat_map { |book, chapters| (1..chapters).map { |c| [book, c] } }
60
+ end
61
+
62
+ def verses_for(book, chapter)
63
+ Versification.verses(book, chapter)
64
+ end
65
+
66
+ # Collapse consecutive whole chapters in the same book: "Genesis 1–3, Exodus 1"
67
+ def format_reference(chapters)
68
+ return nil if chapters.empty?
69
+
70
+ parts = []
71
+ i = 0
72
+ while i < chapters.size
73
+ book = chapters[i][0]
74
+ start = chapters[i][1]
75
+ j = i
76
+ while j + 1 < chapters.size &&
77
+ chapters[j + 1][0] == book &&
78
+ chapters[j + 1][1] == chapters[j][1] + 1
79
+ j += 1
80
+ end
81
+ last = chapters[j][1]
82
+ parts << (start == last ? "#{book} #{start}" : "#{book} #{start}\u2013#{last}")
83
+ i = j + 1
84
+ end
85
+ parts.join(", ")
86
+ end
87
+
88
+ # Group ordered items (each [book, chapter]) into `bins` contiguous groups so
89
+ # that the total weight per group is as even as possible, while guaranteeing
90
+ # every group gets at least one item.
91
+ def balance_by_weight(items, weights, bins)
92
+ n = items.size
93
+ total = weights.sum.to_f
94
+ groups = Array.new(bins) { [] }
95
+ idx = 0
96
+ running = 0.0
97
+ (0...bins).each do |d|
98
+ target = (d + 1) * total / bins
99
+ # every bin takes at least one item
100
+ groups[d] << items[idx]
101
+ running += weights[idx]
102
+ idx += 1
103
+ # then add more only while it brings this bin's cumulative closer to the
104
+ # target boundary (round-to-nearest), keeping one item in reserve per
105
+ # remaining bin
106
+ while idx < n && (n - idx) > (bins - d - 1)
107
+ w = weights[idx]
108
+ break if running >= target
109
+ # stop if overshooting the target would be worse than stopping short
110
+ break if (running + w - target) > (target - running)
111
+
112
+ groups[d] << items[idx]
113
+ running += w
114
+ idx += 1
115
+ end
116
+ end
117
+ groups[bins - 1].concat(items[idx...n]) if idx < n
118
+ groups
119
+ end
120
+
121
+ # ---- Old Testament ----------------------------------------------------
122
+
123
+ def ot_groups
124
+ @ot_groups ||= begin
125
+ chapters = flatten(OT)
126
+ weights = chapters.map { |b, c| verses_for(b, c) }
127
+ balance_by_weight(chapters, weights, DAYS)
128
+ end
129
+ end
130
+
131
+ def ot_plan
132
+ @ot_plan ||= ot_groups.map { |g| format_reference(g) }
133
+ end
134
+
135
+ def ot_verse_loads
136
+ @ot_verse_loads ||= ot_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
137
+ end
138
+
139
+ # ---- New Testament (read through TWICE) -------------------------------
140
+ #
141
+ # 260 chapters x 2 passes = 520 chapter-readings over 270 days, so ~2 chapters
142
+ # a day and no rest days. Each pass gets its own half of the plan (135 days),
143
+ # so Revelation 22 lands on day 135 and again on day 270. Within a pass, whole
144
+ # chapters are grouped so each day is ~equal in verses.
145
+
146
+ NT_PASSES = 2
147
+
148
+ def nt_days_per_pass = DAYS / NT_PASSES
149
+
150
+ def nt_chapters
151
+ @nt_chapters ||= flatten(NT) * NT_PASSES
152
+ end
153
+
154
+ def nt_groups
155
+ @nt_groups ||= begin
156
+ chapters = flatten(NT)
157
+ weights = chapters.map { |b, c| verses_for(b, c) }
158
+ one_pass = balance_by_weight(chapters, weights, nt_days_per_pass)
159
+ one_pass * NT_PASSES
160
+ end
161
+ end
162
+
163
+ def nt_plan
164
+ @nt_plan ||= nt_groups.map { |g| format_reference(g) }
165
+ end
166
+
167
+ def nt_verse_loads
168
+ @nt_verse_loads ||= nt_groups.map { |g| g.sum { |b, c| verses_for(b, c) } }
169
+ end
170
+
171
+ # Days on which the NT track has content (now every day).
172
+ def nt_content_days
173
+ @nt_content_days ||= nt_plan.count { |r| !r.nil? }
174
+ end
175
+
176
+ # 1-indexed day on which the second pass through the NT begins.
177
+ def nt_second_pass_start_day = nt_days_per_pass + 1
178
+
179
+ # ---- Psalms & Proverbs (whole-chapter companion, cycles ~1.9x) --------
180
+ #
181
+ # Chapters are kept WHOLE. Because there are only 181 Psalms/Proverbs
182
+ # chapters but 270 days, the track reads through roughly twice, cycling.
183
+ # Two adjustments keep daily portions roughly even without chopping text:
184
+ # * very short chapters merge with a neighbour (no trivial 2-verse days)
185
+ # * only excessively long chapters (> PP_LONG_CHAPTER verses) are split
186
+ # into balanced parts — in practice just Psalms 78, 89, and 119.
187
+
188
+ PP_MIN_DAY = 12 # keep merging until a portion reaches at least this many verses
189
+ PP_DAY_TARGET = 22 # soft cap: stop merging once a portion would exceed this
190
+ PP_LONG_CHAPTER = 100 # only an excessively long chapter is split; in the whole
191
+ # Psalter+Proverbs that is Psalm 119 (176v) alone, since
192
+ # the next longest chapter is Psalm 78 at 72 verses
193
+ PP_SPLIT_PARTS = 2 # such a chapter is divided into this many readings
194
+
195
+ # The base cycle of readings (whole chapters, short ones merged, long ones
196
+ # split). Each portion is an array of segments; a segment is either
197
+ # [book, chapter] (whole) or [book, chapter, from, to] (part of a chapter).
198
+ def pp_base_portions
199
+ @pp_base_portions ||= begin
200
+ units = flatten(PP).map { |b, c| [b, c, verses_for(b, c)] }
201
+ portions = []
202
+ buf = []
203
+ buflen = 0
204
+ units.each do |book, ch, len|
205
+ if len > PP_LONG_CHAPTER
206
+ unless buf.empty?
207
+ portions << buf
208
+ buf = []
209
+ buflen = 0
210
+ end
211
+ nparts = PP_SPLIT_PARTS
212
+ base = len / nparts
213
+ extra = len % nparts
214
+ v = 1
215
+ nparts.times do |i|
216
+ size = base + (i < extra ? 1 : 0)
217
+ portions << [[book, ch, v, v + size - 1]]
218
+ v += size
219
+ end
220
+ else
221
+ if !buf.empty? && buflen >= PP_MIN_DAY && buflen + len > PP_DAY_TARGET
222
+ portions << buf
223
+ buf = []
224
+ buflen = 0
225
+ end
226
+ buf << [book, ch]
227
+ buflen += len
228
+ end
229
+ end
230
+ portions << buf unless buf.empty?
231
+ portions
232
+ end
233
+ end
234
+
235
+ def pp_cycle_length = pp_base_portions.size
236
+
237
+ def pp_plan
238
+ @pp_plan ||= (0...DAYS).map { |d| format_pp(pp_base_portions[d % pp_cycle_length]) }
239
+ end
240
+
241
+ def pp_verse_loads
242
+ @pp_verse_loads ||= (0...DAYS).map do |d|
243
+ pp_base_portions[d % pp_cycle_length].sum do |s|
244
+ s.size == 2 ? verses_for(s[0], s[1]) : (s[3] - s[2] + 1)
245
+ end
246
+ end
247
+ end
248
+
249
+ # Format a portion, collapsing consecutive whole chapters ("Psalm 24–25")
250
+ # and rendering split parts as verse ranges ("Psalm 119:1–22").
251
+ def format_pp(portion)
252
+ parts = []
253
+ i = 0
254
+ while i < portion.size
255
+ s = portion[i]
256
+ if s.size == 2 # whole chapter
257
+ book, ch = s
258
+ j = i
259
+ while j + 1 < portion.size && portion[j + 1].size == 2 &&
260
+ portion[j + 1][0] == book && portion[j + 1][1] == portion[j][1] + 1
261
+ j += 1
262
+ end
263
+ last = portion[j][1]
264
+ parts << (ch == last ? "#{book} #{ch}" : "#{book} #{ch}\u2013#{last}")
265
+ i = j + 1
266
+ else # part of a chapter
267
+ book, ch, from, to = s
268
+ parts << (from == to ? "#{book} #{ch}:#{from}" : "#{book} #{ch}:#{from}\u2013#{to}")
269
+ i += 1
270
+ end
271
+ end
272
+ parts.join(", ")
273
+ end
274
+
275
+ # ---- public API -------------------------------------------------------
276
+
277
+ def readings_for(day)
278
+ { "ot" => ot_plan[day - 1], "nt" => nt_plan[day - 1], "pp" => pp_plan[day - 1] }
279
+ end
280
+
281
+ def present_tracks(day)
282
+ readings_for(day).select { |_, ref| ref }.keys
283
+ end
284
+
285
+ def required_track_count(day) = present_tracks(day).size
286
+
287
+ def valid_day?(day) = day.is_a?(Integer) && day >= 1 && day <= DAYS
288
+
289
+ # ---- calendar mapping (all pure functions; nil start_date = undated) ----
290
+
291
+ # Coerce whatever we were handed into a Date (or nil).
292
+ def to_date(value)
293
+ case value
294
+ when nil then nil
295
+ when ::Date then value
296
+ when ::Time then value.to_date
297
+ when ::String then (::Date.parse(value) rescue nil)
298
+ else value.respond_to?(:to_date) ? value.to_date : nil
299
+ end
300
+ end
301
+
302
+ # The calendar date a given plan day falls on.
303
+ def date_for(day, start_date)
304
+ start = to_date(start_date)
305
+ return nil unless start && valid_day?(day)
306
+
307
+ start + (day - 1)
308
+ end
309
+
310
+ # The last day of the plan.
311
+ def end_date_for(start_date)
312
+ date_for(DAYS, start_date)
313
+ end
314
+
315
+ # Which plan day a calendar date corresponds to. By default the result is
316
+ # clamped into 1..DAYS; pass clamp: false to get the raw (possibly out of
317
+ # range) offset, which is how you tell "hasn't started yet" from "day 1".
318
+ def day_for(date, start_date, clamp: true)
319
+ start = to_date(start_date)
320
+ on = to_date(date)
321
+ return nil unless start && on
322
+
323
+ offset = (on - start).to_i + 1
324
+ clamp ? offset.clamp(1, DAYS) : offset
325
+ end
326
+
327
+ def before_start?(date, start_date)
328
+ raw = day_for(date, start_date, clamp: false)
329
+ raw ? raw < 1 : false
330
+ end
331
+
332
+ def after_end?(date, start_date)
333
+ raw = day_for(date, start_date, clamp: false)
334
+ raw ? raw > DAYS : false
335
+ end
336
+
337
+ def totals
338
+ {
339
+ ot: flatten(OT).size, # 748 chapters
340
+ nt: flatten(NT).size, # 260 chapters per pass
341
+ nt_passes: NT_PASSES, # read through twice
342
+ nt_chapters_read: nt_chapters.size, # 520 chapter-readings
343
+ pp: flatten(PP).size, # 181 chapters
344
+ pp_verses: flatten(PP).sum { |b, c| verses_for(b, c) }, # 3376 verses
345
+ pp_cycle: pp_cycle_length, # base portions before cycling
346
+ ot_verses: flatten(OT).sum { |b, c| verses_for(b, c) },
347
+ nt_verses: nt_chapters.sum { |b, c| verses_for(b, c) },
348
+ days: DAYS
349
+ }
350
+ end
351
+ end
352
+ end