Project

bible270

0.0
The project is in a healthy, maintained state
Drop-in Rails engine that adds a social daily Bible reading plan over 270 days: Old Testament and New Testament once, with Psalms/Proverbs alongside. Daily portions are balanced by verse count so each day takes about the same time to read. Readers check off each day's readings, leave reflections, and see one another's progress. Designed to mount cleanly into a host Rails app such as ComfortableMediaSurfer.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

Bible 270

Gem Version Gem Downloads GitHub Release Date - Published_At

A mountable Rails engine that drops a 270-day (9 month) interactive Bible reading plan into any Rails app, built with ComfortableMediaSurfer 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.

Initially created for use by students and faculty of Kingdom Movement School of Ministry, to read through all of Scripture together during the school year. Thus it is potentially useful for any Bible School, Seminary, or Discipleship school working on a 9 month schedule.

The plan

Three readings a day, sized by verse count so each day takes roughly the same time.

  • Old Testament — once through, with Psalms and Proverbs in their own track.
  • New Testament — once through, a reading every day. 260 chapters over 270 days, long chapters are divided to even out the daily reading
  • Psalms & Proverbs — once each, interleaved in one track. 181 chapters have to cover 270 days, so some 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.

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

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.

Requirements

  • Ruby >= 3.2. 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).
  • Rails >= 7.0. On Ruby 4.0 your host app is the real constraint, not this engine — check your lockfile with RailsBump first.
  • A host app, and Turbo if you want check-offs without a page reload.

Install

# Gemfile
gem "bible270"
Other sources
gem "bible270", git: "https://github.com/avonderluft/bible270.git", branch: "main"
gem "bible270", path: "<path_to_your_local_copy>/bible270"   # local checkout
bundle install
bin/rails generate bible270:install

That's the whole install. The generator is interactive and walks you through it:

  1. asks where to mount the plan;
  2. asks which sign-in methods you want — email link, OmniAuth, or both — and adds any OmniAuth strategy gems to your Gemfile;
  3. copies the four migrations into your db/migrate and offers to run them;
  4. writes config/initializers/bible270.rb (and omniauth.rb if you chose social sign-in); and
  5. adds mount Bible270::Engine, at: Bible270.config.mount_at to config/routes.rb.

