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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -164
- data/README.md +287 -272
- data/Rakefile +5 -4
- data/app/controllers/bible270/admin_controller.rb +119 -0
- data/app/controllers/bible270/application_controller.rb +19 -2
- data/app/controllers/bible270/checkoffs_controller.rb +2 -3
- data/app/controllers/bible270/comments_controller.rb +6 -2
- data/app/controllers/bible270/days_controller.rb +5 -6
- data/app/controllers/bible270/readers_controller.rb +5 -4
- data/app/controllers/bible270/sessions_controller.rb +60 -26
- data/app/helpers/bible270/plan_helper.rb +11 -8
- data/app/mailers/bible270/application_mailer.rb +4 -1
- data/app/mailers/bible270/sign_in_mailer.rb +1 -0
- data/app/models/bible270/application_record.rb +1 -0
- data/app/models/bible270/checkoff.rb +4 -3
- data/app/models/bible270/comment.rb +26 -5
- data/app/models/bible270/reader.rb +78 -14
- data/app/models/bible270/sign_in_token.rb +5 -2
- data/app/views/bible270/admin/comments.html.erb +44 -0
- data/app/views/bible270/admin/index.html.erb +30 -0
- data/app/views/bible270/admin/show.html.erb +89 -0
- data/app/views/bible270/days/index.html.erb +10 -13
- data/app/views/bible270/readers/index.html.erb +2 -2
- data/app/views/bible270/readers/show.html.erb +5 -5
- data/app/views/bible270/sessions/new.html.erb +9 -3
- data/app/views/bible270/shared/_header.html.erb +1 -0
- data/app/views/bible270/shared/_styles.html.erb +10 -7
- data/app/views/layouts/bible270/application.html.erb +4 -3
- data/config/routes.rb +32 -18
- data/db/migrate/20260101000001_create_bible270_readers.rb +3 -2
- data/db/migrate/20260101000002_create_bible270_checkoffs.rb +3 -2
- data/db/migrate/20260101000003_create_bible270_comments.rb +4 -3
- data/db/migrate/20260101000004_create_bible270_sign_in_tokens.rb +4 -3
- data/db/migrate/20260101000005_add_names_to_bible270_readers.rb +12 -0
- data/db/migrate/20260101000006_add_approval_to_bible270_comments.rb +11 -0
- data/lib/bible270/configuration.rb +91 -19
- data/lib/bible270/email_sign_in.rb +7 -7
- data/lib/bible270/engine.rb +1 -0
- data/lib/bible270/plan.rb +449 -150
- data/lib/bible270/versification.rb +84 -67
- data/lib/bible270/version.rb +2 -1
- data/lib/bible270.rb +6 -5
- data/lib/generators/bible270/install/install_generator.rb +520 -39
- data/lib/generators/bible270/install/templates/bible270.rb.tt +8 -2
- data/lib/generators/bible270/install/templates/omniauth.rb.tt +6 -4
- metadata +17 -29
data/README.md
CHANGED
|
@@ -1,192 +1,192 @@
|
|
|
1
|
-
#
|
|
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
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/bible270) [](http://rubygems.org/gems/bible270) [](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
|
-
|
|
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
|
|
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
|
|
75
|
-
bin/rails db:migrate
|
|
45
|
+
bin/rails generate bible270:install
|
|
76
46
|
```
|
|
77
47
|
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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 --
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
### Upgrading
|
|
110
109
|
|
|
111
|
-
|
|
110
|
+
No need to re-run the generator — just pick up any new migrations:
|
|
112
111
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
+
Need OmniAuth somewhere unrelated? Set `config.omniauth_path_prefix` and it wins.
|
|
154
138
|
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
174
|
+
### Social sign-in
|
|
182
175
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
205
|
+
Worth knowing:
|
|
205
206
|
|
|
206
|
-
- **Sign-in is a POST
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
-
|
|
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
|
-
###
|
|
213
|
+
### Or use your own users
|
|
221
214
|
|
|
222
|
-
|
|
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
|
|
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
|
-
##
|
|
228
|
+
## Admin panel
|
|
239
229
|
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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")
|
|
275
|
-
Bible270::Plan.end_date_for("2026-09-06")
|
|
276
|
-
Bible270::Plan.day_for(Date.current, start)
|
|
277
|
-
Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to
|
|
278
|
-
Bible270::Plan.before_start?(date, start)
|
|
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
|
|
300
|
+
config.allow_reader_start_date = true
|
|
290
301
|
|
|
291
|
-
config.parent_controller = "ActionController::Base"
|
|
302
|
+
config.parent_controller = "ActionController::Base" # or "::ApplicationController"
|
|
292
303
|
config.layout = "bible270/application"
|
|
293
304
|
|
|
294
|
-
config.email_sign_in = true
|
|
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]
|
|
299
|
-
config.omniauth_path_prefix = nil
|
|
300
|
-
config.current_reader_resolver = nil
|
|
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
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
mounted app. Two integration styles:
|
|
326
|
+
## With ComfortableMediaSurfer
|
|
322
327
|
|
|
323
|
-
|
|
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 |
|
|
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`)
|
|
342
|
-
| `
|
|
343
|
-
| `
|
|
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
|
|
346
|
-
|
|
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 (
|
|
342
|
+
## Routes (inside the mount point)
|
|
350
343
|
|
|
351
344
|
```
|
|
352
345
|
GET / days#index overview + community + calendar
|
|
353
|
-
GET /day/:day days#show
|
|
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
|
|
361
|
-
GET /sign_in sessions#new
|
|
362
|
-
POST /sign_in/email sessions#email_link
|
|
363
|
-
GET /sign_in/email/:token sessions#email_callback
|
|
364
|
-
GET /auth/:provider/callback sessions#create
|
|
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
|
|
362
|
+
POST <mount>/auth/:provider OmniAuth middleware, not the engine
|
|
370
363
|
```
|
|
371
364
|
|
|
372
|
-
## Tuning the plan
|
|
365
|
+
## Tuning the plan
|
|
373
366
|
|
|
374
|
-
The whole schedule
|
|
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
|
-
| `
|
|
381
|
-
| `
|
|
382
|
-
| `
|
|
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
|
-
|
|
386
|
-
if you change these.
|
|
376
|
+
### Choosing where a chapter breaks
|
|
387
377
|
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
400
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
404
|
+
All computed at load and memoized — no generated schedule, so nothing to migrate if you change them.
|
|
409
405
|
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
412
|
+
**404 on the provider callback**
|
|
423
413
|
|
|
424
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
428
|
+
**Everything's unstyled in my layout**
|
|
444
429
|
|
|
445
|
-
The
|
|
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`.
|