Class: Bible270::Reader

Inherits:
ApplicationRecord show all
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

Instance Method Summary collapse

Class Method Details

.avatar_uploads?Boolean

Returns:

  • (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.

Returns:

  • (Boolean)


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

Returns:

  • (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

Returns:

  • (Boolean)


143
144
145
# File 'app/models/bible270/reader.rb', line 143

def avatar_uploaded?
  self.class.avatar_uploads? && avatar.attached?
end

#bible_version_labelObject



129
130
131
# File 'app/models/bible270/reader.rb', line 129

def bible_version_label
  Translations.label(effective_bible_version)
end

#calendar_dayObject

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_countsObject

=> 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_percentObject



210
211
212
# File 'app/models/bible270/reader.rb', line 210

def completion_percent
  (days_completed.to_f / Plan::DAYS * 100).round
end

#current_dayObject

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

Returns:

  • (Boolean)


324
325
326
# File 'app/models/bible270/reader.rb', line 324

def dated?
  effective_start_date.present?
end

#day_complete?(day) ⇒ Boolean

Returns:

  • (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_completedObject



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_paceObject

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_versionObject

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_dateObject

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_initialObject

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_nameObject



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

#initialsObject



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

Returns:

  • (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.

Returns:

  • (Boolean)


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

Returns:

  • (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_dateObject



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_dayObject



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

Returns:

  • (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_progressObject



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_nameObject



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_dateObject



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_namesObject

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

Returns:

  • (Boolean)


349
350
351
# File 'app/models/bible270/reader.rb', line 349

def today?(day)
  today_day == day
end

#today_dayObject

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

#update_start_date!(value) ⇒ Object

The reader-facing version, which does respect that permission.



403
404
405
406
407
# File 'app/models/bible270/reader.rb', line 403

def update_start_date!(value)
  return false unless Bible270.config.allow_reader_start_date

  set_start_date!(value)
end