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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c9baaae6c81c3923b1997672d8f13ff5edd97dba42c56f29ec86fee53c508512
4
+ data.tar.gz: c564d896cd9144cee1aa4321e0cc39aa77f3197fb7a6f599045053b0fb674204
5
+ SHA512:
6
+ metadata.gz: 0d2de30b25a9be283d994aef6db3c5743e7a3c112208eae6a3aa6b66cb45d893312cba18321f02dc240699ac774f13a9361cdb59b063c95e709cf934a0d602cc
7
+ data.tar.gz: cf3908da790617444a1ef1717c533912d1303ca6c5ca32624186262964a836997639940d578eea1c7f06c61ad3dd9d00f1e23401ce3cfb8e21d8f8084d62ba21
data/CHANGELOG.md ADDED
@@ -0,0 +1,181 @@
1
+ # Changelog
2
+
3
+ All notable changes to bible270. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
4
+ this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html), treating pre-1.0 minor
5
+ bumps as the place breaking changes may land.
6
+
7
+ ## [0.6.2] — 2026-07-24
8
+
9
+ ### Changed
10
+ - Gem metadata now points at the actual repository rather than reusing the homepage:
11
+ `source_code_uri`, `bug_tracker_uri`, and `changelog_uri` all resolve to
12
+ <https://github.com/avonderluft/bible270>.
13
+ - `rubygems_mfa_required` is set, so pushes to rubygems.org require MFA.
14
+
15
+ ### Added
16
+ - This changelog, packaged with the gem.
17
+
18
+ ## [0.6.1] — 2026-07-24
19
+
20
+ ### Changed
21
+ - Author and copyright now read "Andrew vonderLuft".
22
+ - The display-name-from-email example in the docs and tests uses a neutral address, since the
23
+ algorithm can't recover internal capitals and shouldn't be shown rendering a real name wrongly.
24
+
25
+ ## [0.6.0] — 2026-07-24
26
+
27
+ ### Added
28
+ - **Passwordless email sign-in**, so readers without a GitHub/Google/etc. account can take part.
29
+ A reader enters their address, receives a single-use link, and clicks it — no password anywhere
30
+ in the gem.
31
+ - Tokens are 256-bit and URL-safe, expire after 20 minutes (`email_sign_in_ttl`), and are stored
32
+ **only as a SHA-256 digest**, so the table is useless to anyone reading the database.
33
+ - Claiming a link is a conditional `UPDATE`, so a double-clicked link cannot sign in twice.
34
+ - The "check your inbox" response is identical for known, unknown, and rate-limited addresses:
35
+ no account enumeration and no signal that a limit was hit.
36
+ - Rate limited per address per window (`email_sign_in_max_per_window`, `email_sign_in_window`).
37
+ - Readers may choose the display name shown beside their reflections
38
+ (`email_sign_in_ask_name`); otherwise it is derived from the address.
39
+ - `Bible270::SignInToken.sweep!` clears spent and stale rows from a cron or rake task.
40
+ - `Bible270::EmailSignIn` — Rails-free module holding the normalization and token logic, so the
41
+ security-relevant parts are unit testable on their own.
42
+ - `Bible270::SignInMailer` with text and HTML magic-link templates. The sign-in URL is built by the
43
+ controller, so the mailer needs no `default_url_options`.
44
+ - `Bible270::Reader.from_email`, using the same identity columns as OmniAuth with `provider: "email"`,
45
+ so every reader is treated identically downstream.
46
+ - New table: `bible270_sign_in_tokens` (migration `20260101000004`).
47
+ - `config.any_sign_in_method?`, letting views detect a deployment with no sign-in configured.
48
+
49
+ ### Fixed
50
+ - `display_name_from` raised `ArgumentError` on any address containing an underscore:
51
+ `tr("._-+", …)` was parsed as a character range. The hyphen now comes last.
52
+
53
+ ### Requirements
54
+ - Email sign-in needs working Action Mailer delivery in the host application. Set
55
+ `config.mailer_from`. Delivery is inline by default; set `email_sign_in_deliver_later` if a
56
+ queue backend is available.
57
+
58
+ ## [0.5.0] — 2026-07-24
59
+
60
+ ### Added
61
+ - Built-in OmniAuth sign-in is now fully wired as the primary auth path.
62
+ - `config.omniauth_providers` accepts symbols or `[provider, label]` pairs, with labels for common
63
+ providers and titleisation for anything custom.
64
+ - Sign-in page at `GET <mount>/sign_in` listing every configured provider.
65
+ - Sign-in controls carry the current path as `origin`, so a reader who clicks a check-off while
66
+ signed out returns to that same day. Origins are validated as local paths only.
67
+ - `rails generate bible270:install`, which writes both initializers with OmniAuth's `path_prefix`
68
+ already aligned to the mount point, adds the `mount` line, and prints the callback URL to register.
69
+ - `omniauth` (>= 2.0) and `omniauth-rails_csrf_protection` (>= 1.0) as runtime dependencies. Provider
70
+ strategy gems remain the host's choice.
71
+
72
+ ### Fixed
73
+ - **Sign-in was broken.** The header rendered a GET link to `/auth/github`, but OmniAuth 2.0 disabled
74
+ GET on the request phase (CVE-2015-9284). All sign-in controls are now POST forms with CSRF tokens.
75
+ The old link also contained a dead ternary and hardcoded the provider.
76
+ - Open-redirect hardening on the `origin` parameter: protocol-relative URLs, backslashes (which
77
+ browsers may normalise to slashes), and auth routes are all rejected.
78
+ - `reset_session` on sign-in and sign-out, against session fixation.
79
+ - `Reader.from_omniauth` no longer assumes an `OmniAuth::AuthHash`; it tolerates plain hashes and
80
+ missing `info`, and returns `nil` rather than raising on a malformed payload.
81
+
82
+ ### Changed
83
+ - `sign_out` is `DELETE` only (previously `GET` or `DELETE`).
84
+ - Guarded actions redirect to the sign-in page instead of `request.referer`.
85
+
86
+ ## [0.4.0] — 2026-07-24
87
+
88
+ ### Added
89
+ - **Configurable start dates.** `config.start_date` sets a community-wide cohort date (accepting a
90
+ `Date`, `Time`, or `"YYYY-MM-DD"` string); `config.allow_reader_start_date` controls whether
91
+ individuals may set their own. The four combinations give undated, per-reader, community-with-
92
+ override, and fully pinned modes.
93
+ - Readers manage their own date from the overview (`PATCH`/`DELETE <mount>/start-date`).
94
+ - Calendar mapping as pure functions on `Bible270::Plan`: `date_for`, `day_for` (clamped by default,
95
+ `clamp: false` to detect out-of-range), `end_date_for`, `before_start?`, `after_end?`, `to_date`.
96
+ - Day pages show their calendar date with a **Today** badge; the overview gains a "Go to today" link
97
+ and a pace indicator ("3 days behind", "right on pace", "begins in 12 days").
98
+
99
+ ### Notes
100
+ - Check-offs and comments are keyed to day numbers, never dates, so changing a start date only
101
+ re-maps the calendar — no reading history moves or is lost.
102
+ - A malformed `config.start_date` degrades to `nil` rather than raising at boot; the controller does
103
+ reject bad input with a flash message.
104
+
105
+ ## [0.3.1] — 2026-07-24
106
+
107
+ ### Fixed
108
+ - **Ruby 4.0 compatibility.** Dropped the `cgi` dependency: the CGI library was removed from Ruby's
109
+ default gems in 4.0 (only `cgi/escape` remains), so `require "cgi"` emits a deprecation warning
110
+ there. URL escaping now uses `URI.encode_www_form_component`, which produces byte-identical output
111
+ to `CGI.escape` and relies only on `uri`, a stable default gem.
112
+
113
+ ### Added
114
+ - Regression test asserting the gem never loads the `cgi` library.
115
+ - Compatibility notes in the README: audited against the Ruby 4.0 breaking changes (`Set`/`SortedSet`,
116
+ `Ractor`, `Net::HTTP`, `Process::Status`, `ObjectSpace`, and the gems promoted from default to
117
+ bundled). Every file carries a `frozen_string_literal` comment and the suite passes with
118
+ `--enable-frozen-string-literal` forced.
119
+
120
+ ## [0.3.0] — 2026-07-24
121
+
122
+ ### Changed — BREAKING
123
+ - **Renamed from `bible_reading_plan` to `bible270`.** Module `Bible270`, tables `bible270_*`,
124
+ migrations `CreateBible270*`, CSS and helper prefix `b270`, install task
125
+ `bible270:install:migrations`, mount `Bible270::Engine`. Table names changed, so this is a fresh
126
+ install rather than an upgrade.
127
+ - **The New Testament is now read twice.** Each pass gets its own half of the plan (135 days), so
128
+ Revelation lands on day 135 and again on day 270. Whole chapters are verse-balanced within each
129
+ pass, tightening NT days from 11–140 verses to 20–104. The NT track now has content every day, so
130
+ there are no rest days and all three tracks are present on all 270 days.
131
+ - **Chapters are kept whole.** Only a chapter longer than `PP_LONG_CHAPTER` (100 verses) is divided,
132
+ which in the whole Psalter and Proverbs is Psalm 119 alone — split into exactly two readings
133
+ (1–88, 89–176). Psalms 78 and 89 are no longer split. Short chapters merge with a neighbour, so
134
+ Psalm 117 reads as "Psalm 116–117" rather than occupying a two-verse day.
135
+ - The Psalms/Proverbs companion resolves to exactly 135 portions, making two complete passes across
136
+ 270 days and finishing on Proverbs 31 — so all three tracks finish together at the halfway mark
137
+ and at the end.
138
+
139
+ ### Added
140
+ - `Plan.nt_days_per_pass`, `nt_second_pass_start_day`, `nt_verse_loads`, `pp_cycle_length`,
141
+ `pp_base_portions`, and tuning constants documented in the README.
142
+
143
+ ## [0.2.0] — 2026-07-24
144
+
145
+ ### Changed
146
+ - **Daily portions are balanced by verse count, not chapter count**, so each day takes roughly the
147
+ same time to read. Previously a day on Psalm 119 (176 verses) counted the same as a day on Psalm
148
+ 117 (2 verses).
149
+ - Old Testament days are grouped to be even in verses (avg ~73/day).
150
+
151
+ ### Added
152
+ - `Bible270::Versification` — per-chapter verse counts for all 66 books, generated from a
153
+ public-domain KJV text and validated against canonical anchors (1,189 chapters; Psalms 2,461
154
+ verses; Proverbs 915; Psalm 119 = 176; Psalm 117 = 2). Verse counts are facts about the text's
155
+ structure, so nothing copyrighted is bundled.
156
+
157
+ ### Fixed
158
+ - Per-track progress bars compared days checked against *chapter* totals, so the Old Testament bar
159
+ could never exceed ~36%. Renamed `chapters_read_in` to `days_read_in` and made the totals
160
+ day-based.
161
+
162
+ ## [0.1.0] — 2026-07-24
163
+
164
+ ### Added
165
+ - Initial release: a mountable Rails engine providing a 270-day Bible reading plan with three daily
166
+ tracks (Old Testament, New Testament, Psalms/Proverbs).
167
+ - Per-reader check-offs, public per-day reflections, a community leaderboard, and reader profiles.
168
+ - Two identity paths: built-in session sign-in, or bridging the host application's users via
169
+ `config.current_reader_resolver` and `Reader.for_owner`.
170
+ - The schedule is pure, deterministic Ruby — no rows are stored for the plan itself; the database
171
+ holds only readers, check-offs, and comments.
172
+
173
+ [0.6.2]: https://github.com/avonderluft/bible270/releases/tag/v0.6.2
174
+ [0.6.1]: https://github.com/avonderluft/bible270/releases/tag/v0.6.1
175
+ [0.6.0]: https://github.com/avonderluft/bible270/releases/tag/v0.6.0
176
+ [0.5.0]: https://github.com/avonderluft/bible270/releases/tag/v0.5.0
177
+ [0.4.0]: https://github.com/avonderluft/bible270/releases/tag/v0.4.0
178
+ [0.3.1]: https://github.com/avonderluft/bible270/releases/tag/v0.3.1
179
+ [0.3.0]: https://github.com/avonderluft/bible270/releases/tag/v0.3.0
180
+ [0.2.0]: https://github.com/avonderluft/bible270/releases/tag/v0.2.0
181
+ [0.1.0]: https://github.com/avonderluft/bible270/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Andrew vonderLuft
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
17
+ AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
18
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,373 @@
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.
35
+ - **Ruby 4.0 notes.** Audited against the 4.0 breaking changes: the gem does not use `cgi`
36
+ (removed from default gems in 4.0 — URL escaping goes through `URI.encode_www_form_component`
37
+ instead, which is byte-identical to `CGI.escape`), `Set`/`SortedSet`, `Ractor`, `Net::HTTP`,
38
+ `Process::Status`, `ObjectSpace`, or any of the gems promoted from default to bundled
39
+ (`ostruct`, `logger`, `benchmark`, `pstore`, `irb`, `rdoc`). Every file carries a
40
+ `# frozen_string_literal: true` magic comment and no string literal is mutated.
41
+ - **Rails.** The gemspec allows `rails >= 7.0`, but on Ruby 4.0 the binding constraint is your
42
+ host app, not this engine: Rails 8.0/8.1 require Ruby >= 3.2, and ComfortableMediaSurfer is a
43
+ "Rails 7.0+" engine. Verify your own lockfile (e.g. with RailsBump) before pairing an older
44
+ Rails with Ruby 4.0.
45
+ - A host application (this is an engine, not a standalone app).
46
+ - Turbo is used for seamless check-offs and degrades to full-page redirects if absent.
47
+
48
+ ## Install
49
+
50
+ Add to the host app's `Gemfile`:
51
+
52
+ ```ruby
53
+ gem "bible270", path: "vendor/gems/bible270"
54
+ # or, once pushed to a git remote:
55
+ # gem "bible270", git: "https://github.com/avonderluft/bible270.git", branch: "main"
56
+ ```
57
+
58
+ Then:
59
+
60
+ ```bash
61
+ bundle install
62
+ bin/rails bible270:install:migrations
63
+ bin/rails db:migrate
64
+ ```
65
+
66
+ Mount it in `config/routes.rb`:
67
+
68
+ ```ruby
69
+ mount Bible270::Engine, at: "/reading-plan"
70
+ ```
71
+
72
+ The plan is now live at `/reading-plan`.
73
+
74
+ ---
75
+
76
+ ## Authentication
77
+
78
+ Two built-in ways to sign in, so **nobody is excluded**:
79
+
80
+ 1. **Email link (passwordless)** — the reader types their address, gets a one-time link, and
81
+ clicks it. No password, no account with anyone else. This is the default and it's what makes
82
+ the plan usable by people who don't have (or don't want to use) GitHub, Google, and friends.
83
+ 2. **OmniAuth social sign-in** — optional convenience for those who do.
84
+
85
+ Either can run alone. `config.omniauth_providers = []` gives you an email-only site;
86
+ `config.email_sign_in = false` gives you social-only. Viewing the plan and reading others'
87
+ reflections stays public either way — only checking off and commenting require signing in.
88
+
89
+ ### Email sign-in
90
+
91
+ ```ruby
92
+ Bible270.configure do |config|
93
+ config.email_sign_in = true
94
+ config.mailer_from = "no-reply@gknt.org"
95
+ config.email_sign_in_ask_name = true # reader picks their display name
96
+ # config.email_sign_in_ttl = 20 * 60 # link lifetime (seconds)
97
+ # config.email_sign_in_max_per_window = 5 # per address, per window
98
+ # config.email_sign_in_window = 15 * 60
99
+ # config.email_sign_in_deliver_later = true # needs an Active Job backend
100
+ end
101
+ ```
102
+
103
+ The host app needs working Action Mailer delivery — nothing else. How it behaves:
104
+
105
+ - Tokens are 256-bit, URL-safe, single-use, and expire (20 minutes by default).
106
+ - **Only a SHA-256 digest of the token is stored**, so the table is useless to anyone who reads
107
+ the database. There is no password column anywhere in this gem.
108
+ - Consuming a link is a conditional update, so a double-clicked link can't sign in twice.
109
+ - The "check your inbox" response is identical whether the address was known, unknown, or
110
+ rate-limited — no account enumeration and no hint that a limit was hit.
111
+ - Readers may set the display name shown beside their reflections; otherwise it's derived from the
112
+ address (`mary.anne.smith@…` → "Mary Anne Smith").
113
+ - Spent and stale tokens can be cleaned up with `Bible270::SignInToken.sweep!` from a cron/rake task.
114
+
115
+ An email reader gets `provider: "email"` in the same identity columns OmniAuth uses, so downstream
116
+ (progress, comments, leaderboard) treats every reader identically.
117
+
118
+ ### OmniAuth social sign-in
119
+
120
+ ### Quick setup
121
+
122
+ ```bash
123
+ bin/rails generate bible270:install --mount-at=/reading-plan --providers=github
124
+ ```
125
+
126
+ That writes `config/initializers/bible270.rb` (email sign-in enabled) and
127
+ `config/initializers/omniauth.rb` (with `path_prefix` already matching your mount point), and adds
128
+ the `mount` line to your routes. Pass `--providers=` with an empty value for an email-only site.
129
+ Then add the gems you need and migrate:
130
+
131
+ ```ruby
132
+ # Gemfile — omniauth and omniauth-rails_csrf_protection come in with bible270;
133
+ # the provider strategy is your choice:
134
+ gem "omniauth-github"
135
+ ```
136
+
137
+ ```bash
138
+ bundle install
139
+ bin/rails bible270:install:migrations && bin/rails db:migrate
140
+ ```
141
+
142
+ Finally register the callback URL with the provider:
143
+
144
+ ```
145
+ https://gknt.org/reading-plan/auth/github/callback
146
+ ```
147
+
148
+ ### Manual setup
149
+
150
+ If you'd rather not use the generator, the only subtlety is that OmniAuth's `path_prefix` must
151
+ line up with where the engine is mounted, so its callback lands on the engine's route:
152
+
153
+ ```ruby
154
+ # config/initializers/omniauth.rb
155
+ Rails.application.config.middleware.use OmniAuth::Builder do
156
+ path_prefix "/reading-plan/auth" # == <mount point>/auth
157
+ provider :github, ENV["GITHUB_CLIENT_ID"], ENV["GITHUB_CLIENT_SECRET"]
158
+ end
159
+
160
+ OmniAuth.config.on_failure = proc { |env| Bible270::SessionsController.action(:failure).call(env) }
161
+ ```
162
+
163
+ ```ruby
164
+ # config/initializers/bible270.rb
165
+ Bible270.configure do |config|
166
+ config.omniauth_providers = [:github] # or [:github, [:google_oauth2, "Google"]]
167
+ config.parent_controller = "::ApplicationController"
168
+ end
169
+ ```
170
+
171
+ ### Notes worth knowing
172
+
173
+ - **Sign-in is a POST, not a link.** OmniAuth 2.0+ refuses GET on its request phase
174
+ (CVE-2015-9284), so every sign-in control the engine renders is a `button_to` carrying a CSRF
175
+ token, and `omniauth-rails_csrf_protection` provides the Rails-aware verifier. If you build your
176
+ own sign-in link, it must POST to `<mount>/auth/:provider`.
177
+ - **One provider vs several.** With a single configured provider the header shows a direct
178
+ "Sign in with X" button; with several it links to a sign-in page (`GET <mount>/sign_in`) listing
179
+ them all.
180
+ - **Return-to-origin.** Sign-in controls pass the current path as `origin`, so a reader who clicks
181
+ a check-off while signed out lands back on that same day afterwards. Origins are validated to be
182
+ local paths only.
183
+ - **Session hygiene.** `reset_session` runs on both sign-in and sign-out to avoid session fixation.
184
+ Sign-out is `DELETE <mount>/sign_out`.
185
+ - **What's stored.** Provider, uid, display name, email, and avatar URL — no tokens, no passwords.
186
+
187
+ ### Alternative: bridge your host's own users
188
+
189
+ If the host app already has authentication and you'd rather not use OmniAuth at all, set a
190
+ resolver instead and the engine will never touch sessions:
191
+
192
+ ```ruby
193
+ config.current_reader_resolver = lambda do |controller|
194
+ user = controller.send(:current_user)
195
+ next nil unless user
196
+ Bible270::Reader.for_owner(user, display_name: user.try(:name) || user.email,
197
+ email: user.try(:email), avatar_url: user.try(:avatar_url))
198
+ end
199
+ ```
200
+
201
+ `Reader.for_owner` links a reader to any host model polymorphically via `owner`. Note that
202
+ ComfortableMediaSurfer's admin authentication is separate from public site visitors, so for a
203
+ public plan on a CMS front-end the OmniAuth path above is usually the right fit.
204
+
205
+ ## Start dates
206
+
207
+ The plan works either **undated** (day numbers only) or **dated** (day numbers mapped onto a
208
+ calendar). There are two levers:
209
+
210
+ ```ruby
211
+ Bible270.configure do |config|
212
+ # A community-wide start date — everyone reads together as a cohort.
213
+ # Accepts a Date, Time, or "YYYY-MM-DD" string. Default: nil (undated).
214
+ config.start_date = Date.new(2026, 9, 6)
215
+
216
+ # May an individual reader set/change their own start date? Default: true.
217
+ # Set false to pin everyone to config.start_date.
218
+ config.allow_reader_start_date = true
219
+ end
220
+ ```
221
+
222
+ How the two combine:
223
+
224
+ | `start_date` | `allow_reader_start_date` | Behaviour |
225
+ |---|---|---|
226
+ | nil | true (default) | Each reader is stamped with their own start date the first time they check something off, and can change it. |
227
+ | a date | true | The community date is the default; any reader may override it with their own. |
228
+ | a date | false | Everyone is pinned to the community date. The per-reader form is hidden. |
229
+ | nil | false | Fully undated — day numbers only, no calendar anywhere. |
230
+
231
+ When a plan is dated, each day page shows its calendar date with a **Today** badge, the overview
232
+ gains a "Go to today" link, and readers see whether they're ahead of or behind the pace.
233
+
234
+ Readers manage their own date from the overview page (`PATCH /start-date`, `DELETE /start-date`).
235
+ Changing a start date only re-maps days onto the calendar — check-offs and reflections are keyed
236
+ to day numbers and are never touched.
237
+
238
+ The date helpers are pure functions on `Bible270::Plan`, so you can use them anywhere:
239
+
240
+ ```ruby
241
+ Bible270::Plan.date_for(1, "2026-09-06") # => Sun, 06 Sep 2026
242
+ Bible270::Plan.end_date_for("2026-09-06") # => Wed, 02 Jun 2027 (day 270)
243
+ Bible270::Plan.day_for(Date.current, start) # => 42 (clamped to 1..270)
244
+ Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to detect out-of-range
245
+ Bible270::Plan.before_start?(date, start) # => true/false
246
+ ```
247
+
248
+ ## Configuration reference
249
+
250
+ ```ruby
251
+ Bible270.configure do |config|
252
+ config.app_name = "Daily Bread"
253
+ config.tagline = "A 270-day journey through Scripture"
254
+
255
+ config.start_date = nil # community start date, or nil for undated
256
+ config.allow_reader_start_date = true # may readers set their own?
257
+
258
+ config.parent_controller = "ActionController::Base" # or "::ApplicationController"
259
+ config.layout = "bible270/application"
260
+
261
+ config.email_sign_in = true # passwordless email link
262
+ config.mailer_from = "no-reply@example.com"
263
+ config.email_sign_in_ttl = 20 * 60
264
+ config.email_sign_in_ask_name = true
265
+ config.omniauth_providers = [:github] # optional social sign-in
266
+ config.omniauth_path_prefix = nil # nil = derive "<mount>/auth"
267
+ config.current_reader_resolver = nil # set to bridge host users instead
268
+ config.require_sign_in_to_participate = true
269
+
270
+ config.after_sign_in_path = nil # defaults to plan root
271
+ config.after_sign_out_path = nil
272
+
273
+ config.bible_version = "ESV"
274
+ config.passage_url_builder = ->(reference, version) {
275
+ "https://www.biblegateway.com/passage/?search=#{URI.encode_www_form_component(reference)}&version=#{version}"
276
+ }
277
+ end
278
+ ```
279
+
280
+ Every reading reference links out to the configured Bible reader (Bible Gateway by
281
+ default). Point `passage_url_builder` at your own reader if you host one.
282
+
283
+ ---
284
+
285
+ ## Using it inside ComfortableMediaSurfer
286
+
287
+ ComfortableMediaSurfer serves marketing/content pages; this engine is a separate
288
+ mounted app. Two integration styles:
289
+
290
+ 1. **Link to it** — mount at `/reading-plan` and add a CMS navigation link. The
291
+ engine renders its own themed pages. Simplest and fully featured.
292
+
293
+ 2. **Match the site chrome** — set `config.parent_controller = "::ApplicationController"`
294
+ and `config.layout = "layouts/application"` (your site layout) so the plan pages
295
+ sit inside your normal header/footer. The engine's scoped CSS (all classes are
296
+ prefixed `b270-`) won't collide with CMS styles.
297
+
298
+ Because CMS admin auth (`ComfortableMediaSurfer`) is separate from public readers,
299
+ Option B (OmniAuth) is usually the right fit for public visitors on gknt.org.
300
+
301
+ ---
302
+
303
+ ## Data model
304
+
305
+ | Table | Purpose |
306
+ |-------|---------|
307
+ | `bible270_readers` | identity: display name, avatar, provider/uid or polymorphic `owner`, `started_on` |
308
+ | `bible270_checkoffs` | one row per reader per day per track (`ot`/`nt`/`pp`); unique index prevents dups |
309
+ | `bible270_sign_in_tokens` | short-lived magic-link tokens: email, **digest only**, expiry, consumed-at |
310
+ | `bible270_comments` | a reflection on a day (optionally scoped to a track), public to all |
311
+
312
+ A day counts as "complete" for a reader when they've checked every track that has
313
+ content that day (2 on a light-NT day, otherwise 3). All progress and comments are
314
+ **public** to other readers by design (mirroring Bible.com's shared plans).
315
+
316
+ ## Routes (within the mount point)
317
+
318
+ ```
319
+ GET / days#index overview + community + calendar
320
+ GET /day/:day days#show three readings, completers, reflections
321
+ POST /day/:day/toggle/:track checkoffs#toggle
322
+ POST /day/:day/comments comments#create
323
+ DELETE /comments/:id comments#destroy
324
+ PATCH /start-date readers#update_start_date
325
+ DELETE /start-date readers#clear_start_date
326
+ GET /community readers#index leaderboard
327
+ GET /readers/:id readers#show a reader's progress + reflections
328
+ GET /sign_in sessions#new email form + provider list
329
+ POST /sign_in/email sessions#email_link send a magic link
330
+ GET /sign_in/email/:token sessions#email_callback consume a magic link
331
+ GET /auth/:provider/callback sessions#create (OmniAuth callback)
332
+ POST /auth/:provider/callback sessions#create
333
+ GET /auth/failure sessions#failure
334
+ DELETE /sign_out sessions#destroy
335
+
336
+ POST <mount>/auth/:provider handled by the OmniAuth middleware, not the engine
337
+ ```
338
+
339
+ ## Tuning the plan shape
340
+
341
+ The whole schedule is derived from a handful of constants in `Bible270::Plan`, so the shape
342
+ can be changed without touching anything else:
343
+
344
+ | Constant | Default | Effect |
345
+ |----------|---------|--------|
346
+ | `DAYS` | `270` | plan length |
347
+ | `NT_PASSES` | `2` | how many times the New Testament is read (each pass gets `DAYS / NT_PASSES` days) |
348
+ | `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 |
349
+ | `PP_SPLIT_PARTS` | `2` | how many readings such a chapter becomes |
350
+ | `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 |
351
+
352
+ Everything is computed at load time and memoized; there is no generated schedule to migrate
353
+ if you change these.
354
+
355
+ ## Scaling note
356
+
357
+ The community leaderboard computes completed-days in Ruby from grouped check-off
358
+ counts. That's ideal for a homelab/parish-sized community (hundreds of readers). If
359
+ you grow to many thousands, add a cached `days_completed` counter on `Reader`
360
+ (updated in a `Checkoff` after_commit) and sort in SQL.
361
+
362
+ ## Development / tests
363
+
364
+ The deterministic plan logic is fully unit-tested with no Rails dependency:
365
+
366
+ ```bash
367
+ bundle install
368
+ rake test
369
+ ```
370
+
371
+ ## License
372
+
373
+ MIT. See `MIT-LICENSE`.
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ require "bundler/gem_tasks" # provides build / install / release
3
+ require "rake/testtask"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test" << "lib"
7
+ t.pattern = "test/**/*_test.rb"
8
+ t.warning = false
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class ApplicationController < Bible270.config.parent_controller.constantize
4
+ layout :bible270_layout
5
+
6
+ helper_method :current_reader, :signed_in?, :b270_config
7
+
8
+ private
9
+
10
+ def bible270_layout
11
+ Bible270.config.layout
12
+ end
13
+
14
+ def b270_config
15
+ Bible270.config
16
+ end
17
+
18
+ def current_reader
19
+ return @current_reader if defined?(@current_reader)
20
+
21
+ @current_reader =
22
+ if (resolver = Bible270.config.current_reader_resolver)
23
+ resolver.call(self)
24
+ elsif (id = session[:bible270_reader_id])
25
+ Reader.find_by(id: id)
26
+ end
27
+ end
28
+
29
+ def signed_in?
30
+ current_reader.present?
31
+ end
32
+
33
+ # Guard participation (checking off / commenting). Viewing is always open.
34
+ def require_reader!
35
+ return true if signed_in?
36
+ return true unless Bible270.config.require_sign_in_to_participate
37
+
38
+ respond_to do |format|
39
+ format.turbo_stream { head :unauthorized }
40
+ format.html do
41
+ redirect_to sign_in_path(origin: request.fullpath),
42
+ alert: "Please sign in to take part."
43
+ end
44
+ end
45
+ false
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+ module Bible270
3
+ class CheckoffsController < ApplicationController
4
+ def toggle
5
+ return unless require_reader!
6
+
7
+ @day = params[:day].to_i
8
+ @track = params[:track].to_s
9
+
10
+ unless Plan.valid_day?(@day) && Plan.present_tracks(@day).include?(@track)
11
+ head :bad_request and return
12
+ end
13
+
14
+ existing = current_reader.checkoffs.find_by(day: @day, track: @track)
15
+ if existing
16
+ existing.destroy
17
+ else
18
+ current_reader.ensure_started!
19
+ current_reader.checkoffs.create(day: @day, track: @track)
20
+ end
21
+
22
+ @reader = current_reader.reload
23
+ @reader_tracks = @reader.read_tracks_for(@day)
24
+ @readings = Plan.readings_for(@day)
25
+
26
+ respond_to do |format|
27
+ format.turbo_stream
28
+ format.html { redirect_to day_path(@day) }
29
+ end
30
+ end
31
+ end
32
+ end