bible270 0.6.3 → 0.10.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -164
  3. data/README.md +287 -272
  4. data/Rakefile +5 -4
  5. data/app/controllers/bible270/admin_controller.rb +119 -0
  6. data/app/controllers/bible270/application_controller.rb +19 -2
  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/readers_controller.rb +5 -4
  11. data/app/controllers/bible270/sessions_controller.rb +60 -26
  12. data/app/helpers/bible270/plan_helper.rb +11 -8
  13. data/app/mailers/bible270/application_mailer.rb +4 -1
  14. data/app/mailers/bible270/sign_in_mailer.rb +1 -0
  15. data/app/models/bible270/application_record.rb +1 -0
  16. data/app/models/bible270/checkoff.rb +4 -3
  17. data/app/models/bible270/comment.rb +26 -5
  18. data/app/models/bible270/reader.rb +78 -14
  19. data/app/models/bible270/sign_in_token.rb +5 -2
  20. data/app/views/bible270/admin/comments.html.erb +44 -0
  21. data/app/views/bible270/admin/index.html.erb +30 -0
  22. data/app/views/bible270/admin/show.html.erb +89 -0
  23. data/app/views/bible270/days/index.html.erb +10 -13
  24. data/app/views/bible270/readers/index.html.erb +2 -2
  25. data/app/views/bible270/readers/show.html.erb +5 -5
  26. data/app/views/bible270/sessions/new.html.erb +9 -3
  27. data/app/views/bible270/shared/_header.html.erb +1 -0
  28. data/app/views/bible270/shared/_styles.html.erb +10 -7
  29. data/app/views/layouts/bible270/application.html.erb +4 -3
  30. data/config/routes.rb +32 -18
  31. data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
  32. data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
  33. data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
  34. data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
  35. data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
  36. data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
  37. data/lib/bible270/configuration.rb +91 -19
  38. data/lib/bible270/email_sign_in.rb +7 -7
  39. data/lib/bible270/engine.rb +1 -0
  40. data/lib/bible270/plan.rb +449 -150
  41. data/lib/bible270/versification.rb +84 -67
  42. data/lib/bible270/version.rb +2 -1
  43. data/lib/bible270.rb +6 -5
  44. data/lib/generators/bible270/install/install_generator.rb +520 -39
  45. data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
  46. data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
  47. metadata +17 -29