(Migrations run before the config is written, so a problem in a generated initializer can't block the database work.)

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.

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.

Non-interactive (CI, re-runs, scripted setup):

bin/rails generate bible270:install --defaults \
  --mount-at=/daily-bread --providers=github,google_oauth2 \
  --mailer-from=no-reply@example.org --no-migrate
Flag Effect
--defaults Ask nothing, accept every default
--mount-at=PATH Where to mount (default /daily-bread)
--providers=a,b OmniAuth providers; --providers= for none
--email / --no-email Email sign-in on or off
--mailer-from=ADDR From: address for sign-in emails
--bundle / --no-bundle Run bundle install after adding strategy gems
--migrate / --no-migrate Run db:migrate at the end

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.

Doing it by hand instead
# config/initializers/bible270.rb
Bible270.configure do |config|
  config.mount_at   = "/daily-bread"
  config.email_sign_in = true
  config.mailer_from   = "no-reply@example.com"
  config.omniauth_providers = []            # or [:github], plus an OmniAuth initializer
end
# config/routes.rb
mount Bible270::Engine, at: Bible270.config.mount_at
bin/rails bible270:install:migrations
bin/rails db:migrate

See Authentication for the OmniAuth initializer.

Upgrading

No need to re-run the generator — just pick up any new migrations:

bundle update bible270
bin/rails bible270:install:migrations   # skips ones you already have
bin/rails db:migrate

Bible270 Mount point in your Rails app

The path lives in exactly one place, config.mount_at:

# config/initializers/bible270.rb
Bible270.configure do |config|
  config.mount_at = "/daily-bread"    # or "/read270", "/manna", whatever you wish
end

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.

Values are normalised — "read270", "/read270", "/read270/" all become /read270. Nested paths and mounting at / work too.

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.

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.

Need OmniAuth somewhere unrelated? Set config.omniauth_path_prefix and it wins.

Authentication

Two ways in, so nobody's shut out:

  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.
  2. Social sign-in via OmniAuth — for people who'd rather.

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.

Email links

Bible270.configure do |config|
  config.email_sign_in = true
  config.mailer_from   = "no-reply@example.com"
  config.email_sign_in_ask_name = true
  config.email_sign_in_require_name = true   # first AND last name       # let readers pick their display name
  # config.email_sign_in_ttl = 20 * 60       # link lifetime, seconds
  # config.email_sign_in_max_per_window = 5  # per address
  # config.email_sign_in_window = 15 * 60
  # config.email_sign_in_deliver_later = true  # needs Active Job
end

All it needs is working Action Mailer delivery.

  • Tokens: 256-bit, URL-safe, single-use, 20-minute expiry.
  • Only a SHA-256 digest is stored — no password column anywhere, so the table is useless to anyone reading your DB.
  • Claiming is a conditional update, so a double-clicked link still signs you in once.
  • "Check your inbox" reads the same whether the address was known, unknown, or rate-limited — no account enumeration.
  • Display name comes from the reader, else the address (mary.anne.smith@… → "Mary Anne Smith").
  • Bible270::SignInToken.sweep! clears spent tokens. Good cron fodder.

Email readers get provider: "email" in the same columns OmniAuth uses, so everything downstream treats them identically.

Social sign-in

bin/rails generate bible270:install sets this up: it adds the strategy gem to your Gemfile (omniauth and omniauth-rails_csrf_protection already ride along with bible270), writes the OmniAuth initializer, and prints the callback URL to register with each provider — for example https://example.com/daily-bread/auth/github/callback.

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:

# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
  # `options` is merged into every provider declared after it, so it comes first.
  # OmniAuth::Builder has no path_prefix method — this is the scoped equivalent.
  options path_prefix: Bible270.config.auth_path_prefix

  provider :github, ENV["GITHUB_CLIENT_ID"], ENV["GITHUB_CLIENT_SECRET"]
end

OmniAuth.config.on_failure = proc { |env| Bible270::SessionsController.action(:failure).call(env) }
# config/initializers/bible270.rb
Bible270.configure do |config|
  config.mount_at           = "/daily-bread"
  config.omniauth_providers = [:github]      # or [:github, [:google_oauth2, "Google"]]
  config.parent_controller  = "::ApplicationController"
end

Worth knowing:

  • Sign-in is a POST. OmniAuth 2.0+ refuses GET (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.
  • One provider → a button in the header. Several → a sign-in page at <mount>/sign_in.
  • 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.
  • reset_session on sign-in and sign-out, against session fixation.
  • Stored: provider, uid, display name, email, avatar URL. No tokens, no passwords.

Or use your own users

Already have auth? Hand the engine a resolver and it'll never touch sessions:

config.current_reader_resolver = lambda do |controller|
  user = controller.send(:current_user)
  next nil unless user
  Bible270::Reader.for_owner(user, display_name: user.try(:name) || user.email,
                             email: user.try(:email), avatar_url: user.try(:avatar_url))
end

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.

Admin panel

At <mount>/admin: remove readers, adjust their completions, move them to a given day. Unreachable — every route 404s rather than 403s — unless you say who may use it:

config.admin_emails = %w[andrew@example.org]
# or
config.admin_resolver = ->(reader) { reader.email.to_s.end_with?('@example.org') }

Admins get an extra "Admin" link in the header. From a reader's page you can:

  • move them to a day — "put them on day 42 as of today" back-dates the start date; check-offs are keyed to day numbers, so nothing in their history moves;
  • set their progress — mark complete through day N (this also clears anything after, so it sets progress exactly), or click days in the grid to toggle them;
  • remove them, with every check-off and reflection they've written;
  • moderate reflections at <mount>/admin/comments. Reflections are visible the moment they're written; hiding one takes it off the day and community pages without deleting it, and is reversible. Delete is there too, for anything that shouldn't be kept.

Break points can also live in your app rather than the gem, and be changed without a restart:

config.chapter_breaks = { 'Psalm 78' => [20, 39, 55] }
config.chapter_breaks_path = Rails.root.join('config/bible270_breaks.yml')
# config/bible270_breaks.yml — re-read whenever it changes
Psalm 35: []
Psalm 78: [20, 39, 55]

Start dates

The plan runs undated (day numbers only) or dated (days mapped onto a calendar):

config.start_date = Date.new(2026, 9, 6)   # Date, Time, or "YYYY-MM-DD". nil = undated
config.allow_reader_start_date = true      # can readers set their own?
start_date allow_reader_start_date What happens
nil true (default) Each reader gets stamped on first check-off, and can change it
a date true Community date is the default; anyone may override
a date false Everyone pinned to the community date, form hidden
nil false Fully undated — no calendar anywhere

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.

Date helpers are pure functions, usable anywhere:

Bible270::Plan.date_for(1, "2026-09-06")          # => Sun, 06 Sep 2026
Bible270::Plan.end_date_for("2026-09-06")         # => Wed, 02 Jun 2027 (day 270)
Bible270::Plan.day_for(Date.current, start)       # => 42 (clamped 1..270)
Bible270::Plan.day_for(date, start, clamp: false) # => -3 or 271, to spot out-of-range
Bible270::Plan.before_start?(date, start)

Configuration reference

Bible270.configure do |config|
  config.mount_at = "/daily-bread"         # single source of truth for the path
  config.app_name = "Daily Bread"
  config.tagline  = "A 270-day journey through Scripture"
  config.admin_emails = %w[andrew@example.org]

  # config.start_date = nil       # community start date, or nil for undated
  config.start_date = Date.new(2026, 9, 6)  
  config.allow_reader_start_date = true

  config.parent_controller = "ActionController::Base"   # or "::ApplicationController"
  config.layout            = "bible270/application"

  config.email_sign_in = true
  config.mailer_from = "no-reply@example.com"
  config.email_sign_in_ttl = 20 * 60
  config.email_sign_in_ask_name = true
  config.omniauth_providers = [:github]
  config.omniauth_path_prefix = nil        # nil = derive "<mount_at>/auth"
  config.current_reader_resolver = nil     # set to bridge your own users
  config.require_sign_in_to_participate = true

  config.after_sign_in_path  = nil         # defaults to the plan root
  config.after_sign_out_path = nil

  config.bible_version = "NKJV"
  config.passage_url_builder = ->(reference, version) {
    "https://www.biblegateway.com/passage/?search=#{URI.encode_www_form_component(reference)}&version=#{version}"
  }
end

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.

With ComfortableMediaSurfer

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.

Data model

Table Holds
bible270_readers identity: display name, avatar, provider/uid or polymorphic owner, started_on
bible270_checkoffs one row per reader per day per track (ot/nt/pp), unique-indexed
bible270_comments a reflection on a day, optionally scoped to a track, public to all
bible270_sign_in_tokens magic-link tokens: email, digest only, expiry, consumed-at

A day is "complete" once all three tracks are ticked. Progress and comments are public by design, same as Bible.com's shared plans.

Routes (inside the mount point)

GET    /                         days#index      overview + community + calendar
GET    /day/:day                 days#show       readings, who's finished, reflections
POST   /day/:day/toggle/:track   checkoffs#toggle
POST   /day/:day/comments        comments#create
DELETE /comments/:id             comments#destroy
PATCH  /start-date               readers#update_start_date
DELETE /start-date               readers#clear_start_date
GET    /community                readers#index   leaderboard
GET    /readers/:id              readers#show
GET    /sign_in                  sessions#new    email form + provider list
POST   /sign_in/email            sessions#email_link
GET    /sign_in/email/:token     sessions#email_callback
GET    /auth/:provider/callback  sessions#create
POST   /auth/:provider/callback  sessions#create
GET    /auth/failure             sessions#failure
DELETE /sign_out                 sessions#destroy

POST   <mount>/auth/:provider    OmniAuth middleware, not the engine

Tuning the plan

The whole schedule falls out of a few constants in Bible270::Plan:

Constant Default Effect
DAYS 270 plan length
PROVERBS_PASSES 2 Proverbs laps. Each pass is 31 readings, so this decides how many days are left for the Psalms
PSALM_119_SECTION_SIZE 16 verses per Psalm 119 section. 176 divides evenly by 8, 11, 16, 22 and 44
CHAPTER_BREAKS {} where specific chapters break — see below

Choosing where a chapter breaks

By default a divided chapter is cut into equal parts. To break it somewhere meaningful instead, add an entry to CHAPTER_BREAKS in lib/bible270/plan.rb. Keys are [book, chapter]; the value lists the last verse of every reading except the final one:

CHAPTER_BREAKS = {
  ['Luke', 1]   => [25, 56],   # => Luke 1:1–25, 1:26–56, 1:57–80
  ['Psalm', 78] => [39]        # => Psalm 78:1–39, 78:40–72
}.freeze

The number of breaks fixes how many readings that chapter becomes, and the rest of the plan re-divides around it so both tracks still fill exactly 270 days — give Luke 1 a third reading and some other long chapter quietly gives one up. Verse coverage stays exact either way.

Break points are validated on use: they must be ascending, distinct, and within the chapter, or you get an ArgumentError naming the entry rather than silently overlapping readings. Pinning more readings in total than there are days also raises, instead of producing a plan that doesn't fit.

Psalm 119 is pinned to PSALM_119_SECTION_SIZE sections by default; an entry in CHAPTER_BREAKS overrides that.

Change PROVERBS_PASSES or DAYS and both the New Testament and the Psalms re-divide automatically to fill whatever's left. Divisions always go to whichever chapter currently carries the heaviest reading, so the longest get split first and nothing is divided that doesn't need to be.

All computed at load and memoized — no generated schedule, so nothing to migrate if you change them.

Scaling

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.

Troubleshooting

404 on the provider callback

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.

Sign-in button does nothing, or OmniAuth::AuthenticityError

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.

No email arrives

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.

"That link has expired or was already used"

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.

Everything's unstyled in my layout

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.

Development

bundle install
rake test

Get an email link for local testing

in the rails console: rails c

_record, raw = Bible270::SignInToken.issue!("John.Smith@example.com")
Bible270::Engine.routes.url_helpers.email_sign_in_url(token: raw, host: "localhost:3333")

Plan logic is fully unit-tested with no Rails dependency.

Contributing

Bug reports and pull requests are welcome at https://github.com/avonderluft/bible270.

  1. Fork it and clone your fork.
  2. Branch off main: git checkout -b my-feature.
  3. Make your change, and add tests — rake test should stay green.
  4. Commit with a clear message: git commit -am "Add my feature".
  5. Push: git push origin my-feature.
  6. Open a pull request against main, saying what changed and why.

A few things that'll make review quick:

  • Keep plan logic free of Rails so it stays unit-testable.
  • Anything touching the reading schedule needs a test pinning the expected references — the plan is deterministic, so assert exact values.
  • Prefix new CSS classes and helpers with b270.
  • Note anything user-visible in CHANGELOG.md.

License

MIT. See MIT-LICENSE.