Class: Bible270::Reader
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- Bible270::Reader
- Defined in:
- app/models/bible270/reader.rb
Overview
A reader identity. Either self-contained (created via OmniAuth) or bridged to one of the host application's users through the polymorphic :owner.
Constant Summary collapse
- AVATAR_UPLOADS =
Active Storage is optional: an app may have it disabled, and the engine has to keep working there — readers simply can't upload, and any avatar from their sign-in provider is used instead.
respond_to?(:has_one_attached)
Class Method Summary collapse
- .avatar_uploads? ⇒ Boolean
-
.email_reader_exists?(email) ⇒ Boolean
Find or create the reader behind a verified email address.
-
.for_owner(owner, display_name:, email: nil, avatar_url: nil) ⇒ Object
Find or create a reader bridged to a host user (or any model).
- .from_email(email, first_name: nil, last_name: nil, display_name: nil) ⇒ Object
-
.from_omniauth(auth) ⇒ Object
Build/refresh a reader from an OmniAuth auth hash.
- .from_remember_cookie(reader_id, token) ⇒ Object
- .omniauth_reader_exists?(provider, uid) ⇒ Boolean
Instance Method Summary collapse
-
#attach_avatar(upload) ⇒ Object
Returns false and sets an error when the upload isn't acceptable.
- #avatar_uploaded? ⇒ Boolean
- #bible_version_label ⇒ Object
-
#calendar_day ⇒ Object
The plan day that today corresponds to (clamped into range), or nil when undated.
-
#checked_count(day) ⇒ Object
Tracks ticked on a day, counted from the single grouped query in checked_counts.
-
#checked_counts ⇒ Object
=> number of tracks checked off.
- #clear_day!(day) ⇒ Object
- #clear_start_date! ⇒ Object
- #completion_percent ⇒ Object
-
#current_day ⇒ Object
First day not yet fully complete (where the reader "is").
- #date_for_day(day) ⇒ Object
- #dated? ⇒ Boolean
- #day_complete?(day) ⇒ Boolean
- #day_status(day) ⇒ Object
- #days_completed ⇒ Object
-
#days_off_pace ⇒ Object
How many days behind (positive) or ahead (negative) of the calendar the reader's actual progress is.
-
#days_read_in(track) ⇒ Object
Days on which this track is finished.
-
#effective_bible_version ⇒ Object
The translation this reader reads in.
-
#effective_start_date ⇒ Object
The start date that actually governs this reader.
-
#ensure_started! ⇒ Object
Called when a reader first participates.
-
#first_with_last_initial ⇒ Object
Shorter than the full name, for lists where a surname is more than needed.
-
#forget! ⇒ Object
Rotating the token invalidates every device at once.
- #full_name ⇒ Object
- #initials ⇒ Object
-
#mark_day_complete!(day) ⇒ Object
Tick every track that has content on this day.
-
#mark_through!(day) ⇒ Object
Mark everything up to and including
daycomplete, and clear anything after. - #not_started_yet? ⇒ Boolean
-
#own_start_date? ⇒ Boolean
Whether this reader is following a personal date or the shared cohort one.
- #past_end_date? ⇒ Boolean
- #plan_end_date ⇒ Object
- #raw_calendar_day ⇒ Object
- #read?(day, track, part = nil) ⇒ Boolean
-
#read_parts_for(day, track) ⇒ Object
Which chapters of a track the reader has ticked on this day.
- #read_tracks_for(day) ⇒ Object
- #reload_progress ⇒ Object
-
#remember_token! ⇒ Object
Generated on first use rather than at sign-up, so readers who never stay signed in never carry one.
- #remove_avatar! ⇒ Object
-
#restart_on!(day:, on: Date.current) ⇒ Object
Put this reader on
dayas ofon— i.e. -
#set_start_date!(value) ⇒ Object
Set the start date regardless of whether readers are allowed to set their own.
-
#sort_name ⇒ Object
Readers are listed by first name, matching how they are shown — the display name is "First Last", so sorting on it needs only case folding.
- #start_date ⇒ Object
-
#start_date=(value) ⇒ Object
Set or change this reader's own start date.
-
#suggested_names ⇒ Object
Fill first/last from the display name where we only have one string, e.g.
- #today?(day) ⇒ Boolean
-
#today_day ⇒ Object
Raw, unclamped — lets callers distinguish "not started yet" / "finished".
- #toggle_day!(day) ⇒ Object
- #track_partially_read?(day, track) ⇒ Boolean
- #update_bible_version(code) ⇒ Object
-
#update_names(first, last) ⇒ Object
Day number implied by the calendar, if a start date is set.
-
#update_start_date!(value) ⇒ Object
The reader-facing version, which does respect that permission.
Class Method Details
.avatar_uploads? ⇒ Boolean
19 |
# File 'app/models/bible270/reader.rb', line 19 def self.avatar_uploads? = AVATAR_UPLOADS |
.email_reader_exists?(email) ⇒ Boolean
Find or create the reader behind a verified email address. Uses the same provider/uid identity columns as OmniAuth, with provider "email", so an email reader is indistinguishable from any other downstream. Used when enrolment is closed: an existing reader may still sign in, a new one may not be created.
56 57 58 59 60 61 |
# File 'app/models/bible270/reader.rb', line 56 def self.email_reader_exists?(email) address = EmailSignIn.normalize_email(email) return false if address.nil? exists?(provider: 'email', uid: address) end |
.for_owner(owner, display_name:, email: nil, avatar_url: nil) ⇒ Object
Find or create a reader bridged to a host user (or any model).
102 103 104 105 106 107 108 109 |
# File 'app/models/bible270/reader.rb', line 102 def self.for_owner(owner, display_name:, email: nil, avatar_url: nil) reader = find_or_initialize_by(owner: owner) reader.display_name = display_name.presence || reader.display_name || 'Reader' reader.email ||= email reader.avatar_url ||= avatar_url reader.save! reader end |
.from_email(email, first_name: nil, last_name: nil, display_name: nil) ⇒ Object
69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'app/models/bible270/reader.rb', line 69 def self.from_email(email, first_name: nil, last_name: nil, display_name: nil) address = EmailSignIn.normalize_email(email) return nil if address.nil? reader = find_or_initialize_by(provider: 'email', uid: address) reader.first_name = first_name.to_s.strip if first_name.present? reader.last_name = last_name.to_s.strip if last_name.present? reader.display_name = first_present(reader.full_name, display_name, reader.display_name, EmailSignIn.display_name_from(address), 'Reader') reader.email = address reader.save reader end |
.from_omniauth(auth) ⇒ Object
Build/refresh a reader from an OmniAuth auth hash. Tolerant of the various shapes strategies return (OmniAuth::AuthHash, plain Hash, missing info).
32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'app/models/bible270/reader.rb', line 32 def self.from_omniauth(auth) provider = dig_auth(auth, :provider).to_s uid = dig_auth(auth, :uid).to_s return nil if provider.empty? || uid.empty? info = dig_auth(auth, :info) || {} name = first_present(dig_auth(info, :name), dig_auth(info, :nickname), dig_auth(info, :first_name), dig_auth(info, :email)) reader = find_or_initialize_by(provider: provider, uid: uid) reader.display_name = first_present(name, reader.display_name, 'Reader') email = dig_auth(info, :email) image = first_present(dig_auth(info, :image), dig_auth(info, :avatar_url)) reader.email = email if email.present? reader.avatar_url = image if image.present? reader.save reader end |
.from_remember_cookie(reader_id, token) ⇒ Object
165 166 167 168 169 170 171 172 173 174 175 |
# File 'app/models/bible270/reader.rb', line 165 def self.(reader_id, token) return nil if reader_id.blank? || token.blank? reader = find_by(id: reader_id) return nil if reader.nil? || reader.remember_token.blank? # Constant-time comparison: the token is a credential. return nil unless ActiveSupport::SecurityUtils.secure_compare(reader.remember_token, token.to_s) reader end |
.omniauth_reader_exists?(provider, uid) ⇒ Boolean
63 64 65 66 67 |
# File 'app/models/bible270/reader.rb', line 63 def self.omniauth_reader_exists?(provider, uid) return false if provider.blank? || uid.blank? exists?(provider: provider.to_s, uid: uid.to_s) end |
Instance Method Details
#attach_avatar(upload) ⇒ Object
Returns false and sets an error when the upload isn't acceptable.
182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'app/models/bible270/reader.rb', line 182 def attach_avatar(upload) return false unless self.class.avatar_uploads? problem = Avatars.problem_with(content_type: upload.content_type, byte_size: upload.size) if problem errors.add(:avatar, problem) return false end avatar.attach(upload) true end |
#avatar_uploaded? ⇒ Boolean
177 178 179 |
# File 'app/models/bible270/reader.rb', line 177 def avatar_uploaded? self.class.avatar_uploads? && avatar.attached? end |
#bible_version_label ⇒ Object
129 130 131 |
# File 'app/models/bible270/reader.rb', line 129 def bible_version_label Translations.label(effective_bible_version) end |
#calendar_day ⇒ Object
The plan day that today corresponds to (clamped into range), or nil when undated.
408 409 410 |
# File 'app/models/bible270/reader.rb', line 408 def calendar_day Plan.day_for(Date.current, effective_start_date) end |
#checked_count(day) ⇒ Object
Tracks ticked on a day, counted from the single grouped query in checked_counts. Use this rather than read_tracks_for when rendering a grid: read_tracks_for costs a query per day, which is 270 of them per page.
212 213 214 |
# File 'app/models/bible270/reader.rb', line 212 def checked_count(day) checked_counts[day].to_i end |
#checked_counts ⇒ Object
=> number of tracks checked off
205 206 207 |
# File 'app/models/bible270/reader.rb', line 205 def checked_counts @checked_counts ||= checkoffs.group(:day).count end |
#clear_day!(day) ⇒ Object
324 325 326 327 328 329 330 |
# File 'app/models/bible270/reader.rb', line 324 def clear_day!(day) return false unless Plan.valid_day?(day) checkoffs.where(day: day).destroy_all reload_progress true end |
#clear_start_date! ⇒ Object
483 484 485 |
# File 'app/models/bible270/reader.rb', line 483 def clear_start_date! update!(started_on: nil) end |
#completion_percent ⇒ Object
260 261 262 |
# File 'app/models/bible270/reader.rb', line 260 def completion_percent (days_completed.to_f / Plan::DAYS * 100).round end |
#current_day ⇒ Object
First day not yet fully complete (where the reader "is").
265 266 267 |
# File 'app/models/bible270/reader.rb', line 265 def current_day (1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS end |
#date_for_day(day) ⇒ Object
443 444 445 |
# File 'app/models/bible270/reader.rb', line 443 def date_for_day(day) Plan.date_for(day, effective_start_date) end |
#dated? ⇒ Boolean
398 399 400 |
# File 'app/models/bible270/reader.rb', line 398 def dated? effective_start_date.present? end |
#day_complete?(day) ⇒ Boolean
244 245 246 247 |
# File 'app/models/bible270/reader.rb', line 244 def day_complete?(day) n = checked_counts[day].to_i n.positive? && n >= Plan.total_parts(day) end |
#day_status(day) ⇒ Object
216 217 218 219 220 221 |
# File 'app/models/bible270/reader.rb', line 216 def day_status(day) done = checked_count(day) return :none if done.zero? done >= Plan.total_parts(day) ? :complete : :partial end |
#days_completed ⇒ Object
249 250 251 |
# File 'app/models/bible270/reader.rb', line 249 def days_completed checked_counts.count { |day, n| n >= Plan.total_parts(day) } end |
#days_off_pace ⇒ Object
How many days behind (positive) or ahead (negative) of the calendar the reader's actual progress is. Nil when undated.
449 450 451 452 453 454 |
# File 'app/models/bible270/reader.rb', line 449 def days_off_pace today = calendar_day return nil unless today today - days_completed end |
#days_read_in(track) ⇒ Object
Days on which this track is finished. Counting rows would count chapters now that an Old Testament reading has one per chapter.
255 256 257 258 |
# File 'app/models/bible270/reader.rb', line 255 def days_read_in(track) checkoffs.where(track: track.to_s).group(:day).count .count { |day, done| done >= Plan.part_count(day, track) } end |
#effective_bible_version ⇒ Object
The translation this reader reads in. Null means "whatever the site default is", so changing config.bible_version moves everyone who hasn't chosen.
125 126 127 |
# File 'app/models/bible270/reader.rb', line 125 def effective_bible_version Translations.resolve(bible_version) end |
#effective_start_date ⇒ Object
The start date that actually governs this reader. A reader's own started_on wins when per-reader dates are allowed; otherwise (or if they haven't got one) the community-wide config.start_date applies. Nil means the plan is undated for this reader and no calendar mapping exists.
389 390 391 392 393 394 395 396 |
# File 'app/models/bible270/reader.rb', line 389 def effective_start_date config = Bible270.config if config.allow_reader_start_date && started_on started_on else config.start_date end end |
#ensure_started! ⇒ Object
Called when a reader first participates. Only stamps a personal start date when per-reader dates are enabled and a shared date isn't already in force.
489 490 491 492 493 494 495 496 |
# File 'app/models/bible270/reader.rb', line 489 def ensure_started! config = Bible270.config return unless config.allow_reader_start_date return if started_on.present? return if config.start_date.present? update!(started_on: Date.current) end |
#first_with_last_initial ⇒ Object
Shorter than the full name, for lists where a surname is more than needed. Falls back to the display name for readers who arrived with only one.
117 118 119 120 121 |
# File 'app/models/bible270/reader.rb', line 117 def first_with_last_initial return display_name if first_name.blank? Names.first_with_last_initial(first_name, last_name).presence || display_name end |
#forget! ⇒ Object
Rotating the token invalidates every device at once.
160 161 162 163 |
# File 'app/models/bible270/reader.rb', line 160 def forget! update_column(:remember_token, nil) true end |
#full_name ⇒ Object
111 112 113 |
# File 'app/models/bible270/reader.rb', line 111 def full_name [first_name, last_name].map { |n| n.to_s.strip }.reject(&:empty?).join(' ').presence end |
#initials ⇒ Object
200 201 202 |
# File 'app/models/bible270/reader.rb', line 200 def initials display_name.to_s.split(%r{\s+}).first(2).map { |w| w[0] }.join.upcase.presence || '?' end |
#mark_day_complete!(day) ⇒ Object
Tick every track that has content on this day.
290 291 292 293 294 295 296 297 298 299 300 301 302 303 |
# File 'app/models/bible270/reader.rb', line 290 def mark_day_complete!(day) return false unless Plan.valid_day?(day) # Deliberately not find_or_create_by!: since Rails 8.1 that creates first # and rescues RecordNotUnique, but Checkoff validates uniqueness, so a # duplicate raises RecordInvalid before the database is reached and is # never rescued. Reading what exists first is also one query per day rather # than one per chapter. missing = missing_parts_on(day) insert_checkoffs(day, missing) if missing.any? reload_progress true end |
#mark_through!(day) ⇒ Object
Mark everything up to and including day complete, and clear anything after.
337 338 339 340 341 342 343 344 345 346 347 |
# File 'app/models/bible270/reader.rb', line 337 def mark_through!(day) day = day.to_i return false unless day.between?(0, Plan::DAYS) transaction do checkoffs.where(day: (day + 1)..).delete_all (1..day).each { |d| mark_day_complete!(d) } end reload_progress true end |
#not_started_yet? ⇒ Boolean
431 432 433 |
# File 'app/models/bible270/reader.rb', line 431 def not_started_yet? Plan.before_start?(Date.current, effective_start_date) end |
#own_start_date? ⇒ Boolean
Whether this reader is following a personal date or the shared cohort one.
403 404 405 |
# File 'app/models/bible270/reader.rb', line 403 def own_start_date? Bible270.config.allow_reader_start_date && started_on.present? end |
#past_end_date? ⇒ Boolean
435 436 437 |
# File 'app/models/bible270/reader.rb', line 435 def past_end_date? Plan.after_end?(Date.current, effective_start_date) end |
#plan_end_date ⇒ Object
439 440 441 |
# File 'app/models/bible270/reader.rb', line 439 def plan_end_date Plan.end_date_for(effective_start_date) end |
#raw_calendar_day ⇒ Object
427 428 429 |
# File 'app/models/bible270/reader.rb', line 427 def raw_calendar_day Plan.day_for(Date.current, effective_start_date, clamp: false) end |
#read?(day, track, part = nil) ⇒ Boolean
232 233 234 235 236 237 |
# File 'app/models/bible270/reader.rb', line 232 def read?(day, track, part = nil) return read_parts_for(day, track).include?(part) if part # No part given: the track counts as read only when every chapter is. read_parts_for(day, track).size >= Plan.part_count(day, track) end |
#read_parts_for(day, track) ⇒ Object
Which chapters of a track the reader has ticked on this day.
228 229 230 |
# File 'app/models/bible270/reader.rb', line 228 def read_parts_for(day, track) checkoffs.where(day: day, track: track.to_s).pluck(:part) end |
#read_tracks_for(day) ⇒ Object
223 224 225 |
# File 'app/models/bible270/reader.rb', line 223 def read_tracks_for(day) checkoffs.where(day: day).pluck(:track).uniq end |
#reload_progress ⇒ Object
378 379 380 381 |
# File 'app/models/bible270/reader.rb', line 378 def reload_progress @checked_counts = nil self end |
#remember_token! ⇒ Object
Generated on first use rather than at sign-up, so readers who never stay signed in never carry one.
151 152 153 154 155 156 157 |
# File 'app/models/bible270/reader.rb', line 151 def remember_token! return remember_token if remember_token.present? token = SecureRandom.urlsafe_base64(32) update_column(:remember_token, token) token end |
#remove_avatar! ⇒ Object
195 196 197 198 |
# File 'app/models/bible270/reader.rb', line 195 def remove_avatar! avatar.purge if avatar_uploaded? true end |
#restart_on!(day:, on: Date.current) ⇒ Object
Put this reader on day as of on — i.e. back-date the start so that the
given date lands on the given day of the plan.
351 352 353 354 355 356 |
# File 'app/models/bible270/reader.rb', line 351 def restart_on!(day:, on: Date.current) day = day.to_i return false unless Plan.valid_day?(day) update!(started_on: Plan.to_date(on) - (day - 1)) end |
#set_start_date!(value) ⇒ Object
Set the start date regardless of whether readers are allowed to set their own. For administrative use: config.allow_reader_start_date governs what a reader may do to themselves, not what an admin may do on their behalf. Returns false only when the value isn't a date.
469 470 471 472 473 474 |
# File 'app/models/bible270/reader.rb', line 469 def set_start_date!(value) date = Plan.to_date(value) return false if date.nil? update!(started_on: date) end |
#sort_name ⇒ Object
Readers are listed by first name, matching how they are shown — the display name is "First Last", so sorting on it needs only case folding. Surname order was inconsistent with the community page and read oddly next to names displayed first-name-first.
143 144 145 |
# File 'app/models/bible270/reader.rb', line 143 def sort_name display_name.to_s.strip.downcase end |
#start_date ⇒ Object
461 462 463 |
# File 'app/models/bible270/reader.rb', line 461 def start_date started_on end |
#start_date=(value) ⇒ Object
Set or change this reader's own start date. Accepts a Date or a string.
457 458 459 |
# File 'app/models/bible270/reader.rb', line 457 def start_date=(value) self.started_on = Plan.to_date(value) end |
#suggested_names ⇒ Object
Fill first/last from the display name where we only have one string, e.g. a reader who arrived through OmniAuth.
281 282 283 284 285 |
# File 'app/models/bible270/reader.rb', line 281 def suggested_names return { first_name: first_name, last_name: last_name } if full_name Names.split_display_name(display_name) || { first_name: display_name, last_name: nil } end |
#today?(day) ⇒ Boolean
423 424 425 |
# File 'app/models/bible270/reader.rb', line 423 def today?(day) today_day == day end |
#today_day ⇒ Object
Raw, unclamped — lets callers distinguish "not started yet" / "finished". The plan day that today actually is, or nil when today falls outside the plan's window. calendar_day clamps, so before the start date it reports day 1 — which made day 1 claim to be "today" for anyone whose plan hadn't begun.
416 417 418 419 420 421 |
# File 'app/models/bible270/reader.rb', line 416 def today_day raw = raw_calendar_day return nil if raw.nil? Plan.valid_day?(raw) ? raw : nil end |
#toggle_day!(day) ⇒ Object
332 333 334 |
# File 'app/models/bible270/reader.rb', line 332 def toggle_day!(day) day_complete?(day) ? clear_day!(day) : mark_day_complete!(day) end |
#track_partially_read?(day, track) ⇒ Boolean
239 240 241 242 |
# File 'app/models/bible270/reader.rb', line 239 def track_partially_read?(day, track) done = read_parts_for(day, track).size done.positive? && done < Plan.part_count(day, track) end |
#update_bible_version(code) ⇒ Object
133 134 135 136 137 |
# File 'app/models/bible270/reader.rb', line 133 def update_bible_version(code) return false unless Translations.valid?(code) update(bible_version: Translations.normalize(code)) end |
#update_names(first, last) ⇒ Object
Day number implied by the calendar, if a start date is set. Set the name shown beside this reader's reflections. Returns false when either half is missing, so callers can re-render with a message.
272 273 274 275 276 277 |
# File 'app/models/bible270/reader.rb', line 272 def update_names(first, last) names = Names.normalize(first, last) return false if names.nil? update(**names) end |