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.
- .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
-
#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.
- #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) ⇒ Boolean
- #read_tracks_for(day) ⇒ Object
- #reload_progress ⇒ Object
- #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
- #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
- #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 |
.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.
148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'app/models/bible270/reader.rb', line 148 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
143 144 145 |
# File 'app/models/bible270/reader.rb', line 143 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.
334 335 336 |
# File 'app/models/bible270/reader.rb', line 334 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.
178 179 180 |
# File 'app/models/bible270/reader.rb', line 178 def checked_count(day) checked_counts[day].to_i end |
#checked_counts ⇒ Object
=> number of tracks checked off
171 172 173 |
# File 'app/models/bible270/reader.rb', line 171 def checked_counts @checked_counts ||= checkoffs.group(:day).count end |
#clear_day!(day) ⇒ Object
250 251 252 253 254 255 256 |
# File 'app/models/bible270/reader.rb', line 250 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
409 410 411 |
# File 'app/models/bible270/reader.rb', line 409 def clear_start_date! update!(started_on: nil) end |
#completion_percent ⇒ Object
210 211 212 |
# File 'app/models/bible270/reader.rb', line 210 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").
215 216 217 |
# File 'app/models/bible270/reader.rb', line 215 def current_day (1..Plan::DAYS).find { |d| !day_complete?(d) } || Plan::DAYS end |
#date_for_day(day) ⇒ Object
369 370 371 |
# File 'app/models/bible270/reader.rb', line 369 def date_for_day(day) Plan.date_for(day, effective_start_date) end |
#dated? ⇒ Boolean
324 325 326 |
# File 'app/models/bible270/reader.rb', line 324 def dated? effective_start_date.present? end |
#day_complete?(day) ⇒ Boolean
197 198 199 200 |
# File 'app/models/bible270/reader.rb', line 197 def day_complete?(day) n = checked_counts[day].to_i n.positive? && n >= Plan.required_track_count(day) end |
#day_status(day) ⇒ Object
182 183 184 185 186 187 |
# File 'app/models/bible270/reader.rb', line 182 def day_status(day) done = checked_count(day) return :none if done.zero? done >= Plan.required_track_count(day) ? :complete : :partial end |
#days_completed ⇒ Object
202 203 204 |
# File 'app/models/bible270/reader.rb', line 202 def days_completed checked_counts.count { |day, n| n >= Plan.required_track_count(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.
375 376 377 378 379 380 |
# File 'app/models/bible270/reader.rb', line 375 def days_off_pace today = calendar_day return nil unless today today - days_completed end |
#days_read_in(track) ⇒ Object
206 207 208 |
# File 'app/models/bible270/reader.rb', line 206 def days_read_in(track) checkoffs.where(track: track.to_s).count 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.
315 316 317 318 319 320 321 322 |
# File 'app/models/bible270/reader.rb', line 315 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.
415 416 417 418 419 420 421 422 |
# File 'app/models/bible270/reader.rb', line 415 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 |
#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
166 167 168 |
# File 'app/models/bible270/reader.rb', line 166 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.
240 241 242 243 244 245 246 247 248 |
# File 'app/models/bible270/reader.rb', line 240 def mark_day_complete!(day) return false unless Plan.valid_day?(day) Plan.present_tracks(day).each do |track| checkoffs.find_or_create_by!(day: day, track: track) end reload_progress true end |
#mark_through!(day) ⇒ Object
Mark everything up to and including day complete, and clear anything after.
263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'app/models/bible270/reader.rb', line 263 def mark_through!(day) return false unless day.to_i.between?(0, Plan::DAYS) transaction do checkoffs.where(day: (day.to_i + 1)..).destroy_all (1..day.to_i).each do |d| Plan.present_tracks(d).each { |t| checkoffs.find_or_create_by!(day: d, track: t) } end end reload_progress true end |
#not_started_yet? ⇒ Boolean
357 358 359 |
# File 'app/models/bible270/reader.rb', line 357 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.
329 330 331 |
# File 'app/models/bible270/reader.rb', line 329 def own_start_date? Bible270.config.allow_reader_start_date && started_on.present? end |
#past_end_date? ⇒ Boolean
361 362 363 |
# File 'app/models/bible270/reader.rb', line 361 def past_end_date? Plan.after_end?(Date.current, effective_start_date) end |
#plan_end_date ⇒ Object
365 366 367 |
# File 'app/models/bible270/reader.rb', line 365 def plan_end_date Plan.end_date_for(effective_start_date) end |
#raw_calendar_day ⇒ Object
353 354 355 |
# File 'app/models/bible270/reader.rb', line 353 def raw_calendar_day Plan.day_for(Date.current, effective_start_date, clamp: false) end |
#read?(day, track) ⇒ Boolean
193 194 195 |
# File 'app/models/bible270/reader.rb', line 193 def read?(day, track) read_tracks_for(day).include?(track.to_s) end |
#read_tracks_for(day) ⇒ Object
189 190 191 |
# File 'app/models/bible270/reader.rb', line 189 def read_tracks_for(day) checkoffs.where(day: day).pluck(:track) end |
#reload_progress ⇒ Object
304 305 306 307 |
# File 'app/models/bible270/reader.rb', line 304 def reload_progress @checked_counts = nil self end |
#remove_avatar! ⇒ Object
161 162 163 164 |
# File 'app/models/bible270/reader.rb', line 161 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.
278 279 280 281 282 283 |
# File 'app/models/bible270/reader.rb', line 278 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.
395 396 397 398 399 400 |
# File 'app/models/bible270/reader.rb', line 395 def set_start_date!(value) date = Plan.to_date(value) return false if date.nil? update!(started_on: date) end |
#sort_name ⇒ Object
139 140 141 |
# File 'app/models/bible270/reader.rb', line 139 def sort_name [last_name, first_name].map { |n| n.to_s.strip.downcase }.join(' ').strip.presence || display_name.to_s.downcase end |
#start_date ⇒ Object
387 388 389 |
# File 'app/models/bible270/reader.rb', line 387 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.
383 384 385 |
# File 'app/models/bible270/reader.rb', line 383 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.
231 232 233 234 235 |
# File 'app/models/bible270/reader.rb', line 231 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
349 350 351 |
# File 'app/models/bible270/reader.rb', line 349 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.
342 343 344 345 346 347 |
# File 'app/models/bible270/reader.rb', line 342 def today_day raw = raw_calendar_day return nil if raw.nil? Plan.valid_day?(raw) ? raw : nil end |
#toggle_day!(day) ⇒ Object
258 259 260 |
# File 'app/models/bible270/reader.rb', line 258 def toggle_day!(day) day_complete?(day) ? clear_day!(day) : mark_day_complete!(day) 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.
222 223 224 225 226 227 |
# File 'app/models/bible270/reader.rb', line 222 def update_names(first, last) names = Names.normalize(first, last) return false if names.nil? update(**names) end |