data/README.md CHANGED
@@ -1,192 +1,192 @@
1
- # bible270
2
-
3
- A mountable **Rails engine** that adds a social, 270-day Bible reading plan to a
4
- host Rails application such as [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-mexican-sofa).
5
-
6
- Each day presents three readings, sized by **verse count** so daily portions take roughly
7
- the same time to read. Chapters are kept **whole** — the plan never chops a chapter into
8
- verse ranges except for the one excessively long chapter, Psalm 119.
9
-
10
- - **Old Testament** — read once, cover to cover. Whole chapters grouped so each day is
11
- ~equal in verses (avg ~73/day, range 38–116).
12
- - **New Testament** — read **twice**. Each pass gets half the plan (135 days), so
13
- Revelation lands on day 135 and again on day 270. ~2 chapters/day, every day, verse-balanced
14
- within each pass.
15
- - **Psalms & Proverbs** — a whole-chapter companion that runs **twice** over the 270 days
16
- (135 portions x 2). Very short chapters merge with a neighbour so there are no trivial
17
- two-verse days (Psalm 117 reads as "Psalm 116–117"), and **Psalm 119 (176 verses) is the
18
- sole chapter divided** — into exactly two readings, 1–88 and 89–176.
19
-
20
- Genesis→Malachi, the second pass through Revelation, and Proverbs 31 all finish together on
21
- day 270. A typical day runs ~157 verses across the three tracks (range 102–238).
22
-
23
- Readers check off each track, leave reflections (comments) on any day, and can see everyone
24
- else's progress and reflections — similar to the social plans on Bible.com.
25
-
26
- The plan itself is pure, deterministic Ruby (`Bible270::Plan`) — no rows are stored for the
27
- schedule. Daily portions are sized using per-chapter verse counts (`Bible270::Versification`,
28
- standard English/KJV versification; verse counts are facts about the text's structure, so
29
- nothing copyrighted is bundled). The database only holds **readers, check-offs, and comments**.
30
-
31
- ## Requirements & compatibility
32
-
33
- - **Ruby** >= 3.0 (the gemspec's `required_ruby_version`). The plan logic is plain Ruby with
34
- no C extensions and no dependency on any gem that Ruby 4.0 unbundled. Installs and loads under
35
- Ruby 4.0.6; the test suite is exercised on 3.2 and passes with
36
- `--enable-frozen-string-literal` forced.
37
- - **Ruby 4.0 notes.** Audited against the 4.0 breaking changes: the gem does not use `cgi`
38
- (removed from default gems in 4.0 — URL escaping goes through `URI.encode_www_form_component`
39
- instead, which is byte-identical to `CGI.escape`), `Set`/`SortedSet`, `Ractor`, `Net::HTTP`,
40
- `Process::Status`, `ObjectSpace`, or any of the gems promoted from default to bundled
41
- (`ostruct`, `logger`, `benchmark`, `pstore`, `irb`, `rdoc`). Every file carries a
42
- `# frozen_string_literal: true` magic comment and no string literal is mutated.
43
- - **Rails.** The gemspec allows `rails >= 7.0`, but on Ruby 4.0 the binding constraint is your
44
- host app, not this engine: Rails 8.0/8.1 require Ruby >= 3.2, and ComfortableMediaSurfer is a
45
- "Rails 7.0+" engine. Verify your own lockfile (e.g. with RailsBump) before pairing an older
46
- Rails with Ruby 4.0.
47
- - A host application (this is an engine, not a standalone app).
48
- - Turbo is used for seamless check-offs and degrades to full-page redirects if absent.
1
+ # Bible 270
49
2
 
50
- ## Install
3
+ [![Gem Version](https://badge.fury.io/rb/bible270.svg)](https://badge.fury.io/rb/bible270) [![Gem Downloads](https://img.shields.io/gem/dt/bible270.svg?style=flat)](http://rubygems.org/gems/bible270) [![GitHub Release Date - Published_At](https://img.shields.io/github/release-date/avonderluft/bible270?label=last%20release&color=seagreen)](https://github.com/avonderluft/bible270/releases)
4
+
5
+ A mountable **Rails engine** that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with [ComfortableMediaSurfer](https://github.com/shakacode/comfortable-media-surfer) CMS in mind. Readers tick off each day's readings, post reflections, and see how everyone else is getting on, much like the social plans on Bible.com.
6
+
7
+ Initially created for use by students and faculty of [Kingdom Movement School of Ministry](https://www.kmsm.life/), to read through all of Scripture together, during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school with a 9 month school year.
8
+
9
+ ## The plan
10
+
11
+ Three readings a day, sized by **verse count** so each day takes roughly the same time.
12
+
13
+ - **Old Testament** — once through, with Psalms and Proverbs live in their own track. Whole chapters grouped so each day is ~73 verses (range 38–116).
14
+ - **New Testament** — once through, a reading every day. 260 chapters over 270 days, so the 10 longest are halved (Luke 1, Matthew 26–27, Mark 14, Luke 9, 12, 22, John 6,8, Acts 7).
15
+ - **Psalms & Proverbs** — once each, interleaved in one track. 181 chapters have to cover 270 days, so 89 are divided, shared between the two books by verse load. **A psalm of 25 verses or fewer is never divided**, keeping 131 of the 150 whole; longer psalms and Proverbs chapters break at their turns of thought (see `CHAPTER_BREAKS`). **Psalm 119 is 11 sections of 16 verses** — two of its eight-verse acrostic stanzas each. Proverbs lands on 89 days, near its proportional share. **A divided chapter is always read on consecutive days**, so nothing is inserted between the parts of a split psalm.
16
+
17
+ Genesis → Malachi and Revelation 22 both land on day 270, as does the second Proverbs 31 — the first falls on day 135, the midpoint. Psalm 150 comes in on day 269. Every day carries all three tracks. A typical day is ~119 verses (range 76–175).
51
18
 
52
- Add to the host app's `Gemfile`:
19
+ The schedule is pure deterministic Ruby (`Bible270::Plan`) — none of it is stored. Portions come from per-chapter verse counts in `Bible270::Versification`. The DB only holds readers, check-offs, comments, and sign-in tokens.
20
+
21
+ ## Requirements
22
+
23
+ - **Ruby** >= 3.0. Plain Ruby, no C extensions. Runs on 4.0.6 — it avoids everything 4.0 dropped or unbundled (notably `cgi`; URL escaping uses `URI.encode_www_form_component`).
24
+ - **Rails** >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
25
+ - A host app, and Turbo if you want check-offs without a page reload.
26
+
27
+ ## Install
53
28
 
54
29
  ```ruby
30
+ # Gemfile
55
31
  gem "bible270"
56
32
  ```
57
33
 
58
34
  <details>
59
- <summary>Other sources (unreleased changes, local development)</summary>
35
+ <summary>Other sources</summary>
60
36
 
61
37
  ```ruby
62
- # straight from the repository
63
38
  gem "bible270", git: "https://github.com/avonderluft/bible270.git", branch: "main"
64
-
65
- # a local checkout you're editing
66
- gem "bible270", path: "../bible270"
39
+ gem "bible270", path: "<path_to_your_local_copy>/bible270" # local checkout
67
40
  ```
68
41
  </details>
69
42
 
70
- Then install, copy the migrations, and migrate:
71
-
72
43
  ```bash
73
44
  bundle install
74
- bin/rails bible270:install:migrations
75
- bin/rails db:migrate
45
+ bin/rails generate bible270:install
76
46
  ```
77
47
 
78
- `install:migrations` copies four migrations into your `db/migrate` (readers, check-offs, comments,
79
- sign-in tokens). They belong to your application from then on: they get your timestamps, appear in
80
- your `schema.rb`, and are yours to run, roll back, or edit. The engine does **not** add its own
81
- migration directory to your app's paths, so the copies are the only definitions in play.
48
+ That's the whole install. The generator is interactive and walks you through it:
82
49
 
83
- Mount it in `config/routes.rb`:
50
+ 1. asks where to mount the plan;
51
+ 2. asks which sign-in methods you want — email link, OmniAuth, or both — and adds any OmniAuth strategy gems to your `Gemfile`;
52
+ 3. copies the four migrations into your `db/migrate` and offers to run them;
53
+ 4. writes `config/initializers/bible270.rb` (and `omniauth.rb` if you chose social sign-in); and
54
+ 5. adds `mount Bible270::Engine, at: Bible270.config.mount_at` to `config/routes.rb`.
84
55
 
85
- ```ruby
86
- mount Bible270::Engine, at: "/reading-plan"
87
- ```
56
+ (Migrations run before the config is written, so a problem in a generated initializer can't block the database work.)
57
+
58
+ Every prompt has a default, so holding Enter gives a working install at `/daily-bread` with email and GitHub sign-in. It finishes by listing what's left for you — provider credentials, callback URLs, mailer setup.
88
59
 
89
- Optionally generate the initializers (see [Authentication](#authentication)):
60
+ The copied migrations are yours from then on, in your `schema.rb`. The engine doesn't add its own migration directory to your app's paths, so the copies are the only definitions in play.
61
+
62
+ Non-interactive (CI, re-runs, scripted setup):
90
63
 
91
64
  ```bash
92
- bin/rails generate bible270:install --mount-at=/reading-plan --providers=github
65
+ bin/rails generate bible270:install --defaults \
66
+ --mount-at=/daily-bread --providers=github,google_oauth2 \
67
+ --mailer-from=no-reply@example.org --no-migrate
93
68
  ```
94
69
 
95
- The plan is now live at `/reading-plan`.
70
+ | Flag | Effect |
71
+ |------|--------|
72
+ | `--defaults` | Ask nothing, accept every default |
73
+ | `--mount-at=PATH` | Where to mount (default `/daily-bread`) |
74
+ | `--providers=a,b` | OmniAuth providers; `--providers=` for none |
75
+ | `--email` / `--no-email` | Email sign-in on or off |
76
+ | `--mailer-from=ADDR` | From: address for sign-in emails |
77
+ | `--bundle` / `--no-bundle` | Run `bundle install` after adding strategy gems |
78
+ | `--migrate` / `--no-migrate` | Run `db:migrate` at the end |
96
79
 
97
- ### Upgrading
80
+ The installer is safe to re-run: it leaves an existing `mount` line alone, skips strategy gems already in your Gemfile, and Rails prompts before overwriting an initializer.
81
+
82
+ <details>
83
+ <summary>Doing it by hand instead</summary>
84
+
85
+ ```ruby
86
+ # config/initializers/bible270.rb
87
+ Bible270.configure do |config|
88
+ config.mount_at = "/daily-bread"
89
+ config.email_sign_in = true
90
+ config.mailer_from = "no-reply@example.com"
91
+ config.omniauth_providers = [] # or [:github], plus an OmniAuth initializer
92
+ end
93
+ ```
98
94
 
99
- When a new version adds a migration, re-run the copy step — already-copied migrations are skipped:
95
+ ```ruby
96
+ # config/routes.rb
97
+ mount Bible270::Engine, at: Bible270.config.mount_at
98
+ ```
100
99
 
101
100
  ```bash
102
- bundle update bible270
103
101
  bin/rails bible270:install:migrations
104
102
  bin/rails db:migrate
105
103
  ```
106
104
 
107
- ---
105
+ See [Authentication](#authentication) for the OmniAuth initializer.
106
+ </details>
108
107
 
109
- ## Authentication
108
+ ### Upgrading
110
109
 
111
- Two built-in ways to sign in, so **nobody is excluded**:
110
+ No need to re-run the generator — just pick up any new migrations:
112
111
 
113
- 1. **Email link (passwordless)** — the reader types their address, gets a one-time link, and
114
- clicks it. No password, no account with anyone else. This is the default and it's what makes
115
- the plan usable by people who don't have (or don't want to use) GitHub, Google, and friends.
116
- 2. **OmniAuth social sign-in** — optional convenience for those who do.
112
+ ```bash
113
+ bundle update bible270
114
+ bin/rails bible270:install:migrations # skips ones you already have
115
+ bin/rails db:migrate
116
+ ```
117
117
 
118
- Either can run alone. `config.omniauth_providers = []` gives you an email-only site;
119
- `config.email_sign_in = false` gives you social-only. Viewing the plan and reading others'
120
- reflections stays public either way — only checking off and commenting require signing in.
118
+ ## Bible270 Mount point in your Rails app
121
119
 
122
- ### Email sign-in
120
+ The path lives in exactly **one** place, `config.mount_at`:
123
121
 
124
122
  ```ruby
123
+ # config/initializers/bible270.rb
125
124
  Bible270.configure do |config|
126
- config.email_sign_in = true
127
- config.mailer_from = "no-reply@gknt.org"
128
- config.email_sign_in_ask_name = true # reader picks their display name
129
- # config.email_sign_in_ttl = 20 * 60 # link lifetime (seconds)
130
- # config.email_sign_in_max_per_window = 5 # per address, per window
131
- # config.email_sign_in_window = 15 * 60
132
- # config.email_sign_in_deliver_later = true # needs an Active Job backend
125
+ config.mount_at = "/daily-bread" # or "/read270", "/manna", whatever you wish
133
126
  end
134
127
  ```
135
128
 
136
- The host app needs working Action Mailer delivery — nothing else. How it behaves:
129
+ Routes read it directly; OmniAuth reads `Bible270.config.auth_path_prefix` (just `"<mount_at>/auth"`). The engine builds its own sign-in links from `request.script_name` at runtime, so there's no third spot to update.
137
130
 
138
- - Tokens are 256-bit, URL-safe, single-use, and expire (20 minutes by default).
139
- - **Only a SHA-256 digest of the token is stored**, so the table is useless to anyone who reads
140
- the database. There is no password column anywhere in this gem.
141
- - Consuming a link is a conditional update, so a double-clicked link can't sign in twice.
142
- - The "check your inbox" response is identical whether the address was known, unknown, or
143
- rate-limited — no account enumeration and no hint that a limit was hit.
144
- - Readers may set the display name shown beside their reflections; otherwise it's derived from the
145
- address (`mary.anne.smith@…` → "Mary Anne Smith").
146
- - Spent and stale tokens can be cleaned up with `Bible270::SignInToken.sweep!` from a cron/rake task.
131
+ Values are normalised — `"read270"`, `"/read270"`, `"/read270/"` all become `/read270`. Nested paths and mounting at `/` work too.
147
132
 
148
- An email reader gets `provider: "email"` in the same identity columns OmniAuth uses, so downstream
149
- (progress, comments, leaderboard) treats every reader identically.
133
+ Two things sit outside your app and still need doing by hand when you move it: the **OAuth callback URLs** in each provider's dashboard, and any **CMS links**.
150
134
 
151
- ### OmniAuth social sign-in
135
+ > **Load order matters.** `bible270.rb` has to be read before `omniauth.rb`, since the latter asks for `auth_path_prefix`. Rails loads initializers alphabetically so the default names work fine. If you rename either, keep that order — or set `mount_at` in `config/application.rb`, which loads before all initializers.
152
136
 
153
- ### Quick setup
137
+ Need OmniAuth somewhere unrelated? Set `config.omniauth_path_prefix` and it wins.
154
138
 
155
- ```bash
156
- bin/rails generate bible270:install --mount-at=/reading-plan --providers=github
157
- ```
139
+ ## Authentication
140
+
141
+ Two ways in, so **nobody's shut out**:
142
+
143
+ 1. **Email link** — type an address, get a one-time link, click it. No password, no third-party account. The default, and what makes this usable by people with no GitHub or Google login.
144
+ 2. **Social sign-in** via OmniAuth — for people who'd rather.
145
+
146
+ Either works alone: `omniauth_providers = []` for email-only, `email_sign_in = false` for social-only. Reading is always public; only ticking off and commenting need an account.
158
147
 
159
- That writes `config/initializers/bible270.rb` (email sign-in enabled) and
160
- `config/initializers/omniauth.rb` (with `path_prefix` already matching your mount point), and adds
161
- the `mount` line to your routes. Pass `--providers=` with an empty value for an email-only site.
162
- Then add the gems you need and migrate:
148
+ ### Email links
163
149
 
164
150
  ```ruby
165
- # Gemfile — omniauth and omniauth-rails_csrf_protection come in with bible270;
166
- # the provider strategy is your choice:
167
- gem "omniauth-github"
151
+ Bible270.configure do |config|
152
+ config.email_sign_in = true
153
+ config.mailer_from = "no-reply@example.com"
154
+ config.email_sign_in_ask_name = true
155
+ config.email_sign_in_require_name = true # first AND last name # let readers pick their display name
156
+ # config.email_sign_in_ttl = 20 * 60 # link lifetime, seconds
157
+ # config.email_sign_in_max_per_window = 5 # per address
158
+ # config.email_sign_in_window = 15 * 60
159
+ # config.email_sign_in_deliver_later = true # needs Active Job
160
+ end
168
161
  ```
169
162
 
170
- ```bash
171
- bundle install
172
- bin/rails bible270:install:migrations && bin/rails db:migrate
173
- ```
163
+ All it needs is working Action Mailer delivery.
174
164
 
175
- Finally register the callback URL with the provider:
165
+ - Tokens: 256-bit, URL-safe, single-use, 20-minute expiry.
166
+ - **Only a SHA-256 digest is stored** — no password column anywhere, so the table is useless to anyone reading your DB.
167
+ - Claiming is a conditional update, so a double-clicked link still signs you in once.
168
+ - "Check your inbox" reads the same whether the address was known, unknown, or rate-limited — no account enumeration.
169
+ - Display name comes from the reader, else the address (`mary.anne.smith@…` → "Mary Anne Smith").
170
+ - `Bible270::SignInToken.sweep!` clears spent tokens. Good cron fodder.
176
171
 
177
- ```
178
- https://gknt.org/reading-plan/auth/github/callback
179
- ```
172
+ Email readers get `provider: "email"` in the same columns OmniAuth uses, so everything downstream treats them identically.
180
173
 
181
- ### Manual setup
174
+ ### Social sign-in
182
175
 
183
- If you'd rather not use the generator, the only subtlety is that OmniAuth's `path_prefix` must
184
- line up with where the engine is mounted, so its callback lands on the engine's route:
176
+ `bin/rails generate bible270:install` sets this up: it adds the strategy gem to your `Gemfile`
177
+ (`omniauth` and `omniauth-rails_csrf_protection` already ride along with bible270), writes the
178
+ OmniAuth initializer, and prints the callback URL to register with each provider — for example
179
+ `https://example.com/daily-bread/auth/github/callback`.
180
+
181
+ Adding a provider later, or wiring it up yourself? Add its strategy gem, list it in `config.omniauth_providers`, and register it with OmniAuth. The one thing to get right is lining up `path_prefix` with the mount point:
185
182
 
186
183
  ```ruby
187
184
  # config/initializers/omniauth.rb
188
185
  Rails.application.config.middleware.use OmniAuth::Builder do
189
- path_prefix "/reading-plan/auth" # == <mount point>/auth
186
+ # `options` is merged into every provider declared after it, so it comes first.
187
+ # OmniAuth::Builder has no path_prefix method — this is the scoped equivalent.
188
+ options path_prefix: Bible270.config.auth_path_prefix
189
+
190
190
  provider :github, ENV["GITHUB_CLIENT_ID"], ENV["GITHUB_CLIENT_SECRET"]
191
191
  end
192
192
 
@@ -196,31 +196,23 @@ OmniAuth.config.on_failure = proc { |env| Bible270::SessionsController.action(:f
196
196
  ```ruby
197
197
  # config/initializers/bible270.rb
198
198
  Bible270.configure do |config|
199
- config.omniauth_providers = [:github] # or [:github, [:google_oauth2, "Google"]]
199
+ config.mount_at = "/daily-bread"
200
+ config.omniauth_providers = [:github] # or [:github, [:google_oauth2, "Google"]]
200
201
  config.parent_controller = "::ApplicationController"
201
202
  end
202
203
  ```
203
204
 
204
- ### Notes worth knowing
205
+ Worth knowing:
205
206
 
206
- - **Sign-in is a POST, not a link.** OmniAuth 2.0+ refuses GET on its request phase
207
- (CVE-2015-9284), so every sign-in control the engine renders is a `button_to` carrying a CSRF
208
- token, and `omniauth-rails_csrf_protection` provides the Rails-aware verifier. If you build your
209
- own sign-in link, it must POST to `<mount>/auth/:provider`.
210
- - **One provider vs several.** With a single configured provider the header shows a direct
211
- "Sign in with X" button; with several it links to a sign-in page (`GET <mount>/sign_in`) listing
212
- them all.
213
- - **Return-to-origin.** Sign-in controls pass the current path as `origin`, so a reader who clicks
214
- a check-off while signed out lands back on that same day afterwards. Origins are validated to be
215
- local paths only.
216
- - **Session hygiene.** `reset_session` runs on both sign-in and sign-out to avoid session fixation.
217
- Sign-out is `DELETE <mount>/sign_out`.
218
- - **What's stored.** Provider, uid, display name, email, and avatar URL — no tokens, no passwords.
207
+ - **Sign-in is a POST.** OmniAuth 2.0+ refuses GET ([CVE-2015-9284](https://nvd.nist.gov/vuln/detail/CVE-2015-9284)), so the engine's controls are `button_to` forms with CSRF tokens. Build your own and it must POST to `<mount>/auth/:provider`.
208
+ - One provider → a button in the header. Several → a sign-in page at `<mount>/sign_in`.
209
+ - Sign-in carries the current path as `origin`, so clicking a check-off while signed out brings you back to that day. Origins must be local paths.
210
+ - `reset_session` on sign-in and sign-out, against session fixation.
211
+ - Stored: provider, uid, display name, email, avatar URL. No tokens, no passwords.
219
212
 
220
- ### Alternative: bridge your host's own users
213
+ ### Or use your own users
221
214
 
222
- If the host app already has authentication and you'd rather not use OmniAuth at all, set a
223
- resolver instead and the engine will never touch sessions:
215
+ Already have auth? Hand the engine a resolver and it'll never touch sessions:
224
216
 
225
217
  ```ruby
226
218
  config.current_reader_resolver = lambda do |controller|
@@ -231,224 +223,247 @@ config.current_reader_resolver = lambda do |controller|
231
223
  end
232
224
  ```
233
225
 
234
- `Reader.for_owner` links a reader to any host model polymorphically via `owner`. Note that
235
- ComfortableMediaSurfer's admin authentication is separate from public site visitors, so for a
236
- public plan on a CMS front-end the OmniAuth path above is usually the right fit.
226
+ `Reader.for_owner` links to any host model polymorphically. Heads up: CMS admin auth is separate from public visitors, so for a public plan the email/OmniAuth route above is usually what you want.
237
227
 
238
- ## Start dates
228
+ ## Admin panel
239
229
 
240
- The plan works either **undated** (day numbers only) or **dated** (day numbers mapped onto a
241
- calendar). There are two levers:
230
+ At `<mount>/admin`: remove readers, adjust their completions, move them to a given day. Unreachable —
231
+ every route 404s rather than 403s — unless you say who may use it:
242
232
 
243
233
  ```ruby
244
- Bible270.configure do |config|
245
- # A community-wide start date — everyone reads together as a cohort.
246
- # Accepts a Date, Time, or "YYYY-MM-DD" string. Default: nil (undated).
247
- config.start_date = Date.new(2026, 9, 6)
234
+ config.admin_emails = %w[andrew@example.org]
235
+ # or
236
+ config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }
237
+ ```
248
238
 
249
- # May an individual reader set/change their own start date? Default: true.
250
- # Set false to pin everyone to config.start_date.
251
- config.allow_reader_start_date = true
252
- end
239
+ Admins get an extra "Admin" link in the header. From a reader's page you can:
240
+
241
+ - **move them to a day** — "put them on day 42 as of today" back-dates the start date; check-offs are
242
+ keyed to day numbers, so nothing in their history moves;
243
+ - **set their progress** — mark complete through day N (this also clears anything after, so it sets
244
+ progress exactly), or click days in the grid to toggle them;
245
+ - **remove them**, with every check-off and reflection they've written;
246
+ - **moderate reflections** at `<mount>/admin/comments`. Reflections are visible the moment they're
247
+ written; hiding one takes it off the day and community pages without deleting it, and is
248
+ reversible. Delete is there too, for anything that shouldn't be kept.
249
+
250
+ Break points can also live in your app rather than the gem, and be changed without a restart:
251
+
252
+ ```ruby
253
+ config.chapter_breaks = { 'Psalm 78' => [20, 39, 55] }
254
+ config.chapter_breaks_path = Rails.root.join('config/bible270_breaks.yml')
253
255
  ```
254
256
 
255
- How the two combine:
257
+ ```yaml
258
+ # config/bible270_breaks.yml — re-read whenever it changes
259
+ Psalm 35: []
260
+ Psalm 78: [20, 39, 55]
261
+ ```
256
262
 
257
- | `start_date` | `allow_reader_start_date` | Behaviour |
258
- |---|---|---|
259
- | nil | true (default) | Each reader is stamped with their own start date the first time they check something off, and can change it. |
260
- | a date | true | The community date is the default; any reader may override it with their own. |
261
- | a date | false | Everyone is pinned to the community date. The per-reader form is hidden. |
262
- | nil | false | Fully undated — day numbers only, no calendar anywhere. |
263
+ ## Start dates
263
264
 
264
- When a plan is dated, each day page shows its calendar date with a **Today** badge, the overview
265
- gains a "Go to today" link, and readers see whether they're ahead of or behind the pace.
265
+ The plan runs **undated** (day numbers only) or **dated** (days mapped onto a calendar):
266
266
 
267
- Readers manage their own date from the overview page (`PATCH /start-date`, `DELETE /start-date`).
268
- Changing a start date only re-maps days onto the calendar — check-offs and reflections are keyed
269
- to day numbers and are never touched.
267
+ ```ruby
268
+ config.start_date = Date.new(2026, 9, 6) # Date, Time, or "YYYY-MM-DD". nil = undated
269
+ config.allow_reader_start_date = true # can readers set their own?
270
+ ```
270
271
 
271
- The date helpers are pure functions on `Bible270::Plan`, so you can use them anywhere:
272
+ | `start_date` | `allow_reader_start_date` | What happens |
273
+ |---|---|---|
274
+ | nil | true *(default)* | Each reader gets stamped on first check-off, and can change it |
275
+ | a date | true | Community date is the default; anyone may override |
276
+ | a date | false | Everyone pinned to the community date, form hidden |
277
+ | nil | false | Fully undated — no calendar anywhere |
278
+
279
+ Dated plans show each day's date with a **Today** badge, a "Go to today" link, and whether you're ahead or behind. Readers set their own from the overview. Changing a start date only re-maps the calendar — check-offs and reflections are keyed to day numbers, so nothing moves.
280
+
281
+ Date helpers are pure functions, usable anywhere:
272
282
 
273
283
  ```ruby
274
- Bible270::Plan.date_for(1, "2026-09-06") # => Sun, 06 Sep 2026
275
- Bible270::Plan.end_date_for("2026-09-06") # => Wed, 02 Jun 2027 (day 270)
276
- Bible270::Plan.day_for(Date.current, start) # => 42 (clamped to 1..270)
277
- Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to detect out-of-range
278
- Bible270::Plan.before_start?(date, start) # => true/false
284
+ Bible270::Plan.date_for(1, "2026-09-06") # => Sun, 06 Sep 2026
285
+ Bible270::Plan.end_date_for("2026-09-06") # => Wed, 02 Jun 2027 (day 270)
286
+ Bible270::Plan.day_for(Date.current, start) # => 42 (clamped 1..270)
287
+ Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to spot out-of-range
288
+ Bible270::Plan.before_start?(date, start)
279
289
  ```
280
290
 
281
291
  ## Configuration reference
282
292
 
283
293
  ```ruby
284
294
  Bible270.configure do |config|
295
+ config.mount_at = "/daily-bread" # single source of truth for the path
285
296
  config.app_name = "Daily Bread"
286
297
  config.tagline = "A 270-day journey through Scripture"
287
298
 
288
299
  config.start_date = nil # community start date, or nil for undated
289
- config.allow_reader_start_date = true # may readers set their own?
300
+ config.allow_reader_start_date = true
290
301
 
291
- config.parent_controller = "ActionController::Base" # or "::ApplicationController"
302
+ config.parent_controller = "ActionController::Base" # or "::ApplicationController"
292
303
  config.layout = "bible270/application"
293
304
 
294
- config.email_sign_in = true # passwordless email link
305
+ config.email_sign_in = true
295
306
  config.mailer_from = "no-reply@example.com"
296
307
  config.email_sign_in_ttl = 20 * 60
297
308
  config.email_sign_in_ask_name = true
298
- config.omniauth_providers = [:github] # optional social sign-in
299
- config.omniauth_path_prefix = nil # nil = derive "<mount>/auth"
300
- config.current_reader_resolver = nil # set to bridge host users instead
309
+ config.omniauth_providers = [:github]
310
+ config.omniauth_path_prefix = nil # nil = derive "<mount_at>/auth"
311
+ config.current_reader_resolver = nil # set to bridge your own users
301
312
  config.require_sign_in_to_participate = true
302
313
 
303
- config.after_sign_in_path = nil # defaults to plan root
314
+ config.after_sign_in_path = nil # defaults to the plan root
304
315
  config.after_sign_out_path = nil
305
316
 
306
- config.bible_version = "ESV"
317
+ config.bible_version = "NKJV"
307
318
  config.passage_url_builder = ->(reference, version) {
308
319
  "https://www.biblegateway.com/passage/?search=#{URI.encode_www_form_component(reference)}&version=#{version}"
309
320
  }
310
321
  end
311
322
  ```
312
323
 
313
- Every reading reference links out to the configured Bible reader (Bible Gateway by
314
- default). Point `passage_url_builder` at your own reader if you host one.
315
-
316
- ---
317
-
318
- ## Using it inside ComfortableMediaSurfer
324
+ References link to Bible Gateway by default — point `passage_url_builder` at your another reader if you wish, or your own reader if you host one.
319
325
 
320
- ComfortableMediaSurfer serves marketing/content pages; this engine is a separate
321
- mounted app. Two integration styles:
326
+ ## With ComfortableMediaSurfer
322
327
 
323
- 1. **Link to it** — mount at `/reading-plan` and add a CMS navigation link. The
324
- engine renders its own themed pages. Simplest and fully featured.
325
-
326
- 2. **Match the site chrome** — set `config.parent_controller = "::ApplicationController"`
327
- and `config.layout = "layouts/application"` (your site layout) so the plan pages
328
- sit inside your normal header/footer. The engine's scoped CSS (all classes are
329
- prefixed `b270-`) won't collide with CMS styles.
330
-
331
- Because CMS admin auth (`ComfortableMediaSurfer`) is separate from public readers,
332
- Option B (OmniAuth) is usually the right fit for public visitors on gknt.org.
333
-
334
- ---
328
+ The CMS serves your content; this is a separate mounted app. Either **just link to it** (mount, add a nav link, done — the engine renders its own themed pages), or **match your chrome** by setting `config.parent_controller = "::ApplicationController"` and `config.layout = "layouts/application"` so it sits inside your header and footer. All engine CSS is prefixed `b270-`, so nothing collides.
335
329
 
336
330
  ## Data model
337
331
 
338
- | Table | Purpose |
339
- |-------|---------|
332
+ | Table | Holds |
333
+ |-------|-------|
340
334
  | `bible270_readers` | identity: display name, avatar, provider/uid or polymorphic `owner`, `started_on` |
341
- | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`); unique index prevents dups |
342
- | `bible270_sign_in_tokens` | short-lived magic-link tokens: email, **digest only**, expiry, consumed-at |
343
- | `bible270_comments` | a reflection on a day (optionally scoped to a track), public to all |
335
+ | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`), unique-indexed |
336
+ | `bible270_comments` | a reflection on a day, optionally scoped to a track, public to all |
337
+ | `bible270_sign_in_tokens` | magic-link tokens: email, **digest only**, expiry, consumed-at |
344
338
 
345
- A day counts as "complete" for a reader when they've checked every track that has
346
- content that day (2 on a light-NT day, otherwise 3). All progress and comments are
347
- **public** to other readers by design (mirroring Bible.com's shared plans).
339
+ A day is "complete" once all three tracks are ticked. Progress and comments are **public** by
340
+ design, same as Bible.com's shared plans.
348
341
 
349
- ## Routes (within the mount point)
342
+ ## Routes (inside the mount point)
350
343
 
351
344
  ```
352
345
  GET / days#index overview + community + calendar
353
- GET /day/:day days#show three readings, completers, reflections
346
+ GET /day/:day days#show readings, who's finished, reflections
354
347
  POST /day/:day/toggle/:track checkoffs#toggle
355
348
  POST /day/:day/comments comments#create
356
349
  DELETE /comments/:id comments#destroy
357
350
  PATCH /start-date readers#update_start_date
358
351
  DELETE /start-date readers#clear_start_date
359
352
  GET /community readers#index leaderboard
360
- GET /readers/:id readers#show a reader's progress + reflections
361
- GET /sign_in sessions#new email form + provider list
362
- POST /sign_in/email sessions#email_link send a magic link
363
- GET /sign_in/email/:token sessions#email_callback consume a magic link
364
- GET /auth/:provider/callback sessions#create (OmniAuth callback)
353
+ GET /readers/:id readers#show
354
+ GET /sign_in sessions#new email form + provider list
355
+ POST /sign_in/email sessions#email_link
356
+ GET /sign_in/email/:token sessions#email_callback
357
+ GET /auth/:provider/callback sessions#create
365
358
  POST /auth/:provider/callback sessions#create
366
359
  GET /auth/failure sessions#failure
367
360
  DELETE /sign_out sessions#destroy
368
361
 
369
- POST <mount>/auth/:provider handled by the OmniAuth middleware, not the engine
362
+ POST <mount>/auth/:provider OmniAuth middleware, not the engine
370
363
  ```
371
364
 
372
- ## Tuning the plan shape
365
+ ## Tuning the plan
373
366
 
374
- The whole schedule is derived from a handful of constants in `Bible270::Plan`, so the shape
375
- can be changed without touching anything else:
367
+ The whole schedule falls out of a few constants in `Bible270::Plan`:
376
368
 
377
369
  | Constant | Default | Effect |
378
370
  |----------|---------|--------|
379
371
  | `DAYS` | `270` | plan length |
380
- | `NT_PASSES` | `2` | how many times the New Testament is read (each pass gets `DAYS / NT_PASSES` days) |
381
- | `PP_LONG_CHAPTER` | `100` | a chapter longer than this is divided. At 100 only Psalm 119 (176v) qualifies, since the next longest is Psalm 78 at 72v. Raise it above 176 to never split anything |
382
- | `PP_SPLIT_PARTS` | `2` | how many readings such a chapter becomes |
383
- | `PP_MIN_DAY` / `PP_DAY_TARGET` | `12` / `22` | how aggressively short chapters merge. These two values are what make the companion come out to exactly 135 portions, i.e. two clean passes across 270 days |
372
+ | `PROVERBS_PASSES` | `2` | Proverbs laps. Each pass is 31 readings, so this decides how many days are left for the Psalms |
373
+ | `PSALM_119_SECTION_SIZE` | `16` | verses per Psalm 119 section. 176 divides evenly by 8, 11, 16, 22 and 44 |
374
+ | `CHAPTER_BREAKS` | `{}` | where specific chapters break — see below |
384
375
 
385
- Everything is computed at load time and memoized; there is no generated schedule to migrate
386
- if you change these.
376
+ ### Choosing where a chapter breaks
387
377
 
388
- ## Scaling note
378
+ By default a divided chapter is cut into equal parts. To break it somewhere meaningful instead, add
379
+ an entry to `CHAPTER_BREAKS` in `lib/bible270/plan.rb`. Keys are `[book, chapter]`; the value lists
380
+ the **last verse of every reading except the final one**:
389
381
 
390
- The community leaderboard computes completed-days in Ruby from grouped check-off
391
- counts. That's ideal for a homelab/parish-sized community (hundreds of readers). If
392
- you grow to many thousands, add a cached `days_completed` counter on `Reader`
393
- (updated in a `Checkoff` after_commit) and sort in SQL.
382
+ ```ruby
383
+ CHAPTER_BREAKS = {
384
+ ['Luke', 1] => [25, 56], # => Luke 1:1–25, 1:26–56, 1:57–80
385
+ ['Psalm', 78] => [39] # => Psalm 78:1–39, 78:40–72
386
+ }.freeze
387
+ ```
394
388
 
395
- ## Troubleshooting
389
+ The number of breaks fixes how many readings that chapter becomes, and the rest of the plan
390
+ re-divides around it so both tracks still fill exactly 270 days — give Luke 1 a third reading and
391
+ some other long chapter quietly gives one up. Verse coverage stays exact either way.
396
392
 
397
- **`ActiveRecord::DuplicateMigrationNameError: Multiple migrations have the name CreateBible270Readers`**
393
+ Break points are validated on use: they must be ascending, distinct, and within the chapter, or you
394
+ get an `ArgumentError` naming the entry rather than silently overlapping readings. Pinning more
395
+ readings in total than there are days also raises, instead of producing a plan that doesn't fit.
398
396
 
399
- You're on 0.6.2 or earlier, where the engine wrongly added its own `db/migrate` to the host app's
400
- migration paths *as well as* providing `install:migrations` — so copying them defined every
401
- migration class twice. Upgrade to >= 0.6.3 and keep your copied migrations:
397
+ Psalm 119 is pinned to `PSALM_119_SECTION_SIZE` sections by default; an entry in `CHAPTER_BREAKS`
398
+ overrides that.
402
399
 
403
- ```bash
404
- bundle update bible270
405
- bin/rails db:migrate
406
- ```
400
+ Change `PROVERBS_PASSES` or `DAYS` and both the New Testament and the Psalms re-divide automatically
401
+ to fill whatever's left. Divisions always go to whichever chapter currently carries the heaviest
402
+ reading, so the longest get split first and nothing is divided that doesn't need to be.
407
403
 
408
- **Sign-in returns 404 on the provider callback**
404
+ All computed at load and memoized — no generated schedule, so nothing to migrate if you change them.
409
405
 
410
- OmniAuth's `path_prefix` doesn't match where the engine is mounted. They have to line up:
411
- mounting at `/reading-plan` means `path_prefix "/reading-plan/auth"`, and the URL registered with
412
- the provider must be `https://YOUR-HOST/reading-plan/auth/github/callback`. The install generator
413
- writes both consistently.
406
+ ## Scaling
414
407
 
415
- **Clicking "Sign in with …" does nothing, or raises `OmniAuth::AuthenticityError`**
408
+ The leaderboard counts completed days in Ruby from grouped check-offs — fine for hundreds of readers. Past a few thousand, cache a `days_completed` counter on `Reader` via a `Checkoff` `after_commit` and sort in SQL.
416
409
 
417
- The request has to be a POST with a CSRF token — OmniAuth 2.0+ refuses GET
418
- ([CVE-2015-9284](https://nvd.nist.gov/vuln/detail/CVE-2015-9284)). The engine's own controls are
419
- already POST forms; if you've built your own link, convert it to `button_to`. Also confirm
420
- `omniauth-rails_csrf_protection` is in the bundle.
410
+ ## Troubleshooting
421
411
 
422
- **No sign-in email arrives**
412
+ **404 on the provider callback**
423
413
 
424
- Email sign-in needs working Action Mailer delivery in the host app — the engine only calls
425
- `deliver_now` (or `deliver_later`). Check `config.mailer_from` is a real address your relay will
426
- accept, and watch the logs: delivery errors surface there. Nothing about a failure is shown to the
427
- reader, deliberately, since the "check your inbox" message is identical either way to avoid
428
- disclosing which addresses exist.
414
+ `path_prefix` doesn't match the mount point. Use `Bible270.config.auth_path_prefix` rather than a literal, and check the URL registered with the provider matches. Setting `mount_at` in an initializer Rails loads *after* `omniauth.rb` gives you the default prefix — see [Mount point](#bible270-mount-point-in-your-rails-app).
429
415
 
430
- **"That link has expired or was already used"**
416
+ **Sign-in button does nothing, or `OmniAuth::AuthenticityError`**
417
+
418
+ Has to be a POST with a CSRF token. The engine's controls already are — if you built your own, make it a `button_to`. Also check `omniauth-rails_csrf_protection` is in the bundle.
431
419
 
432
- Links are single-use and live for `email_sign_in_ttl` (20 minutes by default). Some mail scanners
433
- and link-preview services fetch URLs before the recipient clicks, which consumes the token. If you
434
- see this a lot, lengthen the TTL or check whether something upstream is prefetching links.
420
+ **No email arrives**
435
421
 
436
- **Everything renders unstyled inside my layout**
422
+ Needs working Action Mailer delivery — the engine just calls `deliver_now`. Check `mailer_from` is an address your relay accepts, and watch the logs. Failures never surface to the reader, on purpose, since that message can't reveal which addresses exist.
437
423
 
438
- The engine ships scoped CSS in a partial rendered by its own layout. If you point
439
- `config.layout` at your application layout, add
440
- `<%= render "bible270/shared/styles" %>` to that layout's `<head>`, or style the `b270-*` classes
441
- yourself.
424
+ **"That link has expired or was already used"**
425
+
426
+ Links are single-use, 20-minute life. Some mail scanners and link-preview bots fetch URLs before the recipient clicks, burning the token. If it keeps happening, lengthen `email_sign_in_ttl` or find what's prefetching.
442
427
 
443
- ## Development / tests
428
+ **Everything's unstyled in my layout**
444
429
 
445
- The deterministic plan logic is fully unit-tested with no Rails dependency:
430
+ The engine's CSS rides in a partial its own layout renders. Using your layout? Add `<%= render "bible270/shared/styles" %>` to the `<head>`, or style the `b270-*` classes yourself.
431
+
432
+ ## Development
446
433
 
447
434
  ```bash
448
435
  bundle install
449
436
  rake test
450
437
  ```
451
438
 
439
+ ### Get an email link for local testing
440
+
441
+ in the rails console: `rails c`
442
+ ```
443
+ _record, raw = Bible270::SignInToken.issue!("John.Smith@example.com")
444
+ Bible270::Engine.routes.url_helpers.email_sign_in_url(token: raw, host: "localhost:3333")
445
+ ```
446
+
447
+ Plan logic is fully unit-tested with no Rails dependency.
448
+
449
+ ## Contributing
450
+
451
+ Bug reports and pull requests are welcome at <https://github.com/avonderluft/bible270>.
452
+
453
+ 1. Fork it and clone your fork.
454
+ 2. Branch off `main`: `git checkout -b my-feature`.
455
+ 3. Make your change, and add tests — `rake test` should stay green.
456
+ 4. Commit with a clear message: `git commit -am "Add my feature"`.
457
+ 5. Push: `git push origin my-feature`.
458
+ 6. Open a pull request against `main`, saying what changed and why.
459
+
460
+ A few things that'll make review quick:
461
+
462
+ - Keep plan logic free of Rails so it stays unit-testable.
463
+ - Anything touching the reading schedule needs a test pinning the expected references — the plan is deterministic, so assert exact values.
464
+ - Prefix new CSS classes and helpers with `b270`.
465
+ - Note anything user-visible in `CHANGELOG.md`.
466
+
452
467
  ## License
453
468
 
454
- MIT. See `MIT-LICENSE`.
469
+ MIT. See `MIT-LICENSE`.